diff --git a/apps/web/components.json b/apps/web/components.json index 7a96948..4d83704 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -12,7 +12,7 @@ }, "iconLibrary": "lucide", "aliases": { - "components": "@/components", + "components": "@code/ui/components", "utils": "@code/ui/lib/utils", "ui": "@code/ui/components", "lib": "@/lib", diff --git a/apps/web/package.json b/apps/web/package.json index 054c429..bc779f4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,12 +7,13 @@ "dev": "react-router dev", "dev:tailscale": "react-router dev --host 0.0.0.0", "start": "react-router-serve ./build/server/index.js", - "typecheck": "react-router typegen && tsc" + "check-types": "react-router typegen && tsc --noEmit" }, "dependencies": { "@code/auth": "workspace:*", "@code/backend": "workspace:*", "@code/env": "workspace:*", + "@code/primitives": "workspace:*", "@code/ui": "workspace:*", "@flue/react": "1.0.0-beta.9", "@flue/sdk": "1.0.0-beta.9", @@ -40,7 +41,7 @@ "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", - "vitest": "catalog:", - "vite-tsconfig-paths": "^6.1.1" + "vite-tsconfig-paths": "^6.1.1", + "vitest": "catalog:" } } diff --git a/apps/web/src/components/chat/assistant-identity.tsx b/apps/web/src/components/chat/assistant-identity.tsx index 05f6314..47038ef 100644 --- a/apps/web/src/components/chat/assistant-identity.tsx +++ b/apps/web/src/components/chat/assistant-identity.tsx @@ -1,5 +1,4 @@ -import { MessageHeader } from "@code/ui/components/message"; -import { Sparkles } from "lucide-react"; +import { MobileChatAssistantLabel } from "@code/ui/components/mobile-chat"; import type { AssistantResponseState } from "@/lib/chat/types"; @@ -8,20 +7,18 @@ interface AssistantIdentityProps { } export const AssistantIdentity = ({ state }: AssistantIdentityProps) => ( - - - - - Zopu - {state && ( - - - {state === "thinking" ? "Thinking" : "Writing"} - - )} - + ) : undefined + } + /> ); diff --git a/apps/web/src/components/chat/chat-composer.tsx b/apps/web/src/components/chat/chat-composer.tsx index 6c0fad8..2276af5 100644 --- a/apps/web/src/components/chat/chat-composer.tsx +++ b/apps/web/src/components/chat/chat-composer.tsx @@ -1,10 +1,5 @@ -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupTextarea, -} from "@code/ui/components/input-group"; -import { CircleAlert, LoaderCircle, Send } from "lucide-react"; +import { MobileChatComposer } from "@code/ui/components/mobile-chat"; +import { LoaderCircle } from "lucide-react"; import type { ReactNode } from "react"; import { useChatComposer } from "@/hooks/chat/use-chat-composer"; @@ -15,90 +10,31 @@ export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => { status === "connecting" || status === "submitted" || status === "streaming"; const composer = useChatComposer({ busy, onSend }); - let statusContent: ReactNode = ( - - Enter to send · Shift + Enter for a new line - - ); + let statusMessage: ReactNode; if (busy) { - statusContent = ( - <> - + statusMessage = ( + + {status === "connecting" ? "Preparing Zopu" : "Zopu is responding"} - - ); - } - if (error) { - statusContent = ( - - - {error.message || "Message failed to send"} ); } return ( -
-
- - - - - {busy ? ( - - ) : ( - - )} - - - -
- {composer.isFocused || busy || error ? ( - {statusContent} - ) : ( - Zopu can make mistakes. Check important information. - )} -
-
-
+ ); }; diff --git a/apps/web/src/components/chat/chat-conversation.tsx b/apps/web/src/components/chat/chat-conversation.tsx index 686e675..98f9c59 100644 --- a/apps/web/src/components/chat/chat-conversation.tsx +++ b/apps/web/src/components/chat/chat-conversation.tsx @@ -1,15 +1,6 @@ +import { ConversationEmptyState } from "@code/ui/components/ai-elements/conversation"; import { Button } from "@code/ui/components/button"; -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@code/ui/components/empty"; -import { MessageGroup } from "@code/ui/components/message"; -import { MessageScrollerItem } from "@code/ui/components/message-scroller"; -import { Sparkles } from "lucide-react"; +import { MobileChatMark } from "@code/ui/components/mobile-chat"; import { SUGGESTIONS } from "@/lib/chat/constants"; import type { ChatConversationProps } from "@/lib/chat/types"; @@ -25,23 +16,21 @@ export const ChatConversation = ({ }: ChatConversationProps) => { if (historyReady && messages.length === 0) { return ( - - - - - - + + +
+

What can I help with? - - +

+

Think, write, plan, or explore an idea with Zopu. - - - +

+
+
{SUGGESTIONS.map(([title, prompt]) => ( ))} - - +
+
); } return ( - - {messages.map((message, index) => ( - - - +
+ {messages.map((message) => ( + ))} - {status === "submitted" && ( - - - - )} - + {status === "submitted" && } +
); }; diff --git a/apps/web/src/components/chat/chat-header.tsx b/apps/web/src/components/chat/chat-header.tsx index d2bb0fd..eca9509 100644 --- a/apps/web/src/components/chat/chat-header.tsx +++ b/apps/web/src/components/chat/chat-header.tsx @@ -1,32 +1,11 @@ -import { Sparkles } from "lucide-react"; +import { MobileChatHeader } from "@code/ui/components/mobile-chat"; -import { MODEL_LABEL, STATUS_COPY } from "@/lib/chat/constants"; -import { getStatusDotClass } from "@/lib/chat/transforms"; +import { STATUS_COPY } from "@/lib/chat/constants"; import type { ChatHeaderProps } from "@/lib/chat/types"; export const ChatHeader = ({ status }: ChatHeaderProps) => ( -
-
-
-
- -
-
-

- Zopu -

-

-

-
-
-

- {MODEL_LABEL} -

-
-
+ ); diff --git a/apps/web/src/components/chat/chat-message.tsx b/apps/web/src/components/chat/chat-message.tsx index 71d0235..36e0ecf 100644 --- a/apps/web/src/components/chat/chat-message.tsx +++ b/apps/web/src/components/chat/chat-message.tsx @@ -1,7 +1,13 @@ -import { Bubble, BubbleContent } from "@code/ui/components/bubble"; -import { Message, MessageContent } from "@code/ui/components/message"; +import { + Message, + MessageContent, + MessageResponse, +} from "@code/ui/components/ai-elements/message"; +import { + MobileChatBubble, + MobileChatMessage, +} from "@code/ui/components/mobile-chat"; import type { ReactNode } from "react"; -import { Streamdown } from "streamdown"; import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms"; import type { ChatMessageProps } from "@/lib/chat/types"; @@ -21,13 +27,13 @@ export const ChatMessage = ({ message }: ChatMessageProps) => { let content: ReactNode = text; if (!isUser) { content = text ? ( - {text} - + ) : (
@@ -37,41 +43,29 @@ export const ChatMessage = ({ message }: ChatMessageProps) => { ); } + const sender = isUser ? "user" : "assistant"; + return ( - - - {!isUser && ( - - )} - - - {message.parts.map((part) => - part.type === "dynamic-tool" ? ( - - ) : null + + + +
+ {!isUser && ( + )} - {content} - - + + {message.parts.map((part) => + part.type === "dynamic-tool" ? ( + + ) : null + )} + {content} + +
+
); diff --git a/apps/web/src/components/chat/chat-page.tsx b/apps/web/src/components/chat/chat-page.tsx index 6b4ca76..7d08176 100644 --- a/apps/web/src/components/chat/chat-page.tsx +++ b/apps/web/src/components/chat/chat-page.tsx @@ -1,10 +1,8 @@ import { - MessageScroller, - MessageScrollerButton, - MessageScrollerContent, - MessageScrollerProvider, - MessageScrollerViewport, -} from "@code/ui/components/message-scroller"; + Conversation, + ConversationContent, + ConversationScrollButton, +} from "@code/ui/components/ai-elements/conversation"; import { useChatAgent } from "@/hooks/chat/use-chat-agent"; @@ -17,36 +15,24 @@ export const ChatPage = () => { const handleSendMessage = (message: string) => agent.sendMessage(message); return ( -
+
- - - - - - void handleSendMessage(suggestion) - } - status={agent.status} - /> - - - + + void handleSendMessage(suggestion)} + status={agent.status} /> - - + + +
( - - - -
- - - -
+ + + +
+ + +
+ + + +
+
+
+
); diff --git a/apps/web/src/components/chat/chat-tool-call.tsx b/apps/web/src/components/chat/chat-tool-call.tsx index ab98098..37a87c8 100644 --- a/apps/web/src/components/chat/chat-tool-call.tsx +++ b/apps/web/src/components/chat/chat-tool-call.tsx @@ -1,4 +1,11 @@ -import { Check, LoaderCircle, Terminal, TriangleAlert } from "lucide-react"; +import { + Tool, + ToolContent, + ToolInput, + ToolOutput, +} from "@code/ui/components/ai-elements/tool"; +import { MobileChatToolCall } from "@code/ui/components/mobile-chat"; +import { Search, SquareTerminal } from "lucide-react"; import type { ChatToolCallProps } from "@/lib/chat/types"; @@ -7,35 +14,47 @@ export const ChatToolCall = ({ part }: ChatToolCallProps) => { typeof part.input === "string" ? part.input : JSON.stringify(part.input, null, 2); - let Icon = LoaderCircle; - let status = "Running"; + let status = "running"; + let tone: "error" | "neutral" | "success" = "neutral"; + if (part.state === "output-available") { detail = typeof part.output === "string" ? part.output : JSON.stringify(part.output, null, 2); - Icon = Check; - status = "Completed"; + status = "done"; + tone = "success"; } else if (part.state === "output-error") { detail = part.errorText; - Icon = TriangleAlert; - status = "Failed"; + status = "failed"; + tone = "error"; } - const isRunning = part.state === "input-available"; + + const isSearch = part.toolName.toLowerCase().includes("search"); + const output = part.state === "output-available" ? part.output : undefined; + const errorText = part.state === "output-error" ? part.errorText : undefined; return ( -
- - - - {part.toolName} - - {status} - - -
-        {detail}
-      
-
+ + + + ) : ( + + ) + } + status={status} + tone={tone} + toolName={part.toolName} + /> +
+ + +
+
+
); }; diff --git a/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx b/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx new file mode 100644 index 0000000..769603a --- /dev/null +++ b/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx @@ -0,0 +1,102 @@ +import type { Id } from "@code/backend/convex/_generated/dataModel"; +import { + MobileAssistantChatScreen, + MobileExpandedWorkScreen, + MobileHomeScreen, + MobileWorkListScreen, + MobileWorkUnitDetailScreen, +} from "@code/ui/components/mobile-product"; +import { useNavigate, useParams } from "react-router"; + +import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace"; +import { useMobileWorkspace } from "@/hooks/use-mobile-workspace"; + +export type MobileFlowScreen = + | "assistant-chat" + | "home" + | "work-list" + | "work-unit-detail"; + +interface MobileFlowPageProps { + screen: MobileFlowScreen; +} + +export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => { + const navigate = useNavigate(); + const { workUnitId } = useParams(); + const projectWorkspace = useMobileProjectWorkspace({ + selectedWorkUnitId: workUnitId, + }); + const workspace = useMobileWorkspace({ + onCreateIssue: (title, body) => + projectWorkspace.raiseIssue({ body, title }), + onSend: projectWorkspace.sendMessage, + }); + const workPath = "/work"; + const chatPath = "/chat"; + + const handleOpenUnit = (selectedWorkUnitId?: string) => { + const targetId = + selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id; + if (!targetId) { + navigate("/dashboard"); + return; + } + if (screen === "work-list" && !workspace.expanded) { + workspace.setExpanded(true); + return; + } + navigate(`/work/${targetId}`); + }; + + const screenProps = { + composerValue: workspace.composerValue, + createIssueBody: workspace.issueBody, + createIssueTitle: workspace.issueTitle, + data: projectWorkspace.data, + onBack: () => navigate(workPath), + onComposerChange: workspace.handleComposerChange, + onComposerSubmit: workspace.handleComposerSubmit, + onCreateBodyChange: workspace.setIssueBody, + onCreateIssue: workspace.handleCreateIssueSubmit, + onCreateIssueFromSignal: projectWorkspace.data.latestSignal?.projectId + ? () => + void projectWorkspace.raiseIssueFromSignal( + projectWorkspace.data.latestSignal?.id ?? "" + ) + : undefined, + onCreateTitleChange: workspace.setIssueTitle, + onManageProjects: () => navigate("/dashboard"), + onOpenAssistant: () => navigate(chatPath), + onOpenUnit: handleOpenUnit, + onProjectSelect: (projectId: string) => + projectWorkspace.projectWorkspace.setSelectedProjectId( + projectId as Id<"projects"> + ), + onReviewUnit: (reviewUrl: string) => { + window.open(reviewUrl, "_blank", "noopener,noreferrer"); + }, + onStartUnit: (selectedIssueId: string) => { + void projectWorkspace.startWorkUnit( + selectedIssueId as Id<"projectIssues"> + ); + }, + onViewWork: () => navigate(workPath), + pendingAction: projectWorkspace.projectWorkspace.pendingAction, + statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message, + }; + + if (screen === "assistant-chat") { + return ; + } + if (screen === "home") { + return ; + } + if (screen === "work-unit-detail") { + return ; + } + if (workspace.expanded) { + return ; + } + return ; +}; diff --git a/apps/web/src/components/projects/project-workspace-page.tsx b/apps/web/src/components/projects/project-workspace-page.tsx index b171063..3c317ba 100644 --- a/apps/web/src/components/projects/project-workspace-page.tsx +++ b/apps/web/src/components/projects/project-workspace-page.tsx @@ -3,296 +3,702 @@ import { Button } from "@code/ui/components/button"; import { Input } from "@code/ui/components/input"; import { Textarea } from "@code/ui/components/textarea"; import { + ArrowUpRight, Bot, + CircleAlert, ExternalLink, - FileText, + FolderGit2, GitFork, - Play, + GitPullRequest, + LoaderCircle, Plus, + Sparkles, } from "lucide-react"; import UserMenu from "@/components/user-menu"; import { useProjectWorkspace } from "@/hooks/use-project-workspace"; +import type { + ProjectIssueSummary, + ProjectLoopView, +} from "@/lib/projects/project-evidence"; -const statusStyle: Record["status"], string> = { - completed: - "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", - failed: "border-destructive/40 bg-destructive/10 text-destructive", - "needs-input": - "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300", - open: "border-border bg-muted/40 text-muted-foreground", - queued: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300", - working: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300", +interface Project { + readonly id: string; + readonly name: string; + readonly sources: readonly { + readonly defaultBranch?: string; + readonly host: string; + readonly repositoryPath: string; + readonly url: string; + }[]; +} + +const formatStatus = (status: Doc<"projectIssues">["status"]): string => { + if (status === "needs-input") { + return "Needs input"; + } + if (status === "working") { + return "In progress"; + } + return status.charAt(0).toUpperCase() + status.slice(1); }; +const statusClasses: Record["status"], string> = { + completed: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", + failed: "bg-destructive/15 text-destructive", + "needs-input": "bg-orange-500/15 text-orange-700 dark:text-orange-300", + open: "bg-muted text-muted-foreground", + queued: "bg-violet-500/15 text-violet-700 dark:text-violet-300", + working: "bg-blue-500/15 text-blue-700 dark:text-blue-300", +}; + +const verificationIconClass = ( + verification: ProjectLoopView["verification"] +): string => { + if (verification === "passed") { + return "text-emerald-600"; + } + if (verification === "failed" || verification === "blocked") { + return "text-orange-600"; + } + return "text-muted-foreground"; +}; + +const checkStateClass = ( + state: ProjectLoopView["verificationChecks"][number]["state"] +): string => { + if (state === "complete") { + return "bg-emerald-500 text-white"; + } + if (state === "attention") { + return "bg-orange-500 text-white"; + } + if (state === "current") { + return "bg-blue-500 text-white"; + } + return "bg-muted text-muted-foreground"; +}; + +const LoadingLine = ({ label }: { readonly label: string }) => ( +
+ + {label} +
+); + interface RepositoryFormProps { readonly busy: boolean; - readonly onChange: (value: string) => void; - readonly onSubmit: () => void; + readonly onRepositoryChange: (value: string) => void; + readonly onRepositorySubmit: () => void; readonly value: string; } const RepositoryForm = ({ busy, - onChange, - onSubmit, + onRepositoryChange, + onRepositorySubmit, value, }: RepositoryFormProps) => (
{ event.preventDefault(); - onSubmit(); + onRepositorySubmit(); }} > -
- -

