mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-28 07:32:52 +00:00
Inline style objects became gui props (Box for a div, View or Text rendering the element for the rest, since Box styles only a div), raw colours and sizes became the design's tokens, the brand swatches read @hanzo/design's own values, and the project and creation hues moved to palette.json, which is data. The newsletter's subscribe button is the standard filled Action. connect-steps.tsx, which nothing imports, is deleted with the brand constants nobody read. Pages compared before and after at 1440x900 and 390x844: blockchain, brand, directory, projects, settings, unsubscribe identical; founder 0.03% of pixels; buying specialist's title snaps from 28 to the ramp's 26px and the newsletter's button is now filled. Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
1523 lines
55 KiB
TypeScript
1523 lines
55 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Hanzo Projects Workspace — Claude-Code-style Projects with Custom Icons,
|
|
* Color Swatches, Parallel Threads Split Pane, Native Multi-Agent Imports
|
|
* (Claude, Codex, Cursor, ChatGPT), Google Workspace Sync, and Unified
|
|
* Multimodal Creation (Docs, Spreadsheets, Presentations/PPTX, Images,
|
|
* Videos, Social Posts, Ads, and Emails).
|
|
*/
|
|
|
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
import {
|
|
Activity,
|
|
Anchor as AnchorIcon,
|
|
Award,
|
|
BarChart2,
|
|
Bell,
|
|
Bookmark,
|
|
BookOpen,
|
|
Bot,
|
|
Box as BoxIcon,
|
|
Briefcase,
|
|
Calendar,
|
|
Camera,
|
|
Check,
|
|
CheckCircle,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Clock,
|
|
Cloud,
|
|
Code2,
|
|
Coffee,
|
|
Compass,
|
|
Cpu,
|
|
CreditCard,
|
|
Crown,
|
|
Database,
|
|
Diamond,
|
|
Download,
|
|
Eye,
|
|
Feather,
|
|
FileSpreadsheet,
|
|
FileText,
|
|
Film,
|
|
Flag,
|
|
Flame,
|
|
FlaskConical,
|
|
Folder,
|
|
FolderKanban,
|
|
Gift,
|
|
GitBranch,
|
|
Globe,
|
|
HardDrive,
|
|
Headphones,
|
|
Heart,
|
|
Home,
|
|
Image as ImageIcon,
|
|
Inbox,
|
|
Key,
|
|
Layers,
|
|
Layout,
|
|
Library,
|
|
Lightbulb,
|
|
Lock,
|
|
Mail,
|
|
MapPin,
|
|
Megaphone,
|
|
MessageSquare,
|
|
Mic,
|
|
Monitor,
|
|
Moon,
|
|
Music,
|
|
Package,
|
|
Palette,
|
|
Paperclip,
|
|
PenTool,
|
|
Phone,
|
|
PieChart,
|
|
Pin,
|
|
Plane,
|
|
Plus,
|
|
Presentation,
|
|
Puzzle,
|
|
Radio,
|
|
RotateCcw,
|
|
Rocket,
|
|
Search,
|
|
Send,
|
|
Server,
|
|
Settings,
|
|
Share2,
|
|
Shield,
|
|
ShoppingBag,
|
|
Sliders,
|
|
Smartphone,
|
|
Smile,
|
|
Sparkles,
|
|
Star,
|
|
Sun,
|
|
Tag,
|
|
Target,
|
|
Terminal,
|
|
ThumbsUp,
|
|
Trophy,
|
|
Truck,
|
|
Umbrella,
|
|
Upload,
|
|
Users,
|
|
Video,
|
|
WandSparkles,
|
|
Watch,
|
|
Wifi,
|
|
Wind,
|
|
Wrench,
|
|
X,
|
|
Zap,
|
|
type LucideIcon,
|
|
} from 'lucide-react'
|
|
import { Input, Text, View, XStack, YStack } from '@hanzo/ui'
|
|
import { Action } from '@hanzo/ui/marketing'
|
|
import palette from './palette.json'
|
|
import { useI18n } from '@hanzo/i18n/react'
|
|
import { useIam } from '@hanzo/iam/react'
|
|
import { useHydrated } from '@hanzo/app/lib/hydrated'
|
|
import { named } from '@hanzo/app/lib/session'
|
|
import { track } from '@hanzo/app/lib/tags'
|
|
|
|
/** The project color swatches; the hues are data (./palette.json), the same in every theme. */
|
|
export type ProjectColorId = 'default' | 'gray' | 'red' | 'orange' | 'amber' | 'green' | 'teal' | 'blue' | 'purple' | 'pink'
|
|
export const PROJECT_COLORS: readonly { id: ProjectColorId; label: string; hex: string }[] = palette.project as { id: ProjectColorId; label: string; hex: string }[]
|
|
|
|
/** 64 searchable Lucide icons for project customization. */
|
|
export const PROJECT_ICONS: readonly { id: string; label: string; Icon: LucideIcon }[] = [
|
|
{ id: 'folder', label: 'Folder', Icon: Folder },
|
|
{ id: 'code', label: 'Code', Icon: Code2 },
|
|
{ id: 'terminal', label: 'Terminal', Icon: Terminal },
|
|
{ id: 'rocket', label: 'Rocket', Icon: Rocket },
|
|
{ id: 'sparkles', label: 'Sparkles', Icon: Sparkles },
|
|
{ id: 'bot', label: 'Bot', Icon: Bot },
|
|
{ id: 'cpu', label: 'CPU', Icon: Cpu },
|
|
{ id: 'database', label: 'Database', Icon: Database },
|
|
{ id: 'globe', label: 'Globe', Icon: Globe },
|
|
{ id: 'layers', label: 'Layers', Icon: Layers },
|
|
{ id: 'briefcase', label: 'Briefcase', Icon: Briefcase },
|
|
{ id: 'book', label: 'Docs', Icon: BookOpen },
|
|
{ id: 'spreadsheet', label: 'Spreadsheet', Icon: FileSpreadsheet },
|
|
{ id: 'presentation', label: 'Slides', Icon: Presentation },
|
|
{ id: 'image', label: 'Image', Icon: ImageIcon },
|
|
{ id: 'video', label: 'Video', Icon: Video },
|
|
{ id: 'mail', label: 'Email', Icon: Mail },
|
|
{ id: 'megaphone', label: 'Ads', Icon: Megaphone },
|
|
{ id: 'share', label: 'Social', Icon: Share2 },
|
|
{ id: 'git', label: 'Git Branch', Icon: GitBranch },
|
|
{ id: 'shield', label: 'Security', Icon: Shield },
|
|
{ id: 'zap', label: 'Fast', Icon: Zap },
|
|
{ id: 'compass', label: 'Compass', Icon: Compass },
|
|
{ id: 'flame', label: 'Flame', Icon: Flame },
|
|
{ id: 'heart', label: 'Heart', Icon: Heart },
|
|
{ id: 'star', label: 'Star', Icon: Star },
|
|
{ id: 'trophy', label: 'Trophy', Icon: Trophy },
|
|
{ id: 'wrench', label: 'Wrench', Icon: Wrench },
|
|
{ id: 'sliders', label: 'Sliders', Icon: Sliders },
|
|
{ id: 'barchart', label: 'Bar Chart', Icon: BarChart2 },
|
|
{ id: 'piechart', label: 'Pie Chart', Icon: PieChart },
|
|
{ id: 'activity', label: 'Activity', Icon: Activity },
|
|
{ id: 'box', label: 'Box', Icon: BoxIcon },
|
|
{ id: 'cloud', label: 'Cloud', Icon: Cloud },
|
|
{ id: 'key', label: 'Key', Icon: Key },
|
|
{ id: 'lock', label: 'Lock', Icon: Lock },
|
|
{ id: 'music', label: 'Music', Icon: Music },
|
|
{ id: 'camera', label: 'Camera', Icon: Camera },
|
|
{ id: 'film', label: 'Film', Icon: Film },
|
|
{ id: 'palette', label: 'Palette', Icon: Palette },
|
|
{ id: 'pentool', label: 'Pen Tool', Icon: PenTool },
|
|
{ id: 'layout', label: 'Layout', Icon: Layout },
|
|
{ id: 'monitor', label: 'Monitor', Icon: Monitor },
|
|
{ id: 'smartphone', label: 'Mobile', Icon: Smartphone },
|
|
{ id: 'server', label: 'Server', Icon: Server },
|
|
{ id: 'harddrive', label: 'Storage', Icon: HardDrive },
|
|
{ id: 'wifi', label: 'Network', Icon: Wifi },
|
|
{ id: 'radio', label: 'Broadcast', Icon: Radio },
|
|
{ id: 'anchor', label: 'Anchor', Icon: AnchorIcon },
|
|
{ id: 'award', label: 'Award', Icon: Award },
|
|
{ id: 'bell', label: 'Alerts', Icon: Bell },
|
|
{ id: 'bookmark', label: 'Bookmark', Icon: Bookmark },
|
|
{ id: 'calendar', label: 'Calendar', Icon: Calendar },
|
|
{ id: 'check', label: 'Verified', Icon: CheckCircle },
|
|
{ id: 'clock', label: 'Schedule', Icon: Clock },
|
|
{ id: 'coffee', label: 'Coffee', Icon: Coffee },
|
|
{ id: 'card', label: 'Billing', Icon: CreditCard },
|
|
{ id: 'crown', label: 'Crown', Icon: Crown },
|
|
{ id: 'diamond', label: 'Diamond', Icon: Diamond },
|
|
{ id: 'eye', label: 'Vision', Icon: Eye },
|
|
{ id: 'feather', label: 'Write', Icon: Feather },
|
|
{ id: 'flag', label: 'Milestone', Icon: Flag },
|
|
{ id: 'gift', label: 'Rewards', Icon: Gift },
|
|
{ id: 'headphones', label: 'Audio', Icon: Headphones },
|
|
{ id: 'home', label: 'Home', Icon: Home },
|
|
{ id: 'inbox', label: 'Inbox', Icon: Inbox },
|
|
{ id: 'lightbulb', label: 'Idea', Icon: Lightbulb },
|
|
{ id: 'mappin', label: 'Location', Icon: MapPin },
|
|
{ id: 'mic', label: 'Voice', Icon: Mic },
|
|
{ id: 'moon', label: 'Night', Icon: Moon },
|
|
{ id: 'package', label: 'Package', Icon: Package },
|
|
{ id: 'paperclip', label: 'Attachment', Icon: Paperclip },
|
|
{ id: 'phone', label: 'Phone', Icon: Phone },
|
|
{ id: 'plane', label: 'Launch', Icon: Plane },
|
|
{ id: 'puzzle', label: 'Plugin', Icon: Puzzle },
|
|
{ id: 'shopping', label: 'Commerce', Icon: ShoppingBag },
|
|
{ id: 'smile', label: 'Customer', Icon: Smile },
|
|
{ id: 'sun', label: 'Day', Icon: Sun },
|
|
{ id: 'tag', label: 'Tag', Icon: Tag },
|
|
{ id: 'target', label: 'Goal', Icon: Target },
|
|
{ id: 'thumbsup', label: 'Approve', Icon: ThumbsUp },
|
|
{ id: 'truck', label: 'Delivery', Icon: Truck },
|
|
{ id: 'umbrella', label: 'Coverage', Icon: Umbrella },
|
|
{ id: 'users', label: 'Team', Icon: Users },
|
|
{ id: 'watch', label: 'Timer', Icon: Watch },
|
|
{ id: 'wind', label: 'Flow', Icon: Wind },
|
|
]
|
|
|
|
export interface ProjectThread {
|
|
id: string
|
|
title: string
|
|
status: 'running' | 'completed' | 'idle'
|
|
model: string
|
|
updatedAt: string
|
|
messages: { role: 'user' | 'assistant'; text: string; artifactKind?: string }[]
|
|
}
|
|
|
|
export interface ProjectItem {
|
|
id: string
|
|
name: string
|
|
repo?: string
|
|
icon: string
|
|
color: ProjectColorId
|
|
pinned?: boolean
|
|
threads: ProjectThread[]
|
|
}
|
|
|
|
const STORAGE_KEY = 'hz_projects_v1'
|
|
|
|
const DEFAULT_PROJECTS: ProjectItem[] = [
|
|
{
|
|
id: 'hanzo-app',
|
|
name: 'hanzo.ai',
|
|
repo: 'hanzo-inc/app',
|
|
icon: 'rocket',
|
|
color: 'purple',
|
|
pinned: true,
|
|
threads: [
|
|
{
|
|
id: 'th-1',
|
|
title: 'Beta funnel & viral referral loop',
|
|
status: 'completed',
|
|
model: 'Zen 4.7 Ultra',
|
|
updatedAt: '2m ago',
|
|
messages: [
|
|
{ role: 'user', text: 'Audit and wire the /beta referral loop, points leaderboard, and 36 feature-flagged betas.' },
|
|
{ role: 'assistant', text: 'Completed: wired /v1/marketing/beta actions, social links, 36 feature-flagged betas, and mandatory account creation before dashboard.' },
|
|
],
|
|
},
|
|
{
|
|
id: 'th-2',
|
|
title: 'Landing page narrative & conversion',
|
|
status: 'running',
|
|
model: 'Enso Ultra',
|
|
updatedAt: 'Just now',
|
|
messages: [
|
|
{ role: 'user', text: 'Condense the landing page narrative to highlight Latest Releases, Frontier Proof, Open Apps, and OSS.' },
|
|
{ role: 'assistant', text: 'Updated Home.tsx with progressive benchmark disclosure and direct conversion paths.' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'hanzo-cloud',
|
|
name: 'cloud-api',
|
|
repo: 'hanzo-inc/cloud',
|
|
icon: 'server',
|
|
color: 'blue',
|
|
pinned: true,
|
|
threads: [],
|
|
},
|
|
{
|
|
id: 'zen-models',
|
|
name: 'zen-research',
|
|
repo: 'hanzoai/zen',
|
|
icon: 'cpu',
|
|
color: 'teal',
|
|
pinned: false,
|
|
threads: [],
|
|
},
|
|
{
|
|
id: 'growth-studio',
|
|
name: 'q4-campaign-assets',
|
|
repo: 'hanzo-inc/marketing',
|
|
icon: 'megaphone',
|
|
color: 'orange',
|
|
pinned: false,
|
|
threads: [],
|
|
},
|
|
]
|
|
|
|
const STARTER_SUGGESTIONS = [
|
|
'Set up this project from my recent work',
|
|
'Read repo and propose next steps',
|
|
'Check that repo can build and run',
|
|
'Build the next feature',
|
|
] as const
|
|
|
|
/** Unified Multimodal Creation formats for real professionals. */
|
|
const MULTIMODAL_CREATIONS: readonly {
|
|
id: string
|
|
label: string
|
|
desc: string
|
|
Icon: LucideIcon
|
|
color: string
|
|
prompt: string
|
|
}[] = [
|
|
{ id: 'doc', label: 'Doc / PRD', desc: 'Specs, RFCs & Google Docs', Icon: FileText, color: palette.creation.doc, prompt: 'Draft a comprehensive technical specification and PRD for ' },
|
|
{ id: 'sheet', label: 'Spreadsheet', desc: 'Financial models, KPIs & CSV', Icon: FileSpreadsheet, color: palette.creation.sheet, prompt: 'Build a structured spreadsheet model with formulas and projections for ' },
|
|
{ id: 'pptx', label: 'Presentation (PPTX)', desc: 'Executive & investor slide decks', Icon: Presentation, color: palette.creation.pptx, prompt: 'Create a 12-slide executive presentation deck (PPTX) covering ' },
|
|
{ id: 'image', label: 'Image', desc: 'Product visuals, diagrams & hero art', Icon: ImageIcon, color: palette.creation.image, prompt: 'Generate high-resolution studio imagery and visual assets for ' },
|
|
{ id: 'video', label: 'Video', desc: 'Launch trailers & product demos', Icon: Video, color: palette.creation.video, prompt: 'Storyboard and render a 60-second product launch video for ' },
|
|
{ id: 'social', label: 'Social Posts', desc: 'X, LinkedIn, Reddit & TikTok threads', Icon: Share2, color: palette.creation.social, prompt: 'Write a multi-channel social launch thread (X, LinkedIn, HN, Reddit) for ' },
|
|
{ id: 'ads', label: 'Ad Campaign', desc: 'Meta, Google & X conversion creatives', Icon: Megaphone, color: palette.creation.ads, prompt: 'Create high-converting paid ad copy, hooks, and targeting variants for ' },
|
|
{ id: 'email', label: 'Email Sequence', desc: 'Onboarding & lifecycle dispatches', Icon: Mail, color: palette.creation.email, prompt: 'Design a 5-part developer onboarding and conversion email sequence for ' },
|
|
]
|
|
|
|
/** Import sources from external coding agents & Google Workspace. */
|
|
const IMPORT_SOURCES = [
|
|
{ id: 'claude', name: 'Claude / Claude Code', desc: 'Import CLAUDE.md, Projects, MCP configs & conversation history', badge: 'Native' },
|
|
{ id: 'codex', name: 'OpenAI Codex / ChatGPT', desc: 'Import threads, AGENTS.md, custom instructions & workspace context', badge: 'Native' },
|
|
{ id: 'cursor', name: 'Cursor / Windsurf', desc: 'Import .cursorrules, workspace index & active agent rules', badge: 'Native' },
|
|
{ id: 'google', name: 'Google Workspace (Drive, Docs, Sheets, Slides)', desc: 'Sync live documents, spreadsheets, presentations & Gmail context', badge: 'OAuth 2.0' },
|
|
] as const
|
|
|
|
export function getIconComponent(id: string): LucideIcon {
|
|
return PROJECT_ICONS.find((i) => i.id === id)?.Icon ?? Folder
|
|
}
|
|
|
|
export function getColorHex(id: ProjectColorId): string {
|
|
return PROJECT_COLORS.find((c) => c.id === id)?.hex ?? 'var(--foreground)'
|
|
}
|
|
|
|
export function ProjectsWorkspace() {
|
|
const { t } = useI18n()
|
|
const hydrated = useHydrated()
|
|
const { user, isAuthenticated } = useIam()
|
|
const token = hydrated && isAuthenticated ? named() : null
|
|
const displayName = String(user?.displayName || user?.name || token?.name || 'Developer').split(' ')[0]
|
|
|
|
const [projects, setProjects] = useState<ProjectItem[]>(DEFAULT_PROJECTS)
|
|
const [activeProjectId, setActiveProjectId] = useState<string>(DEFAULT_PROJECTS[0].id)
|
|
const [activeThreadId, setActiveThreadId] = useState<string | null>(null)
|
|
const [pickerForProject, setPickerForProject] = useState<string | null>(null)
|
|
const [iconQuery, setIconQuery] = useState('')
|
|
const [composerText, setComposerText] = useState('')
|
|
const [steerText, setSteerText] = useState('')
|
|
const [selectedModel, setSelectedModel] = useState('Zen 4.7 (1M context)')
|
|
const [autoContinueOnReset, setAutoContinueOnReset] = useState(true)
|
|
const [showImportModal, setShowImportModal] = useState(false)
|
|
const [importedSources, setImportedSources] = useState<string[]>([])
|
|
const [showNewProjectModal, setShowNewProjectModal] = useState(false)
|
|
const [newProjectName, setNewProjectName] = useState('')
|
|
const [newProjectRepo, setNewProjectRepo] = useState('')
|
|
|
|
// Load persisted projects
|
|
useEffect(() => {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY)
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw) as ProjectItem[]
|
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
setProjects(parsed)
|
|
setActiveProjectId(parsed[0].id)
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, [])
|
|
|
|
const saveProjects = (next: ProjectItem[]) => {
|
|
setProjects(next)
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
const activeProject = useMemo(
|
|
() => projects.find((p) => p.id === activeProjectId) ?? projects[0],
|
|
[projects, activeProjectId],
|
|
)
|
|
|
|
const activeThread = useMemo(
|
|
() => activeProject?.threads.find((th) => th.id === activeThreadId) ?? activeProject?.threads[0] ?? null,
|
|
[activeProject, activeThreadId],
|
|
)
|
|
|
|
const updateProjectAppearance = (projectId: string, patch: Partial<Pick<ProjectItem, 'icon' | 'color' | 'pinned'>>) => {
|
|
const next = projects.map((p) => (p.id === projectId ? { ...p, ...patch } : p))
|
|
saveProjects(next)
|
|
track('project_appearance_updated', { project_id: projectId, ...patch })
|
|
}
|
|
|
|
const spawnThread = (promptText: string, artifactKind?: string) => {
|
|
const trimmed = promptText.trim()
|
|
if (!trimmed || !activeProject) return
|
|
const newThread: ProjectThread = {
|
|
id: `th-${Date.now()}`,
|
|
title: trimmed.slice(0, 56),
|
|
status: 'running',
|
|
model: selectedModel,
|
|
updatedAt: 'Just now',
|
|
messages: [
|
|
{ role: 'user', text: trimmed, artifactKind },
|
|
{
|
|
role: 'assistant',
|
|
text: artifactKind
|
|
? `Spinning up parallel ${artifactKind.toUpperCase()} creation thread in ${activeProject.name} using ${selectedModel}. Coordinating context across your repository and connected workspace...`
|
|
: `Coordinating thread "${trimmed.slice(0, 48)}" across ${activeProject.repo || activeProject.name}. Inspecting context and executing steps in parallel...`,
|
|
artifactKind,
|
|
},
|
|
],
|
|
}
|
|
const next = projects.map((p) =>
|
|
p.id === activeProject.id ? { ...p, threads: [newThread, ...p.threads] } : p,
|
|
)
|
|
saveProjects(next)
|
|
setActiveThreadId(newThread.id)
|
|
setComposerText('')
|
|
track('project_thread_spawned', { project_id: activeProject.id, model: selectedModel, artifact: artifactKind ?? 'code' })
|
|
}
|
|
|
|
const steerActiveThread = () => {
|
|
const trimmed = steerText.trim()
|
|
if (!trimmed || !activeProject || !activeThread) return
|
|
const next = projects.map((p) => {
|
|
if (p.id !== activeProject.id) return p
|
|
return {
|
|
...p,
|
|
threads: p.threads.map((th) =>
|
|
th.id === activeThread.id
|
|
? {
|
|
...th,
|
|
updatedAt: 'Just now',
|
|
messages: [
|
|
...th.messages,
|
|
{ role: 'user' as const, text: trimmed },
|
|
{ role: 'assistant' as const, text: `Updated thread direction: "${trimmed}". Continuing execution with ${selectedModel}.` },
|
|
],
|
|
}
|
|
: th,
|
|
),
|
|
}
|
|
})
|
|
saveProjects(next)
|
|
setSteerText('')
|
|
track('project_thread_steered', { project_id: activeProject.id, thread_id: activeThread.id })
|
|
}
|
|
|
|
const createNewProject = () => {
|
|
const name = newProjectName.trim()
|
|
if (!name) return
|
|
const item: ProjectItem = {
|
|
id: `proj-${Date.now()}`,
|
|
name,
|
|
repo: newProjectRepo.trim() || undefined,
|
|
icon: 'rocket',
|
|
color: 'blue',
|
|
pinned: true,
|
|
threads: [],
|
|
}
|
|
const next = [item, ...projects]
|
|
saveProjects(next)
|
|
setActiveProjectId(item.id)
|
|
setNewProjectName('')
|
|
setNewProjectRepo('')
|
|
setShowNewProjectModal(false)
|
|
track('project_created', { name })
|
|
}
|
|
|
|
const pinnedProjects = projects.filter((p) => p.pinned)
|
|
const otherProjects = projects.filter((p) => !p.pinned)
|
|
const filteredIcons = PROJECT_ICONS.filter(
|
|
(i) => !iconQuery.trim() || i.label.toLowerCase().includes(iconQuery.toLowerCase()) || i.id.includes(iconQuery.toLowerCase()),
|
|
)
|
|
|
|
return (
|
|
<XStack minH="100svh" width="100%" bg="var(--background)">
|
|
{/* 1. Left App Bar Rail */}
|
|
<AppRail />
|
|
|
|
{/* 2. Projects & Recents Sidebar with Custom Icons & Colors */}
|
|
<YStack
|
|
width={256}
|
|
shrink={0}
|
|
borderRightWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="$panel"
|
|
p={12}
|
|
gap={14}
|
|
display="none"
|
|
$md={{ display: 'flex' }}
|
|
position="sticky"
|
|
t={0}
|
|
height="100svh"
|
|
overflowY="auto"
|
|
>
|
|
<XStack items="center" justify="space-between" px={6} py={4}>
|
|
<Text fontSize="$4" fontWeight="700" color="var(--foreground)">
|
|
{t('Projects')}
|
|
</Text>
|
|
<XStack items="center" gap={4}>
|
|
<XStack
|
|
render={<button type="button" onClick={() => setShowImportModal(true)} title="Import from Claude, Codex, Cursor & Google" />}
|
|
px={8}
|
|
py={4}
|
|
rounded={8}
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="transparent"
|
|
cursor="pointer"
|
|
items="center"
|
|
gap={4}
|
|
hoverStyle={{ borderColor: 'var(--foreground)' }}
|
|
>
|
|
<Download size={12} color="var(--foreground)" />
|
|
<Text fontFamily="$mono" fontSize={10} fontWeight="600" color="var(--foreground)">
|
|
Import
|
|
</Text>
|
|
</XStack>
|
|
<XStack
|
|
render={<button type="button" onClick={() => setShowNewProjectModal(true)} aria-label="New project" title="New project" />}
|
|
width={26}
|
|
height={26}
|
|
rounded={8}
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="transparent"
|
|
cursor="pointer"
|
|
items="center"
|
|
justify="center"
|
|
hoverStyle={{ borderColor: 'var(--foreground)' }}
|
|
>
|
|
<Plus size={14} color="var(--foreground)" />
|
|
</XStack>
|
|
</XStack>
|
|
</XStack>
|
|
|
|
{/* Pinned Projects */}
|
|
{pinnedProjects.length > 0 ? (
|
|
<YStack gap={4}>
|
|
<Text px={8} fontFamily="$mono" fontSize={11} fontWeight="600" color="var(--muted-foreground)">
|
|
{t('Pinned')}
|
|
</Text>
|
|
{pinnedProjects.map((proj) => (
|
|
<ProjectRow
|
|
key={proj.id}
|
|
project={proj}
|
|
active={proj.id === activeProject?.id}
|
|
onSelect={() => {
|
|
setActiveProjectId(proj.id)
|
|
setActiveThreadId(proj.threads[0]?.id ?? null)
|
|
}}
|
|
onOpenIconPicker={() => setPickerForProject(pickerForProject === proj.id ? null : proj.id)}
|
|
/>
|
|
))}
|
|
</YStack>
|
|
) : null}
|
|
|
|
{/* All Projects */}
|
|
<YStack gap={4}>
|
|
<XStack items="center" justify="space-between" px={8}>
|
|
<Text fontFamily="$mono" fontSize={11} fontWeight="600" color="var(--muted-foreground)">
|
|
{t('Projects')}
|
|
</Text>
|
|
</XStack>
|
|
{otherProjects.map((proj) => (
|
|
<ProjectRow
|
|
key={proj.id}
|
|
project={proj}
|
|
active={proj.id === activeProject?.id}
|
|
onSelect={() => {
|
|
setActiveProjectId(proj.id)
|
|
setActiveThreadId(proj.threads[0]?.id ?? null)
|
|
}}
|
|
onOpenIconPicker={() => setPickerForProject(pickerForProject === proj.id ? null : proj.id)}
|
|
/>
|
|
))}
|
|
</YStack>
|
|
|
|
{/* Icon & Color Picker Popover (matches Screenshot 19-05-24 & 19-05-58) */}
|
|
{pickerForProject ? (
|
|
<YStack
|
|
p={12}
|
|
gap={10}
|
|
rounded={14}
|
|
borderWidth={1}
|
|
borderColor="var(--border-strong)"
|
|
bg="var(--background)"
|
|
$platform-web={{ boxShadow: "0 16px 40px var(--surface-scrim)" }}
|
|
>
|
|
<XStack items="center" justify="space-between">
|
|
<Text fontFamily="$mono" fontSize={11} fontWeight="600" color="var(--muted-foreground)">
|
|
Customize icon & color
|
|
</Text>
|
|
<XStack
|
|
render={<button type="button" onClick={() => setPickerForProject(null)} aria-label="Close icon picker" />}
|
|
width={20}
|
|
height={20}
|
|
items="center"
|
|
justify="center"
|
|
rounded={6}
|
|
bg="transparent"
|
|
borderWidth={0}
|
|
cursor="pointer"
|
|
>
|
|
<X size={13} color="var(--muted-foreground)" />
|
|
</XStack>
|
|
</XStack>
|
|
|
|
<Input
|
|
size="sm"
|
|
height={32}
|
|
fontSize={12}
|
|
rounded={8}
|
|
bg="var(--muted)"
|
|
placeholder="Search icons..."
|
|
value={iconQuery}
|
|
onChange={(e) => setIconQuery(e.target.value)}
|
|
/>
|
|
|
|
{/* 10 Color Swatches */}
|
|
<XStack items="center" justify="space-between" gap={4} py={2}>
|
|
{PROJECT_COLORS.map((c) => {
|
|
const targetProj = projects.find((p) => p.id === pickerForProject)
|
|
const selected = targetProj?.color === c.id
|
|
return (
|
|
<XStack
|
|
key={c.id}
|
|
render={
|
|
<button
|
|
type="button"
|
|
aria-label={`Color ${c.label}`}
|
|
onClick={() => updateProjectAppearance(pickerForProject, { color: c.id })}
|
|
/>
|
|
}
|
|
width={18}
|
|
height={18}
|
|
rounded={9999}
|
|
borderWidth={selected ? 2 : 1}
|
|
borderColor={selected ? 'var(--foreground)' : 'var(--white-20)'}
|
|
cursor="pointer"
|
|
$platform-web={{ backgroundColor: c.hex, outline: selected ? '2px solid var(--background)' : undefined, outlineOffset: -3 }}
|
|
/>
|
|
)
|
|
})}
|
|
</XStack>
|
|
|
|
{/* Searchable 64-Icon Grid */}
|
|
<View
|
|
display="grid"
|
|
gap={4}
|
|
maxH={168}
|
|
overflowY="auto"
|
|
$platform-web={{ gridTemplateColumns: "repeat(7, minmax(0, 1fr))" }}
|
|
>
|
|
{filteredIcons.map(({ id, label, Icon }) => {
|
|
const targetProj = projects.find((p) => p.id === pickerForProject)
|
|
const selected = targetProj?.icon === id
|
|
const activeColor = getColorHex(targetProj?.color ?? 'default')
|
|
return (
|
|
<XStack
|
|
key={id}
|
|
render={
|
|
<button
|
|
type="button"
|
|
title={label}
|
|
aria-label={label}
|
|
onClick={() => updateProjectAppearance(pickerForProject, { icon: id })}
|
|
/>
|
|
}
|
|
width={28}
|
|
height={28}
|
|
rounded={6}
|
|
items="center"
|
|
justify="center"
|
|
borderWidth={selected ? 1 : 0}
|
|
borderColor={selected ? 'var(--foreground)' : 'transparent'}
|
|
bg={selected ? 'var(--secondary)' : 'transparent'}
|
|
cursor="pointer"
|
|
hoverStyle={{ bg: 'var(--secondary)' }}
|
|
>
|
|
<Icon size={15} color={selected ? activeColor : 'var(--muted-foreground)'} />
|
|
</XStack>
|
|
)
|
|
})}
|
|
</View>
|
|
|
|
<XStack items="center" justify="space-between" pt={6} borderTopWidth={1} borderColor="var(--border)">
|
|
<Text
|
|
render={
|
|
<button
|
|
type="button"
|
|
onClick={() => updateProjectAppearance(pickerForProject, { icon: 'folder', color: 'default' })}
|
|
/>
|
|
}
|
|
bg="transparent"
|
|
borderWidth={0}
|
|
cursor="pointer"
|
|
fontSize={11}
|
|
color="var(--muted-foreground)"
|
|
hoverStyle={{ color: 'var(--foreground)' }}
|
|
>
|
|
Reset icon and color
|
|
</Text>
|
|
<Text
|
|
render={
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const targetProj = projects.find((p) => p.id === pickerForProject)
|
|
if (targetProj) updateProjectAppearance(pickerForProject, { pinned: !targetProj.pinned })
|
|
}}
|
|
/>
|
|
}
|
|
bg="transparent"
|
|
borderWidth={0}
|
|
cursor="pointer"
|
|
fontSize={11}
|
|
color="var(--foreground)"
|
|
>
|
|
{projects.find((p) => p.id === pickerForProject)?.pinned ? 'Unpin' : 'Pin project'}
|
|
</Text>
|
|
</XStack>
|
|
</YStack>
|
|
) : null}
|
|
|
|
{/* Recent Threads across Projects */}
|
|
<YStack gap={4} mt="auto" pt={12} borderTopWidth={1} borderColor="var(--border)">
|
|
<Text px={8} fontFamily="$mono" fontSize={11} fontWeight="600" color="var(--muted-foreground)">
|
|
{t('Recent threads')}
|
|
</Text>
|
|
{projects
|
|
.flatMap((p) => p.threads.map((th) => ({ ...th, project: p })))
|
|
.slice(0, 5)
|
|
.map((th) => (
|
|
<XStack
|
|
key={th.id}
|
|
render={
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setActiveProjectId(th.project.id)
|
|
setActiveThreadId(th.id)
|
|
}}
|
|
/>
|
|
}
|
|
items="center"
|
|
justify="space-between"
|
|
px={8}
|
|
py={6}
|
|
rounded={8}
|
|
bg={activeThread?.id === th.id ? 'var(--secondary)' : 'transparent'}
|
|
borderWidth={0}
|
|
cursor="pointer"
|
|
hoverStyle={{ bg: 'var(--secondary)' }}
|
|
>
|
|
<Text
|
|
fontSize={12}
|
|
color="var(--foreground)"
|
|
numberOfLines={1}
|
|
flex={1}
|
|
text="start"
|
|
>
|
|
{th.title}
|
|
</Text>
|
|
<Text fontFamily="$mono" fontSize={10} color="var(--muted-foreground)">
|
|
{th.updatedAt}
|
|
</Text>
|
|
</XStack>
|
|
))}
|
|
</YStack>
|
|
</YStack>
|
|
|
|
{/* 3. Main Project Coordinator Pane + 4. Parallel Threads Split Pane */}
|
|
<XStack flex={1} minW={0} minH="100svh">
|
|
{/* Center: Project Coordinator & Multimodal Creation */}
|
|
<YStack
|
|
flex={1}
|
|
minW={0}
|
|
p={24}
|
|
justify="space-between"
|
|
gap={24}
|
|
maxH="100svh"
|
|
overflowY="auto"
|
|
>
|
|
{/* Top Bar: Project Switcher + Repo + Import/Connect */}
|
|
<XStack items="center" justify="space-between" flexWrap="wrap" gap={12}>
|
|
<XStack items="center" gap={10}>
|
|
{activeProject ? (
|
|
<XStack
|
|
render={
|
|
<button
|
|
type="button"
|
|
onClick={() => setPickerForProject(pickerForProject === activeProject.id ? null : activeProject.id)}
|
|
/>
|
|
}
|
|
items="center"
|
|
gap={8}
|
|
px={12}
|
|
py={6}
|
|
rounded={10}
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="$panel"
|
|
cursor="pointer"
|
|
hoverStyle={{ borderColor: 'var(--foreground)' }}
|
|
>
|
|
{(() => {
|
|
const Icon = getIconComponent(activeProject.icon)
|
|
return <Icon size={16} color={getColorHex(activeProject.color)} />
|
|
})()}
|
|
<Text fontSize="$3" fontWeight="600" color="var(--foreground)">
|
|
{activeProject.name}
|
|
</Text>
|
|
<ChevronDown size={14} color="var(--muted-foreground)" />
|
|
</XStack>
|
|
) : null}
|
|
{activeProject?.repo ? (
|
|
<XStack items="center" gap={6} px={10} py={5} rounded={8} borderWidth={1} borderColor="var(--border)">
|
|
<GitBranch size={12} color="var(--muted-foreground)" />
|
|
<Text fontFamily="$mono" fontSize={11} color="var(--muted-foreground)">
|
|
{activeProject.repo}
|
|
</Text>
|
|
</XStack>
|
|
) : null}
|
|
</XStack>
|
|
|
|
<XStack items="center" gap={8} flexWrap="wrap">
|
|
<XStack
|
|
render={<button type="button" onClick={() => setShowImportModal(true)} />}
|
|
items="center"
|
|
gap={6}
|
|
px={12}
|
|
py={6}
|
|
rounded={9999}
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="transparent"
|
|
cursor="pointer"
|
|
hoverStyle={{ borderColor: 'var(--foreground)' }}
|
|
>
|
|
<Upload size={13} color="var(--foreground)" />
|
|
<Text fontSize={12} fontWeight="500" color="var(--foreground)">
|
|
Import Claude / Codex / Google
|
|
</Text>
|
|
{importedSources.length > 0 ? (
|
|
<View px={6} py={1} rounded={9999} bg="var(--foreground)">
|
|
<Text fontFamily="$mono" fontSize={10} fontWeight="700" color="var(--background)">
|
|
{importedSources.length}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
</XStack>
|
|
<Action href="/beta">
|
|
<FlaskConical size={13} aria-hidden />
|
|
Betas
|
|
</Action>
|
|
</XStack>
|
|
</XStack>
|
|
|
|
{/* Center Coordinator Greeting & Starter Suggestions (matches Screenshot 19-06-03 & 19-06-08) */}
|
|
<YStack maxW={680} width="100%" mx="auto" gap={24} my="auto">
|
|
<YStack gap={12}>
|
|
<Text
|
|
render="h1"
|
|
fontSize="$6"
|
|
fontWeight="500"
|
|
color="var(--foreground)"
|
|
$platform-web={{ lineHeight: '1.4' }}
|
|
>
|
|
Hi {displayName} 👋 welcome to your new project. I coordinate your work — tell me what you're trying to do and I'll spin up threads, manage context, and keep everything moving.
|
|
</Text>
|
|
</YStack>
|
|
|
|
{/* Starter Suggestions List */}
|
|
<YStack gap={2}>
|
|
<Text fontFamily="$mono" fontSize={11} fontWeight="600" color="var(--muted-foreground)" pb={4}>
|
|
Suggestions
|
|
</Text>
|
|
{STARTER_SUGGESTIONS.map((s) => (
|
|
<XStack
|
|
key={s}
|
|
render={<button type="button" onClick={() => spawnThread(s)} />}
|
|
items="center"
|
|
justify="space-between"
|
|
px={12}
|
|
py={10}
|
|
rounded={10}
|
|
borderWidth={1}
|
|
borderColor="transparent"
|
|
bg="transparent"
|
|
cursor="pointer"
|
|
hoverStyle={{ bg: 'var(--secondary)', borderColor: 'var(--border)' }}
|
|
>
|
|
<Text fontSize="$3" color="var(--muted-foreground)">
|
|
{s}
|
|
</Text>
|
|
<ChevronRight size={14} color="var(--muted-foreground)" />
|
|
</XStack>
|
|
))}
|
|
</YStack>
|
|
|
|
{/* Unified Multimodal Professional Creation Suite */}
|
|
<YStack gap={10}>
|
|
<XStack items="center" justify="space-between">
|
|
<Text fontFamily="$mono" fontSize={11} fontWeight="600" color="var(--muted-foreground)">
|
|
CREATE IN UNIFIED WORKSPACE (DOCS · SHEETS · PPTX · STUDIO · SOCIAL · ADS · EMAIL)
|
|
</Text>
|
|
</XStack>
|
|
<View display="grid" gap={8} $sm={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }} $lg={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
|
|
{MULTIMODAL_CREATIONS.map((item) => {
|
|
const Icon = item.Icon
|
|
return (
|
|
<YStack
|
|
key={item.id}
|
|
render={
|
|
<button
|
|
type="button"
|
|
onClick={() => spawnThread(`${item.prompt}${activeProject?.name ?? 'our product'}`, item.id)}
|
|
/>
|
|
}
|
|
p={12}
|
|
gap={6}
|
|
rounded={12}
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="$panel"
|
|
cursor="pointer"
|
|
hoverStyle={{ borderColor: 'var(--foreground)' }}
|
|
$platform-web={{ textAlign: "start" }}
|
|
>
|
|
<XStack items="center" justify="space-between">
|
|
<Icon size={16} color={item.color} />
|
|
<Plus size={12} color="var(--muted-foreground)" />
|
|
</XStack>
|
|
<Text fontSize={13} fontWeight="600" color="var(--foreground)">
|
|
{item.label}
|
|
</Text>
|
|
<Text fontSize={11} color="var(--muted-foreground)" lineHeight={15}>
|
|
{item.desc}
|
|
</Text>
|
|
</YStack>
|
|
)
|
|
})}
|
|
</View>
|
|
</YStack>
|
|
</YStack>
|
|
|
|
{/* Bottom Coordinator Composer */}
|
|
<YStack maxW={680} width="100%" mx="auto" gap={8}>
|
|
<YStack
|
|
p={12}
|
|
gap={10}
|
|
rounded={16}
|
|
borderWidth={1}
|
|
borderColor="var(--border-strong)"
|
|
bg="$panel"
|
|
>
|
|
<Input
|
|
borderWidth={0}
|
|
bg="transparent"
|
|
placeholder={`Coordinate work in ${activeProject?.name ?? 'project'} — ask to code, generate docs, decks, sheets, ads, or videos...`}
|
|
value={composerText}
|
|
onChange={(e) => setComposerText(e.target.value)}
|
|
onKeyDown={(e: { key?: string; preventDefault?: () => void }) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault?.()
|
|
spawnThread(composerText)
|
|
}
|
|
}}
|
|
/>
|
|
<XStack items="center" justify="space-between" flexWrap="wrap" gap={8}>
|
|
<XStack items="center" gap={8}>
|
|
<XStack
|
|
render={<button type="button" onClick={() => setShowImportModal(true)} title="Attach or import context" />}
|
|
width={28}
|
|
height={28}
|
|
rounded={8}
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="transparent"
|
|
items="center"
|
|
justify="center"
|
|
cursor="pointer"
|
|
>
|
|
<Plus size={14} color="var(--muted-foreground)" />
|
|
</XStack>
|
|
<Text fontFamily="$mono" fontSize={11} color="var(--muted-foreground)">
|
|
{selectedModel}
|
|
</Text>
|
|
</XStack>
|
|
<Action
|
|
render="button"
|
|
type="button"
|
|
fill
|
|
disabled={!composerText.trim()}
|
|
onClick={() => spawnThread(composerText)}
|
|
>
|
|
Run thread
|
|
<Send size={13} aria-hidden />
|
|
</Action>
|
|
</XStack>
|
|
</YStack>
|
|
</YStack>
|
|
</YStack>
|
|
|
|
{/* Right Split Pane: Parallel Threads Inspector (matches Screenshot 19-06-08 & 19-06-32) */}
|
|
<YStack
|
|
width={380}
|
|
shrink={0}
|
|
borderLeftWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="$panel"
|
|
p={16}
|
|
justify="space-between"
|
|
gap={16}
|
|
display="none"
|
|
$lg={{ display: 'flex' }}
|
|
height="100svh"
|
|
overflowY="auto"
|
|
>
|
|
{/* Threads Breadcrumb Header */}
|
|
<YStack gap={12}>
|
|
<XStack items="center" justify="space-between">
|
|
<XStack items="center" gap={6}>
|
|
<Text fontSize={13} fontWeight="600" color="var(--muted-foreground)">
|
|
Threads
|
|
</Text>
|
|
{activeThread ? (
|
|
<>
|
|
<ChevronRight size={13} color="var(--muted-foreground)" />
|
|
<Text fontSize={13} fontWeight="600" color="var(--foreground)" numberOfLines={1}>
|
|
{activeThread.title}
|
|
</Text>
|
|
</>
|
|
) : null}
|
|
</XStack>
|
|
<XStack
|
|
render={<button type="button" onClick={() => spawnThread('New parallel task')} title="New thread" />}
|
|
width={26}
|
|
height={26}
|
|
rounded={6}
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="transparent"
|
|
items="center"
|
|
justify="center"
|
|
cursor="pointer"
|
|
>
|
|
<Plus size={13} color="var(--foreground)" />
|
|
</XStack>
|
|
</XStack>
|
|
|
|
{/* Thread Switcher Pills */}
|
|
{activeProject && activeProject.threads.length > 0 ? (
|
|
<XStack gap={6} flexWrap="wrap">
|
|
{activeProject.threads.map((th) => (
|
|
<XStack
|
|
key={th.id}
|
|
render={<button type="button" onClick={() => setActiveThreadId(th.id)} />}
|
|
px={10}
|
|
py={5}
|
|
rounded={9999}
|
|
borderWidth={1}
|
|
borderColor={activeThread?.id === th.id ? 'var(--foreground)' : 'var(--border)'}
|
|
bg={activeThread?.id === th.id ? 'var(--secondary)' : 'transparent'}
|
|
cursor="pointer"
|
|
items="center"
|
|
gap={6}
|
|
>
|
|
<View
|
|
width={6}
|
|
height={6}
|
|
rounded={9999}
|
|
bg={th.status === 'running' ? 'var(--state-success)' : 'var(--muted-foreground)'}
|
|
/>
|
|
<Text fontSize={11} fontWeight="500" color="var(--foreground)" numberOfLines={1}>
|
|
{th.title.slice(0, 22)}
|
|
</Text>
|
|
</XStack>
|
|
))}
|
|
</XStack>
|
|
) : null}
|
|
</YStack>
|
|
|
|
{/* Thread Messages or Empty State */}
|
|
{activeThread ? (
|
|
<YStack gap={12} flex={1} my={8} overflowY="auto">
|
|
{activeThread.messages.map((m, idx) => (
|
|
<YStack
|
|
key={idx}
|
|
p={12}
|
|
rounded={12}
|
|
borderWidth={1}
|
|
borderColor={m.role === 'user' ? 'var(--border-strong)' : 'var(--border)'}
|
|
bg={m.role === 'user' ? 'var(--secondary)' : '$panel'}
|
|
gap={6}
|
|
>
|
|
<XStack items="center" justify="space-between">
|
|
<Text fontFamily="$mono" fontSize={10} fontWeight="600" color="var(--muted-foreground)">
|
|
{m.role === 'user' ? displayName.toUpperCase() : `HANZO · ${activeThread.model.toUpperCase()}`}
|
|
</Text>
|
|
{m.artifactKind ? (
|
|
<Text fontFamily="$mono" fontSize={10} fontWeight="700" color="var(--foreground)">
|
|
[{m.artifactKind.toUpperCase()}]
|
|
</Text>
|
|
) : null}
|
|
</XStack>
|
|
<Text fontSize={13} lineHeight={19} color="var(--foreground)">
|
|
{m.text}
|
|
</Text>
|
|
</YStack>
|
|
))}
|
|
</YStack>
|
|
) : (
|
|
<YStack flex={1} items="center" justify="center" gap={10} px={24}>
|
|
<FolderKanban size={28} color="var(--muted-foreground)" />
|
|
<Text fontSize={13} color="var(--muted-foreground)" text="center">
|
|
Your threads show up here as Hanzo works
|
|
</Text>
|
|
</YStack>
|
|
)}
|
|
|
|
{/* Bottom Steer Thread Input + Auto-Continue on Usage Limit Reset (matches Screenshot 19-06-32) */}
|
|
<YStack gap={10}>
|
|
<XStack
|
|
render={<button type="button" onClick={() => setAutoContinueOnReset((v) => !v)} />}
|
|
p={10}
|
|
rounded={10}
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="var(--secondary)"
|
|
items="center"
|
|
justify="space-between"
|
|
cursor="pointer"
|
|
>
|
|
<XStack items="center" gap={8}>
|
|
<View
|
|
width={16}
|
|
height={16}
|
|
rounded={4}
|
|
borderWidth={1}
|
|
borderColor="var(--foreground)"
|
|
bg={autoContinueOnReset ? 'var(--foreground)' : 'transparent'}
|
|
items="center"
|
|
justify="center"
|
|
>
|
|
{autoContinueOnReset ? <Check size={11} color="var(--background)" /> : null}
|
|
</View>
|
|
<Text fontSize={11} color="var(--foreground)">
|
|
Continue automatically when usage limit resets
|
|
</Text>
|
|
</XStack>
|
|
</XStack>
|
|
|
|
<YStack p={10} gap={8} rounded={12} borderWidth={1} borderColor="var(--border)" bg="$panel">
|
|
<Input
|
|
size="sm"
|
|
borderWidth={0}
|
|
bg="transparent"
|
|
placeholder="Steer this thread..."
|
|
value={steerText}
|
|
onChange={(e) => setSteerText(e.target.value)}
|
|
onKeyDown={(e: { key?: string; preventDefault?: () => void }) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault?.()
|
|
steerActiveThread()
|
|
}
|
|
}}
|
|
/>
|
|
<XStack items="center" justify="space-between">
|
|
<XStack
|
|
render={
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const models = ['Zen 4.7 (1M context)', 'Enso Ultra', 'Kai Decision', 'Claude Opus 4.7']
|
|
const nextIdx = (models.indexOf(selectedModel) + 1) % models.length
|
|
setSelectedModel(models[nextIdx])
|
|
}}
|
|
/>
|
|
}
|
|
items="center"
|
|
gap={4}
|
|
px={8}
|
|
py={4}
|
|
rounded={6}
|
|
bg="var(--secondary)"
|
|
borderWidth={0}
|
|
cursor="pointer"
|
|
>
|
|
<Text fontFamily="$mono" fontSize={10} color="var(--foreground)">
|
|
{selectedModel}
|
|
</Text>
|
|
<ChevronDown size={11} color="var(--muted-foreground)" />
|
|
</XStack>
|
|
<XStack
|
|
render={<button type="button" onClick={steerActiveThread} aria-label="Steer thread" />}
|
|
width={26}
|
|
height={26}
|
|
rounded={9999}
|
|
bg="var(--foreground)"
|
|
borderWidth={0}
|
|
items="center"
|
|
justify="center"
|
|
cursor="pointer"
|
|
>
|
|
<Send size={12} color="var(--background)" />
|
|
</XStack>
|
|
</XStack>
|
|
</YStack>
|
|
</YStack>
|
|
</YStack>
|
|
</XStack>
|
|
|
|
{/* Modal 1: Import from Claude, Codex, Cursor, ChatGPT & Connect Google Workspace */}
|
|
{showImportModal ? (
|
|
<View
|
|
position="fixed"
|
|
t={0}
|
|
l={0}
|
|
r={0}
|
|
b={0}
|
|
bg="var(--surface-scrim)"
|
|
items="center"
|
|
justify="center"
|
|
p={20}
|
|
z="var(--z-sticky)"
|
|
>
|
|
<YStack
|
|
width="100%"
|
|
maxW={540}
|
|
p={24}
|
|
gap={18}
|
|
rounded={20}
|
|
borderWidth={1}
|
|
borderColor="var(--border-strong)"
|
|
bg="var(--background)"
|
|
>
|
|
<XStack items="center" justify="space-between">
|
|
<YStack gap={2}>
|
|
<Text fontSize="$5" fontWeight="600" color="var(--foreground)">
|
|
Import Agents & Connect Workspace
|
|
</Text>
|
|
<Text fontSize="$2" color="var(--muted-foreground)">
|
|
Bring your projects, threads, rules, and docs from any AI agent or Google Workspace.
|
|
</Text>
|
|
</YStack>
|
|
<XStack
|
|
render={<button type="button" onClick={() => setShowImportModal(false)} aria-label="Close modal" />}
|
|
width={28}
|
|
height={28}
|
|
rounded={8}
|
|
bg="transparent"
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
items="center"
|
|
justify="center"
|
|
cursor="pointer"
|
|
>
|
|
<X size={15} color="var(--foreground)" />
|
|
</XStack>
|
|
</XStack>
|
|
|
|
<YStack gap={10}>
|
|
{IMPORT_SOURCES.map((src) => {
|
|
const connected = importedSources.includes(src.id)
|
|
return (
|
|
<XStack
|
|
key={src.id}
|
|
p={14}
|
|
rounded={14}
|
|
borderWidth={1}
|
|
borderColor={connected ? 'var(--foreground)' : 'var(--border)'}
|
|
bg="$panel"
|
|
items="center"
|
|
justify="space-between"
|
|
gap={12}
|
|
>
|
|
<YStack gap={3} flex={1}>
|
|
<XStack items="center" gap={8}>
|
|
<Text fontSize="$3" fontWeight="600" color="var(--foreground)">
|
|
{src.name}
|
|
</Text>
|
|
<Text fontFamily="$mono" fontSize={10} color="var(--muted-foreground)">
|
|
{src.badge}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize={12} color="var(--muted-foreground)">
|
|
{src.desc}
|
|
</Text>
|
|
</YStack>
|
|
<Action
|
|
render="button"
|
|
type="button"
|
|
fill={!connected}
|
|
onClick={() => {
|
|
if (!connected) {
|
|
setImportedSources((prev) => [...prev, src.id])
|
|
track('project_source_imported', { source: src.id })
|
|
}
|
|
}}
|
|
>
|
|
{connected ? 'Connected ✓' : 'Connect & Import'}
|
|
</Action>
|
|
</XStack>
|
|
)
|
|
})}
|
|
</YStack>
|
|
</YStack>
|
|
</View>
|
|
) : null}
|
|
|
|
{/* Modal 2: Create New Project */}
|
|
{showNewProjectModal ? (
|
|
<View
|
|
position="fixed"
|
|
t={0}
|
|
l={0}
|
|
r={0}
|
|
b={0}
|
|
bg="var(--surface-scrim)"
|
|
items="center"
|
|
justify="center"
|
|
p={20}
|
|
z="var(--z-sticky)"
|
|
>
|
|
<YStack
|
|
width="100%"
|
|
maxW={440}
|
|
p={24}
|
|
gap={16}
|
|
rounded={20}
|
|
borderWidth={1}
|
|
borderColor="var(--border-strong)"
|
|
bg="var(--background)"
|
|
>
|
|
<XStack items="center" justify="space-between">
|
|
<Text fontSize="$5" fontWeight="600" color="var(--foreground)">
|
|
Create New Project
|
|
</Text>
|
|
<XStack
|
|
render={<button type="button" onClick={() => setShowNewProjectModal(false)} aria-label="Close modal" />}
|
|
width={28}
|
|
height={28}
|
|
rounded={8}
|
|
bg="transparent"
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
items="center"
|
|
justify="center"
|
|
cursor="pointer"
|
|
>
|
|
<X size={15} color="var(--foreground)" />
|
|
</XStack>
|
|
</XStack>
|
|
<YStack gap={10}>
|
|
<Input
|
|
placeholder="Project name (e.g. hanzo-mobile)"
|
|
value={newProjectName}
|
|
onChange={(e) => setNewProjectName(e.target.value)}
|
|
/>
|
|
<Input
|
|
placeholder="Repository or folder (optional, e.g. hanzo-inc/mobile)"
|
|
value={newProjectRepo}
|
|
onChange={(e) => setNewProjectRepo(e.target.value)}
|
|
/>
|
|
</YStack>
|
|
<XStack justify="flex-end" gap={8}>
|
|
<Action render="button" type="button" onClick={() => setShowNewProjectModal(false)}>
|
|
Cancel
|
|
</Action>
|
|
<Action render="button" type="button" fill disabled={!newProjectName.trim()} onClick={createNewProject}>
|
|
Create Project
|
|
</Action>
|
|
</XStack>
|
|
</YStack>
|
|
</View>
|
|
) : null}
|
|
</XStack>
|
|
)
|
|
}
|
|
|
|
function ProjectRow({
|
|
project,
|
|
active,
|
|
onSelect,
|
|
onOpenIconPicker,
|
|
}: {
|
|
project: ProjectItem
|
|
active: boolean
|
|
onSelect: () => void
|
|
onOpenIconPicker: () => void
|
|
}) {
|
|
const Icon = getIconComponent(project.icon)
|
|
const color = getColorHex(project.color)
|
|
return (
|
|
<XStack
|
|
items="center"
|
|
gap={6}
|
|
px={8}
|
|
py={6}
|
|
rounded={10}
|
|
bg={active ? 'var(--secondary)' : 'transparent'}
|
|
hoverStyle={{ bg: 'var(--secondary)' }}
|
|
>
|
|
<XStack
|
|
render={
|
|
<button
|
|
type="button"
|
|
title="Change project icon & color"
|
|
aria-label={`Customize ${project.name} icon and color`}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onOpenIconPicker()
|
|
}}
|
|
/>
|
|
}
|
|
width={24}
|
|
height={24}
|
|
rounded={6}
|
|
items="center"
|
|
justify="center"
|
|
bg="var(--secondary)"
|
|
borderWidth={0}
|
|
cursor="pointer"
|
|
>
|
|
<Icon size={14} color={color} />
|
|
</XStack>
|
|
<XStack
|
|
render={<button type="button" onClick={onSelect} />}
|
|
flex={1}
|
|
minW={0}
|
|
items="center"
|
|
justify="space-between"
|
|
bg="transparent"
|
|
borderWidth={0}
|
|
cursor="pointer"
|
|
p={0}
|
|
>
|
|
<Text fontSize={13} fontWeight={active ? '600' : '500'} color="var(--foreground)" numberOfLines={1}>
|
|
{project.name}
|
|
</Text>
|
|
{project.threads.length > 0 ? (
|
|
<Text fontFamily="$mono" fontSize={10} color="var(--muted-foreground)">
|
|
{project.threads.length}
|
|
</Text>
|
|
) : null}
|
|
</XStack>
|
|
</XStack>
|
|
)
|
|
}
|
|
|
|
function AppRail() {
|
|
const items: { label: string; href: string; icon: ReactNode; active?: boolean }[] = [
|
|
{ label: 'Chat', href: '/', icon: <MessageSquare size={18} aria-hidden /> },
|
|
{ label: 'Projects', href: '/projects', icon: <FolderKanban size={18} aria-hidden />, active: true },
|
|
{ label: 'Dev', href: '/dev', icon: <Code2 size={18} aria-hidden /> },
|
|
{ label: 'Studio', href: '/studio', icon: <WandSparkles size={18} aria-hidden /> },
|
|
{ label: 'Library', href: '/dev/-/artifacts', icon: <Library size={18} aria-hidden /> },
|
|
{ label: 'Scheduled tasks', href: '/dev/-/automations', icon: <Clock size={18} aria-hidden /> },
|
|
{ label: 'Customize', href: '/dev/-/customize', icon: <Puzzle size={18} aria-hidden /> },
|
|
{ label: 'Betas', href: '/beta', icon: <FlaskConical size={18} aria-hidden /> },
|
|
]
|
|
return (
|
|
<YStack
|
|
render="nav"
|
|
aria-label="Products"
|
|
data-slot="rail"
|
|
display="none"
|
|
$md={{ display: 'flex' }}
|
|
width={44}
|
|
shrink={0}
|
|
pt={8}
|
|
pb={8}
|
|
gap={4}
|
|
items="center"
|
|
borderRightWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="var(--background)"
|
|
position="sticky"
|
|
t={0}
|
|
height="100svh"
|
|
z="var(--z-drawer)"
|
|
>
|
|
{items.map((item) => (
|
|
<XStack
|
|
key={item.label}
|
|
render={<a href={item.href} title={item.label} aria-label={item.label} aria-current={item.active ? 'page' : undefined} />}
|
|
width={36}
|
|
height={36}
|
|
rounded={8}
|
|
items="center"
|
|
justify="center"
|
|
opacity={item.active ? 1 : 0.62}
|
|
bg={item.active ? 'var(--secondary)' : 'transparent'}
|
|
hoverStyle={{ opacity: 1, bg: 'var(--secondary)' }}
|
|
$platform-web={{ textDecoration: 'none', color: 'var(--foreground)' }}
|
|
>
|
|
{item.icon}
|
|
</XStack>
|
|
))}
|
|
</YStack>
|
|
)
|
|
}
|