From b49ca236cf354f96998df572253434c0038d5403 Mon Sep 17 00:00:00 2001 From: sai karthik Date: Fri, 24 Jul 2026 00:52:19 +0530 Subject: [PATCH] feat(web): connect mobile workspace to live data --- .../mobile-workspace/mobile-flow-page.tsx | 29 +- apps/web/src/hooks/chat/use-chat-agent.ts | 11 +- .../src/hooks/use-mobile-project-workspace.ts | 83 ++ apps/web/src/hooks/use-mobile-workspace.ts | 29 +- .../src/hooks/use-personal-organization.ts | 38 +- .../build-mobile-workspace-view.ts | 164 +++ packages/backend/convex/_generated/api.d.ts | 6 + packages/ui/src/components/mobile-product.tsx | 10 +- .../components/mobile-workspace-screens.tsx | 1245 ++++++++++------- .../src/components/mobile-workspace/index.ts | 10 +- .../src/components/mobile-workspace/models.ts | 50 + .../src/components/mobile-workspace/types.ts | 9 + 12 files changed, 1178 insertions(+), 506 deletions(-) create mode 100644 apps/web/src/hooks/use-mobile-project-workspace.ts create mode 100644 apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts create mode 100644 packages/ui/src/components/mobile-workspace/models.ts diff --git a/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx b/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx index 9f92ecd..f692be0 100644 --- a/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx +++ b/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx @@ -5,8 +5,9 @@ import { MobileWorkListScreen, MobileWorkUnitDetailScreen, } from "@code/ui/components/mobile-product"; -import { useNavigate } from "react-router"; +import { useNavigate, useParams } from "react-router"; +import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace"; import { useMobileWorkspace } from "@/hooks/use-mobile-workspace"; export type MobileFlowScreen = @@ -21,29 +22,41 @@ interface MobileFlowPageProps { export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => { const navigate = useNavigate(); - const workspace = useMobileWorkspace(); - const homePath = "/"; + const { workUnitId } = useParams(); + const projectWorkspace = useMobileProjectWorkspace({ + selectedWorkUnitId: workUnitId, + }); + const workspace = useMobileWorkspace({ + onSend: projectWorkspace.sendMessage, + }); const workPath = "/work"; const chatPath = "/chat"; - const detailPath = "/work/flow-08"; - const handleOpenUnit = () => { + 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(detailPath); + navigate(`/work/${targetId}`); }; const screenProps = { composerValue: workspace.composerValue, - onBack: () => navigate(homePath), + data: projectWorkspace.data, + onBack: () => navigate(workPath), onComposerChange: workspace.handleComposerChange, onComposerSubmit: workspace.handleComposerSubmit, + onManageProjects: () => navigate("/dashboard"), onOpenAssistant: () => navigate(chatPath), onOpenUnit: handleOpenUnit, onViewWork: () => navigate(workPath), - statusMessage: workspace.statusMessage, + statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message, }; if (screen === "assistant-chat") { diff --git a/apps/web/src/hooks/chat/use-chat-agent.ts b/apps/web/src/hooks/chat/use-chat-agent.ts index e90fb25..77eeb2a 100644 --- a/apps/web/src/hooks/chat/use-chat-agent.ts +++ b/apps/web/src/hooks/chat/use-chat-agent.ts @@ -1,13 +1,13 @@ import { useFlueAgent } from "@flue/react"; import { usePersonalOrganization } from "@/hooks/use-personal-organization"; +import type { PersonalOrganizationState } from "@/hooks/use-personal-organization"; import { CHAT_AGENT } from "@/lib/chat/constants"; import type { ChatAgentState } from "@/lib/chat/types"; -export const useChatAgent = (): ChatAgentState => { - // The agent session is not created until the authenticated user's personal - // organization has been ensured and its id is available. - const organization = usePersonalOrganization(); +export const useOrganizationChatAgent = ( + organization: PersonalOrganizationState +): ChatAgentState => { const agent = useFlueAgent({ ...CHAT_AGENT, id: organization.organizationId, @@ -36,3 +36,6 @@ export const useChatAgent = (): ChatAgentState => { status, }; }; + +export const useChatAgent = (): ChatAgentState => + useOrganizationChatAgent(usePersonalOrganization()); diff --git a/apps/web/src/hooks/use-mobile-project-workspace.ts b/apps/web/src/hooks/use-mobile-project-workspace.ts new file mode 100644 index 0000000..168fe82 --- /dev/null +++ b/apps/web/src/hooks/use-mobile-project-workspace.ts @@ -0,0 +1,83 @@ +import { api } from "@code/backend/convex/_generated/api"; +import type { + MobileAssistantMessageView, + MobileAssistantView, +} from "@code/ui/components/mobile-product"; +import { useQuery } from "convex/react"; + +import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent"; +import { usePersonalOrganization } from "@/hooks/use-personal-organization"; +import { useProjectWorkspace } from "@/hooks/use-project-workspace"; +import { STATUS_COPY } from "@/lib/chat/constants"; +import { getMessageText } from "@/lib/chat/transforms"; +import { buildMobileWorkspaceView } from "@/lib/mobile-workspace/build-mobile-workspace-view"; + +const toAssistantMessages = ( + messages: ReturnType["messages"] +): MobileAssistantMessageView[] => + messages.flatMap((message) => { + if (message.role !== "assistant" && message.role !== "user") { + return []; + } + const text = getMessageText(message); + return text + ? [ + { + id: message.id, + role: message.role, + text, + }, + ] + : []; + }); + +const getStatusTone = ( + status: ReturnType["status"] +): MobileAssistantView["statusTone"] => { + if (status === "error") { + return "red"; + } + return status === "connecting" ? "amber" : "green"; +}; + +interface UseMobileProjectWorkspaceOptions { + readonly selectedWorkUnitId?: string; +} + +export const useMobileProjectWorkspace = ({ + selectedWorkUnitId, +}: UseMobileProjectWorkspaceOptions) => { + const organization = usePersonalOrganization(); + const projectWorkspace = useProjectWorkspace(); + const chatAgent = useOrganizationChatAgent(organization); + const signals = useQuery( + api.signals.list, + organization.organizationId + ? { organizationId: organization.organizationId } + : "skip" + ); + const assistant: MobileAssistantView = { + isBusy: + chatAgent.status === "connecting" || + chatAgent.status === "streaming" || + chatAgent.status === "submitted", + messages: toAssistantMessages(chatAgent.messages), + statusLabel: STATUS_COPY[chatAgent.status], + statusTone: getStatusTone(chatAgent.status), + }; + + return { + data: buildMobileWorkspaceView({ + artifacts: projectWorkspace.artifacts, + assistant, + issues: projectWorkspace.issues, + organizationLabel: organization.organizationName, + projects: projectWorkspace.projects, + selectedProject: projectWorkspace.selectedProject, + selectedWorkUnitId, + signals, + }), + error: organization.error ?? chatAgent.error, + sendMessage: chatAgent.sendMessage, + } as const; +}; diff --git a/apps/web/src/hooks/use-mobile-workspace.ts b/apps/web/src/hooks/use-mobile-workspace.ts index ff19753..7fdfb2f 100644 --- a/apps/web/src/hooks/use-mobile-workspace.ts +++ b/apps/web/src/hooks/use-mobile-workspace.ts @@ -1,8 +1,18 @@ import type { FormEvent } from "react"; import { useState } from "react"; -export const useMobileWorkspace = (initialExpanded = false) => { - const [activeIndex, setActiveIndex] = useState(0); +interface UseMobileWorkspaceOptions { + readonly initialExpanded?: boolean; + readonly onSend: (message: string) => Promise; +} + +const errorMessage = (error: unknown) => + error instanceof Error ? error.message : "Message could not be sent"; + +export const useMobileWorkspace = ({ + initialExpanded = false, + onSend, +}: UseMobileWorkspaceOptions) => { const [composerValue, setComposerValue] = useState(""); const [expanded, setExpanded] = useState(initialExpanded); const [statusMessage, setStatusMessage] = useState(); @@ -18,15 +28,22 @@ export const useMobileWorkspace = (initialExpanded = false) => { if (!message) { return; } - setComposerValue(""); - setStatusMessage("Added to Zopu’s queue"); + setStatusMessage("Sending to Zopu…"); + const sendMessage = async () => { + try { + await onSend(message); + setComposerValue(""); + setStatusMessage("Sent to Zopu"); + } catch (error) { + setStatusMessage(errorMessage(error)); + } + }; + void sendMessage(); }; return { - activeIndex, composerValue, expanded, - handleActiveIndexChange: setActiveIndex, handleComposerChange, handleComposerSubmit, setExpanded, diff --git a/apps/web/src/hooks/use-personal-organization.ts b/apps/web/src/hooks/use-personal-organization.ts index 5b1feae..3eca937 100644 --- a/apps/web/src/hooks/use-personal-organization.ts +++ b/apps/web/src/hooks/use-personal-organization.ts @@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; export interface PersonalOrganizationState { readonly error?: Error; readonly organizationId?: Id<"organizations">; + readonly organizationName?: string; } /** Ensure the authenticated user has its personal tenancy boundary. */ @@ -16,10 +17,12 @@ export const usePersonalOrganization = (): PersonalOrganizationState => { const ensurePersonalOrganization = useMutation( api.organizations.ensurePersonalOrganization ); - const [organizationId, setOrganizationId] = useState< - Id<"organizations"> | undefined - >(); - const [error, setError] = useState(); + const [state, setState] = useState<{ + readonly error?: Error; + readonly organizationId?: Id<"organizations">; + readonly organizationName?: string; + readonly userId: string; + }>(); useEffect(() => { if (!userId) { @@ -31,16 +34,21 @@ export const usePersonalOrganization = (): PersonalOrganizationState => { try { const organization = await ensurePersonalOrganization(); if (active) { - setOrganizationId(organization._id); - setError(undefined); + setState({ + organizationId: organization._id, + organizationName: organization.name, + userId, + }); } } catch (caughtError: unknown) { if (active) { - setError( - caughtError instanceof Error - ? caughtError - : new Error(String(caughtError)) - ); + setState({ + error: + caughtError instanceof Error + ? caughtError + : new Error(String(caughtError)), + userId, + }); } } }; @@ -51,9 +59,13 @@ export const usePersonalOrganization = (): PersonalOrganizationState => { }; }, [ensurePersonalOrganization, userId]); - if (!userId) { + if (!userId || state?.userId !== userId) { return {}; } - return { error, organizationId }; + return { + error: state.error, + organizationId: state.organizationId, + organizationName: state.organizationName, + }; }; diff --git a/apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts b/apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts new file mode 100644 index 0000000..072de19 --- /dev/null +++ b/apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts @@ -0,0 +1,164 @@ +import type { api } from "@code/backend/convex/_generated/api"; +import type { Doc } from "@code/backend/convex/_generated/dataModel"; +import type { + MobileAssistantView, + MobileSignalView, + MobileWorkspaceView, + MobileWorkUnitTone, + MobileWorkUnitView, +} from "@code/ui/components/mobile-product"; +import type { FunctionReturnType } from "convex/server"; + +type SignalList = FunctionReturnType; +type ProjectView = FunctionReturnType[number]; + +interface BuildMobileWorkspaceViewInput { + readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined; + readonly assistant: MobileAssistantView; + readonly issues: readonly Doc<"projectIssues">[] | undefined; + readonly organizationLabel?: string; + readonly projects: readonly ProjectView[] | undefined; + readonly selectedProject: ProjectView | null; + readonly selectedWorkUnitId?: string; + readonly signals: SignalList | undefined; +} + +const updatedDateFormatter = new Intl.DateTimeFormat("en", { + day: "numeric", + month: "short", +}); + +const statusPresentation: Record< + Doc<"projectIssues">["status"], + { + readonly nextAction: string; + readonly progress: number; + readonly statusLabel: string; + readonly tone: MobileWorkUnitTone; + } +> = { + completed: { + nextAction: "Review completed work", + progress: 100, + statusLabel: "Ready for review", + tone: "green", + }, + failed: { + nextAction: "Retry this work unit", + progress: 45, + statusLabel: "Blocked", + tone: "orange", + }, + "needs-input": { + nextAction: "Add the missing context", + progress: 55, + statusLabel: "Needs you", + tone: "orange", + }, + open: { + nextAction: "Start this work unit", + progress: 25, + statusLabel: "Researching", + tone: "purple", + }, + queued: { + nextAction: "Zopu is preparing", + progress: 40, + statusLabel: "Queued", + tone: "blue", + }, + working: { + nextAction: "Review current progress", + progress: 68, + statusLabel: "In progress", + tone: "blue", + }, +}; + +const toWorkUnit = ( + issue: Doc<"projectIssues">, + artifactCount: number +): MobileWorkUnitView => { + const presentation = statusPresentation[issue.status]; + return { + artifactCount, + code: `#${issue.number}`, + id: issue._id, + nextAction: presentation.nextAction, + progress: presentation.progress, + statusLabel: presentation.statusLabel, + summary: issue.body, + title: issue.title, + tone: presentation.tone, + updatedLabel: updatedDateFormatter.format(issue.updatedAt), + }; +}; + +const toLatestSignalView = ( + signal: SignalList[number]["signal"] | undefined +): MobileSignalView | undefined => { + if (!signal) { + return; + } + return { + desiredOutcome: signal.problemStatement.desiredOutcome, + id: signal._id, + summary: signal.problemStatement.summary, + title: signal.problemStatement.title, + }; +}; + +export const buildMobileWorkspaceView = ({ + artifacts, + assistant, + issues, + organizationLabel = "Personal workspace", + projects, + selectedProject, + selectedWorkUnitId, + signals, +}: BuildMobileWorkspaceViewInput): MobileWorkspaceView => { + const artifactCount = artifacts?.length ?? 0; + const workUnits = + issues?.map((issue) => toWorkUnit(issue, artifactCount)) ?? []; + const selectedWorkUnit = + workUnits.find((workUnit) => workUnit.id === selectedWorkUnitId) ?? + workUnits.find((workUnit) => workUnit.tone === "blue") ?? + workUnits[0]; + const selectedProjectId = selectedProject?.id; + const relevantSignals = + signals?.filter( + ({ signal }) => + !signal.projectId || + String(signal.projectId) === String(selectedProjectId) + ) ?? []; + const latestSignal = relevantSignals[0]?.signal; + const activeCount = workUnits.filter( + (workUnit) => workUnit.progress < 100 + ).length; + const needsAttentionCount = issues?.filter( + (issue) => issue.status === "failed" || issue.status === "needs-input" + ).length; + const shippedCount = issues?.filter( + (issue) => issue.status === "completed" + ).length; + const hasSelectedProject = Boolean(selectedProject); + + return { + activeCount, + activityCount: workUnits.length + relevantSignals.length, + artifactCount, + assistant, + isLoading: + projects === undefined || + (hasSelectedProject && (artifacts === undefined || issues === undefined)), + latestSignal: toLatestSignalView(latestSignal), + needsAttentionCount: needsAttentionCount ?? 0, + organizationLabel, + projectName: selectedProject?.name, + selectedWorkUnit, + shippedCount: shippedCount ?? 0, + totalCount: workUnits.length, + workUnits, + }; +}; diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 3296b47..6e039f5 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -12,6 +12,7 @@ import type * as agentWorkspace from "../agentWorkspace.js"; import type * as artifactModel from "../artifactModel.js"; import type * as auth from "../auth.js"; import type * as authz from "../authz.js"; +import type * as conversationMessages from "../conversationMessages.js"; import type * as daemonCommands from "../daemonCommands.js"; import type * as daemonRuntime from "../daemonRuntime.js"; import type * as daemons from "../daemons.js"; @@ -22,7 +23,9 @@ import type * as organizations from "../organizations.js"; import type * as privateData from "../privateData.js"; import type * as projectArtifacts from "../projectArtifacts.js"; import type * as projectIssues from "../projectIssues.js"; +import type * as projectStore from "../projectStore.js"; import type * as projects from "../projects.js"; +import type * as signals from "../signals.js"; import type * as todos from "../todos.js"; import type { @@ -36,6 +39,7 @@ declare const fullApi: ApiFromModules<{ artifactModel: typeof artifactModel; auth: typeof auth; authz: typeof authz; + conversationMessages: typeof conversationMessages; daemonCommands: typeof daemonCommands; daemonRuntime: typeof daemonRuntime; daemons: typeof daemons; @@ -46,7 +50,9 @@ declare const fullApi: ApiFromModules<{ privateData: typeof privateData; projectArtifacts: typeof projectArtifacts; projectIssues: typeof projectIssues; + projectStore: typeof projectStore; projects: typeof projects; + signals: typeof signals; todos: typeof todos; }>; diff --git a/packages/ui/src/components/mobile-product.tsx b/packages/ui/src/components/mobile-product.tsx index 0bd4f06..93e2bb7 100644 --- a/packages/ui/src/components/mobile-product.tsx +++ b/packages/ui/src/components/mobile-product.tsx @@ -5,4 +5,12 @@ export { MobileWorkListScreen, MobileWorkUnitDetailScreen, } from "./mobile-workspace/index"; -export type { MobileWorkspaceScreenProps } from "./mobile-workspace/index"; +export type { + MobileAssistantMessageView, + MobileAssistantView, + MobileSignalView, + MobileWorkspaceScreenProps, + MobileWorkspaceView, + MobileWorkUnitTone, + MobileWorkUnitView, +} from "./mobile-workspace/index"; diff --git a/packages/ui/src/components/mobile-workspace-screens.tsx b/packages/ui/src/components/mobile-workspace-screens.tsx index 6240007..ea09763 100644 --- a/packages/ui/src/components/mobile-workspace-screens.tsx +++ b/packages/ui/src/components/mobile-workspace-screens.tsx @@ -15,6 +15,12 @@ import { } from "lucide-react"; import type { FormEvent, ReactNode } from "react"; +import type { + MobileWorkspaceView, + MobileWorkUnitTone, + MobileWorkUnitView, +} from "./mobile-workspace/models"; + export type MobileWorkspaceVariant = | "character-pass" | "expanded-work-unit" @@ -24,11 +30,13 @@ export type MobileWorkspaceVariant = export interface MobileWorkspaceRendererProps { composerValue: string; + data: MobileWorkspaceView; onBack?: () => void; onComposerChange: (value: string) => void; onComposerSubmit: (event: FormEvent) => void; + onManageProjects?: () => void; onOpenAssistant?: () => void; - onOpenUnit?: () => void; + onOpenUnit?: (workUnitId?: string) => void; onViewWork?: () => void; statusMessage?: string; variant: MobileWorkspaceVariant; @@ -50,6 +58,7 @@ interface ProductHeaderProps { avatar?: boolean; letterLogo?: boolean; onAssistant?: () => void; + onManageProjects?: () => void; profileInset?: boolean; subtitle?: string; } @@ -58,6 +67,7 @@ const ProductHeader = ({ avatar = false, letterLogo = false, onAssistant, + onManageProjects, profileInset = false, subtitle = "Workspace is active", }: ProductHeaderProps) => ( @@ -81,6 +91,7 @@ const ProductHeader = ({ "ml-auto flex size-[42px] items-center justify-center rounded-full bg-[#0b0b0a] text-[13px] font-semibold text-white", profileInset && "mr-[31px]" )} + onClick={onManageProjects} type="button" > YM @@ -98,6 +109,7 @@ const ProductHeader = ({ + + +); + const WorkFeedScreen = ({ + data, onChange, + onManageProjects, onOpenAssistant, onOpen, onSubmit, @@ -200,168 +260,226 @@ const WorkFeedScreen = ({ statusMessage, value, }: ComposerProps & { - onOpen?: () => void; + data: MobileWorkspaceView; + onManageProjects?: () => void; + onOpen?: (workUnitId?: string) => void; onOpenAssistant?: () => void; onViewWork?: () => void; -}) => ( -
- -
-

- Good morning. -

-

- Your work is moving. Two things need you. -

-
-
-

ACTIVE

-

4 units

-
-
-

NEEDS YOU

-

2 blockers

-
-
-

SHIPPED

-

3 this week

-
-
-
-

Now

- -
-
-
- - ↗ - -
-

- IN PROGRESS · 68% -

-

- Updated 4 min ago by Zopu -

-
- -
-

- Launch the new onboarding flow -

-

- First-run flow mapped. Zopu is drafting the welcome sequence. +}) => { + const primaryWorkUnit = data.selectedWorkUnit; + const secondaryWorkUnits = data.workUnits + .filter((workUnit) => workUnit.id !== primaryWorkUnit?.id) + .slice(0, 2); + + return ( +

+ +
+

+ Good morning. +

+

+ {data.projectName + ? `${data.projectName} is moving. ${data.needsAttentionCount} things need you.` + : "Connect a project to put work in motion."}

-
-
-
-
-
-

- NEXT ACTION -

-

- Review the welcome copy +

+
+

ACTIVE

+

+ {data.activeCount} units

+
+

NEEDS YOU

+

+ {data.needsAttentionCount} blockers +

+
+
+

SHIPPED

+

+ {data.shippedCount} total +

+
+
+
+

Now

-
-
-
- - WAITING ON YOU - -
-

- Fix GitHub authentication bug -

-
-
-

Blocked by missing Safari repro logs

-

- Zopu can continue as soon as they’re attached. + {primaryWorkUnit ? ( +

+
+ + ↗ + +
+

+ {primaryWorkUnit.statusLabel} · {primaryWorkUnit.progress}% +

+

+ Updated {primaryWorkUnit.updatedLabel} +

+
+ +
+

+ {primaryWorkUnit.title} +

+

+ {data.latestSignal?.summary ?? primaryWorkUnit.summary}

-
+
+
+
+
+
+

+ NEXT ACTION +

+

+ {primaryWorkUnit.nextAction} +

+
+ +
+
+ ) : ( + + )} + {secondaryWorkUnits[0] ? ( +
+
+ + {secondaryWorkUnits[0].statusLabel.toUpperCase()} + +
+

+ {secondaryWorkUnits[0].title} +

+
+
+

+ {secondaryWorkUnits[0].summary} +

+

+ Updated {secondaryWorkUnits[0].updatedLabel} +

+
+ +
+
+ ) : null} +
+ {secondaryWorkUnits.slice(1).map((workUnit) => ( +
+
+ {workUnit.statusLabel} + + {workUnit.progress}% + +
+

+ {workUnit.title} +

+

+ {workUnit.summary} +

+
+ ))} +
+
+
+
+ + GLOBAL MODE +
+
-
- -
-
-
- RESEARCHING - 42% -
-

- Pricing page rewrite -

-

- Comparing 8 competitors -

-
-
-
- READY - -
-

Q3 launch brief

-

- Draft ready for review -

-
-
- -
-
- - GLOBAL MODE -
- - - onChange(event.target.value)} - placeholder="Ask anything or create new work..." - value={value} - /> - - - {statusMessage ? {statusMessage} : null} -
- -); + onChange(event.target.value)} + placeholder="Ask anything or create new work..." + value={value} + /> + + + {statusMessage ? ( + {statusMessage} + ) : null} + + + ); +}; const DeviceFrame402 = ({ children }: { children: ReactNode }) => (
@@ -371,148 +489,223 @@ const DeviceFrame402 = ({ children }: { children: ReactNode }) => (
); -const ExpandedWorkUnitContent = ({ - compact = false, +const getExpandedTitle = ( + workUnit: MobileWorkUnitView | undefined, + isLoading: boolean +) => { + if (workUnit) { + return workUnit.title; + } + return isLoading ? "Loading work…" : "No work selected"; +}; + +const getUpdatedLabel = ( + workUnit: MobileWorkUnitView | undefined, + compact: boolean +) => { + if (!workUnit) { + return "Waiting for project data"; + } + return compact ? workUnit.updatedLabel : `Updated ${workUnit.updatedLabel}`; +}; + +const getExpandedLayout = ( + compact: boolean, + workUnit: MobileWorkUnitView | undefined +) => ({ + articleHeight: compact ? "h-[500px]" : "h-[610px]", + articleTone: workUnit + ? workUnitToneClass[workUnit.tone] + : "bg-[#e8efff] text-[#14265f]", + milestoneSpacing: compact ? "mt-5" : "mt-[35px]", + summarySpacing: compact ? "mt-3" : "mt-[43px]", + titleSize: compact + ? "mt-4 text-[27px] leading-[30px]" + : "mt-5 text-[29px] leading-[31px]", +}); + +const ExpandedWorkUnitActivity = ({ + compact, + data, + workUnit, }: { - compact?: boolean; -}) => ( -
-
- - IN PROGRESS - - 68% -
-

- Launch onboarding flow -

-

- Create a calm first-run experience that helps new users reach their first - meaningful outcome. -

-
-
-
-
- ON TRACK - - {compact ? "4m ago" : "Updated 4m ago"} - -
-
-

CURRENT STATE

-

- {compact - ? "Flow mapped. Welcome copy and the activation checklist are being drafted." - : "The first-run flow is mapped. Zopu is drafting the welcome sequence and activation checklist."} -

-
-
- - - -
-

- NEXT MILESTONE -

-

- Review the welcome copy -

-

- Ready for your feedback · about 5 min -

-
-
-
-
-

ARTIFACTS

- 2 - - Flow map · Copy draft - -
-
-

ACTIVITY

- 12 - Actions this week -
-
- {compact ? ( + compact: boolean; + data: MobileWorkspaceView; + workUnit?: MobileWorkUnitView; +}) => { + if (compact) { + return (
Z

- Activation checklist updated + {workUnit?.title ?? "Project workspace ready"} +

+

+ Zopu · {workUnit?.updatedLabel ?? "now"}

-

Zopu · 4 minutes ago

- ) : ( - <> -
- RECENT ACTIVITY - View all -
-
-
- - Z - -
-

- Zopu updated the activation checklist -

-

4 minutes ago

-
-
-
- - ↗ - -
-

- Welcome copy draft was attached -

-

18 minutes ago

-
+ ); + } + + return ( + <> +
+ RECENT ACTIVITY + View all +
+
+
+ + Z + +
+

+ {data.latestSignal?.title ?? "Zopu updated the project signal"} +

+

+ {workUnit?.updatedLabel ?? "Recently"} +

- - )} -
-); +
+ + ↗ + +
+

+ {data.artifactCount} project artifacts available +

+

+ Synced from {data.projectName ?? data.organizationLabel} +

+
+
+ + + ); +}; + +const ExpandedWorkUnitContent = ({ + compact = false, + data, +}: { + compact?: boolean; + data: MobileWorkspaceView; +}) => { + const workUnit = data.selectedWorkUnit; + const title = getExpandedTitle(workUnit, data.isLoading); + const summary = + data.latestSignal?.summary ?? + workUnit?.summary ?? + "Create an issue in the project workspace to begin."; + const nextAction = + data.latestSignal?.desiredOutcome ?? + workUnit?.nextAction ?? + "Manage project work"; + const progress = workUnit?.progress ?? 0; + const layout = getExpandedLayout(compact, workUnit); + const milestoneNote = data.latestSignal + ? `Signal: ${data.latestSignal.title}` + : "Ready for your review"; + + return ( +
+
+ + {workUnit?.statusLabel.toUpperCase() ?? "WORKSPACE"} + + {progress}% +
+

+ {title} +

+

+ {summary} +

+
+
+
+
+ {workUnit?.statusLabel.toUpperCase() ?? "READY"} + + {getUpdatedLabel(workUnit, compact)} + +
+
+

+ CURRENT STATE +

+

{summary}

+
+
+ + + +
+

+ NEXT MILESTONE +

+

{nextAction}

+

{milestoneNote}

+
+
+
+
+

ARTIFACTS

+ {data.artifactCount} + Project context +
+
+

ACTIVITY

+ {data.activityCount} + Issues and signals +
+
+ +
+ ); +}; const WorkUnitScreen = ({ + data, onBack, onChange, + onManageProjects, onSubmit, statusMessage, value, -}: ComposerProps & { onBack?: () => void }) => ( +}: ComposerProps & { + data: MobileWorkspaceView; + onBack?: () => void; + onManageProjects?: () => void; +}) => (
- +
void }) => ( - - -
-
-

- Work in motion -

- - MON 20 - -
-

- Scroll vertically. Tap a card to expand it in place. -

-
- ACTIVE WORK - 4 units -
-
-
- - RESEARCHING +}: ComposerProps & { + data: MobileWorkspaceView; + onManageProjects?: () => void; + onOpen?: (workUnitId?: string) => void; +}) => { + const { selectedWorkUnit } = data; + const secondaryWorkUnits = data.workUnits + .filter((workUnit) => workUnit.id !== selectedWorkUnit?.id) + .slice(0, 3); + const previewLayout = [ + "inset-x-[13px] top-0", + "inset-x-[7px] top-[70px]", + "inset-x-1 top-[142px]", + ] as const; + + return ( + + +
+
+

+ Work in motion +

+ + {data.projectName ?? data.organizationLabel} - - WORK-402 +
+

+ Scroll vertically. Tap a card to expand it in place. +

+
+ ACTIVE WORK + + {data.totalCount} units - - Rewrite pricing page - -
-
- - BLOCKED - - BUG-12 - - Fix GitHub authentication - -
-
- - READY FOR REVIEW - - - BRIEF-03 - - Q3 launch brief -
-
-
- - IN PROGRESS - - FLOW-08 -
-

- Launch onboarding flow -

-

- Welcome copy and activation checklist are being drafted. -

-
-
-
-
- 68% COMPLETE - On track -
-
+
+ {secondaryWorkUnits.map((workUnit, index) => ( +
+ + {workUnit.statusLabel} - - Review welcome copy + + {workUnit.code} - - 2 artifacts · Updated 4 minutes ago - - - -
-
-
- -
-); + + {workUnit.title} + + + ))} + {selectedWorkUnit ? ( +
+
+ + {selectedWorkUnit.statusLabel.toUpperCase()} + + + {selectedWorkUnit.code} + +
+

+ {selectedWorkUnit.title} +

+

+ {data.latestSignal?.summary ?? selectedWorkUnit.summary} +

+
+
+
+
+ {selectedWorkUnit.progress}% COMPLETE + + {selectedWorkUnit.updatedLabel} + +
+ +
+ ) : ( + + )} + + + +
+ ); +}; const HomeExpanded = ({ + data, onChange, + onManageProjects, onOpen, onSubmit, statusMessage, value, -}: ComposerProps & { onOpen?: () => void }) => ( +}: ComposerProps & { + data: MobileWorkspaceView; + onManageProjects?: () => void; + onOpen?: (workUnitId?: string) => void; +}) => (
@@ -700,18 +943,20 @@ const HomeExpanded = ({ BLOCKED
- +
); +const assistantStatusDotClass: Record< + MobileWorkspaceView["assistant"]["statusTone"], + string +> = { + amber: "bg-[#e3a127]", + green: "bg-[#18c973]", + red: "bg-[#e04b3f]", +}; + const CharacterChat = ({ + data, onChange, onSubmit, statusMessage, value, -}: ComposerProps) => ( +}: ComposerProps & { data: MobileWorkspaceView }) => (
@@ -737,93 +992,112 @@ const CharacterChat = ({

Zopu

- - Ready to help + + {data.assistant.statusLabel}

-
- TODAY  •  9:41 AM -
-
-
- - - - Zopu - • a fresh start -
-

- Good morning. What are we making clearer today? -

-
- - - -
-
-
- Turn my messy notes into a focused plan. -
-

- 9:42 AM  -

-
-
- - - - Zopu - - THINKING PARTNER - -
-

- Absolutely. I’ll turn the noise into a small set of next steps, then - leave room for the unexpected. -

-
-
- - ↗ - -
-

- Weekly reset -

-

- 3 focus areas · drafted just now -

+
+ {data.assistant.messages.length === 0 ? ( + <> +
+ TODAY
- +
+ + + + Zopu + • a fresh start +
+

+ Good morning. What are we making clearer today? +

+
+ + + +
+ {data.latestSignal ? ( +
+
+ + ↗ + +
+

+ {data.latestSignal.title} +

+

+ Latest project signal +

+
+ +
+

+ {data.latestSignal.summary} +

+
+ ) : null} + + ) : ( +
+ {data.assistant.messages.map((message) => + message.role === "user" ? ( +
+ {message.text} +
+ ) : ( +
+
+ + + + Zopu + + THINKING PARTNER + +
+

+ {message.text} +

+
+ ) + )} + {data.assistant.isBusy ? ( +

Zopu is thinking…

+ ) : null}
-
    -
  • - - Finish the launch brief - today -
  • -
  • - - Block two hours for deep work - Tue -
  • -
  • - - Leave an hour for cleanup - Fri -
  • -
-
+ )}
; + return ; } if (variant === "expanded-work-unit") { - return ; + return ( + + ); } if (variant === "home-expanded-in-place") { - return ; + return ( + + ); } if (variant === "vertical-work-stack") { - return ; + return ( + + ); } if (variant === "work-units-home") { return ( ; + +export type { + MobileAssistantMessageView, + MobileAssistantView, + MobileSignalView, + MobileWorkspaceView, + MobileWorkUnitTone, + MobileWorkUnitView, +} from "./models";