- Import with a public HTTP(S) Git URL. +

+

+ Connect project +

+

+ Bring a public repository into the project loop.

onChange(event.target.value)} + aria-label="Public Git repository URL" + onChange={(event) => onRepositoryChange(event.target.value)} placeholder="https://github.com/owner/repo" required value={value} /> ); -interface ProjectListProps { - readonly projects: - | readonly { - readonly id: string; - readonly name: string; - readonly sources: readonly { - readonly host: string; - readonly repositoryPath: string; - readonly url: string; - }[]; - }[] - | undefined; +interface ProjectSidebarProps { + readonly onProjectSelect: (projectId: Id<"projects">) => void; + readonly projects: readonly Project[] | undefined; readonly selectedId: Id<"projects"> | null; - readonly onSelect: (projectId: Id<"projects">) => void; } -const ProjectList = ({ projects, selectedId, onSelect }: ProjectListProps) => { - const renderContent = () => { +const ProjectSidebar = ({ + onProjectSelect, + projects, + selectedId, +}: ProjectSidebarProps) => { + const renderProjects = () => { if (projects === undefined) { - return ( -

Loading projects...

- ); + return ; } if (projects.length === 0) { return ( -

- Import a repository to create its project artifacts and issue queue. +

+ Connect a repository to create the first project workspace.

); } return ( -
- {projects.map((project) => ( - - ))} +
+ {projects.map((project) => { + const [source] = project.sources; + const selected = selectedId === project.id; + return ( + + ); + })}
); }; return ( - - ); -}; - -interface ArtifactGridProps { - readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined; -} - -const renderArtifacts = (artifacts: ArtifactGridProps["artifacts"]) => { - if (artifacts === undefined) { - return

Loading artifacts...

; - } - if (artifacts.length === 0) { - return

No artifacts yet.

; - } - return ( -
- {artifacts.map((artifact) => ( -
- -

{artifact.path}

-

- rev {artifact.revision} +

+ +
+
+ +

+ Project context stays attached to every issue and run. +

+
+
+ ); }; -const ArtifactGrid = ({ artifacts }: ArtifactGridProps) => ( -
-

- Project artifacts -

- {renderArtifacts(artifacts)} -
-); - interface IssueComposerProps { readonly body: string; readonly busy: boolean; - readonly onBodyChange: (value: string) => void; - readonly onSubmit: () => void; - readonly onTitleChange: (value: string) => void; + readonly onIssueBodyChange: (value: string) => void; + readonly onIssueSubmit: () => void; + readonly onIssueTitleChange: (value: string) => void; readonly title: string; } const IssueComposer = ({ body, busy, - onBodyChange, - onSubmit, - onTitleChange, + onIssueBodyChange, + onIssueSubmit, + onIssueTitleChange, title, }: IssueComposerProps) => (
{ event.preventDefault(); - onSubmit(); + onIssueSubmit(); }} > -

New issue

- onTitleChange(event.target.value)} - placeholder="Issue title" - required - value={title} - /> -