1927 lines
64 KiB
TypeScript
1927 lines
64 KiB
TypeScript
import { cn } from "@code/ui/lib/utils";
|
|
import {
|
|
ArrowLeft,
|
|
ArrowUp,
|
|
Check,
|
|
ChevronRight,
|
|
ExternalLink,
|
|
FileText,
|
|
ListChecks,
|
|
LoaderCircle,
|
|
MoreHorizontal,
|
|
Paperclip,
|
|
Plus,
|
|
Sparkles,
|
|
X,
|
|
} from "lucide-react";
|
|
import type { FormEvent, ReactNode } from "react";
|
|
|
|
import {
|
|
Conversation,
|
|
ConversationContent,
|
|
ConversationScrollButton,
|
|
} from "./ai-elements/conversation";
|
|
import {
|
|
Message,
|
|
MessageContent,
|
|
MessageResponse,
|
|
} from "./ai-elements/message";
|
|
import {
|
|
PromptInput,
|
|
PromptInputBody,
|
|
PromptInputButton,
|
|
PromptInputSubmit,
|
|
PromptInputTextarea,
|
|
} from "./ai-elements/prompt-input";
|
|
import type {
|
|
MobileProjectView,
|
|
MobileWorkspaceView,
|
|
MobileWorkUnitTone,
|
|
MobileWorkUnitView,
|
|
} from "./mobile-workspace/models";
|
|
|
|
export type MobileWorkspaceVariant =
|
|
| "character-pass"
|
|
| "expanded-work-unit"
|
|
| "home-expanded-in-place"
|
|
| "vertical-work-stack"
|
|
| "work-units-home";
|
|
|
|
export interface MobileWorkspaceRendererProps {
|
|
composerValue: string;
|
|
createIssueBody: string;
|
|
createIssueTitle: string;
|
|
data: MobileWorkspaceView;
|
|
modelId?: string;
|
|
modelLabel?: string;
|
|
onBack?: () => void;
|
|
onComposerChange: (value: string) => void;
|
|
onComposerMessageSubmit?: (message: string) => Promise<void>;
|
|
onComposerSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
|
onCreateBodyChange: (value: string) => void;
|
|
onCreateIssue: (event: FormEvent<HTMLFormElement>) => void;
|
|
onCreateIssueFromSignal?: () => void;
|
|
onCreateTitleChange: (value: string) => void;
|
|
onManageProjects?: () => void;
|
|
onProjectManagerClose?: () => void;
|
|
onOpenAssistant?: () => void;
|
|
onOpenUnit?: (workUnitId?: string) => void;
|
|
onProjectSelect?: (projectId: string) => void;
|
|
onRepositoryChange?: (value: string) => void;
|
|
onRepositoryConnect?: (event: FormEvent<HTMLFormElement>) => void;
|
|
onSettingsClose?: () => void;
|
|
onSettingsOpen?: () => void;
|
|
onReviewUnit?: (reviewUrl: string) => void;
|
|
onStartUnit?: (workUnitId: string) => void;
|
|
onViewWork?: () => void;
|
|
pendingAction?: string | null;
|
|
projectManagerOpen?: boolean;
|
|
repositoryValue?: string;
|
|
settingsOpen?: boolean;
|
|
statusMessage?: string;
|
|
variant: MobileWorkspaceVariant;
|
|
}
|
|
|
|
const BrandGlyph = ({ className }: { className?: string }) => (
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"inline-flex h-[23px] w-[9px] items-center justify-center bg-black text-[4px] leading-none font-bold tracking-[-0.08em] text-white [writing-mode:vertical-rl]",
|
|
className
|
|
)}
|
|
>
|
|
ZOPU
|
|
</span>
|
|
);
|
|
|
|
interface ProductHeaderProps {
|
|
avatar?: boolean;
|
|
letterLogo?: boolean;
|
|
onAssistant?: () => void;
|
|
onManageProjects?: () => void;
|
|
profileInset?: boolean;
|
|
subtitle?: string;
|
|
}
|
|
|
|
const ProductHeader = ({
|
|
avatar = false,
|
|
letterLogo = false,
|
|
onAssistant,
|
|
onManageProjects,
|
|
profileInset = false,
|
|
subtitle = "Workspace is active",
|
|
}: ProductHeaderProps) => (
|
|
<header className="flex h-20 shrink-0 items-center bg-white px-4">
|
|
<div className="flex size-10 items-center justify-center rounded-[14px] bg-[#c8ff00] text-[20px] font-bold text-black">
|
|
{letterLogo ? "Z" : <BrandGlyph />}
|
|
</div>
|
|
<div className="ml-3">
|
|
<h1 className="text-[20px] leading-6 font-semibold tracking-[-0.03em] text-[#0b0b0a]">
|
|
Zopu
|
|
</h1>
|
|
<p className="mt-0.5 flex items-center gap-2 text-[13px] leading-4 text-[#7c7772]">
|
|
<span className="size-1.5 rounded-full bg-[#64ad1f]" />
|
|
{subtitle}
|
|
</p>
|
|
</div>
|
|
{avatar ? (
|
|
<button
|
|
aria-label="Open profile"
|
|
className={cn(
|
|
"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
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button
|
|
aria-label="Open Zopu assistant"
|
|
className="ml-auto flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
|
|
onClick={onAssistant}
|
|
type="button"
|
|
>
|
|
<BrandGlyph className="h-[20px] w-[8px]" />
|
|
</button>
|
|
<button
|
|
aria-label="More options"
|
|
className="ml-2 flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
|
|
onClick={onManageProjects}
|
|
type="button"
|
|
>
|
|
<MoreHorizontal className="size-5" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</header>
|
|
);
|
|
|
|
interface ComposerProps {
|
|
contextLabel?: string;
|
|
deviceHomeIndicator?: boolean;
|
|
hint?: string;
|
|
homeIndicator?: boolean;
|
|
onChange: (value: string) => void;
|
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
|
placeholder: string;
|
|
sendBlue?: boolean;
|
|
statusMessage?: string;
|
|
value: string;
|
|
}
|
|
|
|
const ReferenceComposer = ({
|
|
contextLabel,
|
|
deviceHomeIndicator = false,
|
|
hint,
|
|
homeIndicator = false,
|
|
onChange,
|
|
onSubmit,
|
|
placeholder,
|
|
sendBlue = false,
|
|
statusMessage,
|
|
value,
|
|
}: ComposerProps) => (
|
|
<footer
|
|
className={cn(
|
|
"relative shrink-0 border-t border-[#eff0ec] bg-white px-[14px]",
|
|
contextLabel ? "h-[144px] pt-[13px]" : "h-[120px] pt-[14px]",
|
|
!homeIndicator && !contextLabel && "h-[100px]",
|
|
deviceHomeIndicator && "h-[125px]"
|
|
)}
|
|
>
|
|
{contextLabel ? (
|
|
<div className="mb-2 flex h-6 w-fit items-center gap-2 rounded-full bg-[#dbe6ff] px-2.5 text-[10px] font-semibold text-[#315dc0]">
|
|
<span className="size-1.5 rounded-full bg-[#315dc0]" />
|
|
{contextLabel}
|
|
<X className="size-3" />
|
|
</div>
|
|
) : null}
|
|
<form
|
|
className="flex h-[60px] items-center rounded-[30px] bg-[#f2f3ef] p-2"
|
|
onSubmit={onSubmit}
|
|
>
|
|
<button
|
|
aria-label="Add attachment"
|
|
className="flex size-11 shrink-0 items-center justify-center rounded-full bg-white text-[#5e5a56]"
|
|
type="button"
|
|
>
|
|
<Plus className="size-5" strokeWidth={1.8} />
|
|
</button>
|
|
<label className="ml-2 min-w-0 flex-1">
|
|
<span className="sr-only">{placeholder}</span>
|
|
<textarea
|
|
className="block h-5 w-full resize-none overflow-hidden bg-transparent text-[14px] leading-5 text-[#282622] outline-none placeholder:text-[#b3afab]"
|
|
onChange={(event) => onChange(event.target.value)}
|
|
placeholder={placeholder}
|
|
rows={1}
|
|
value={value}
|
|
/>
|
|
{hint || statusMessage ? (
|
|
<span className="block truncate text-[10px] leading-4 text-[#c0bcb7]">
|
|
{statusMessage ?? hint}
|
|
</span>
|
|
) : null}
|
|
</label>
|
|
<button
|
|
aria-label="Send"
|
|
className={cn(
|
|
"ml-2 flex size-11 shrink-0 items-center justify-center rounded-full text-white",
|
|
sendBlue ? "bg-[#315dc0]" : "bg-black",
|
|
!value.trim() && "opacity-100"
|
|
)}
|
|
type="submit"
|
|
>
|
|
<ArrowUp className="size-5" strokeWidth={2.2} />
|
|
</button>
|
|
</form>
|
|
{homeIndicator ? (
|
|
<span className="absolute bottom-[13px] left-1/2 h-[5px] w-[120px] -translate-x-1/2 rounded-full bg-black" />
|
|
) : null}
|
|
</footer>
|
|
);
|
|
|
|
interface CreateWorkFormProps {
|
|
readonly body: string;
|
|
readonly onBodyChange: (value: string) => void;
|
|
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
|
readonly onTitleChange: (value: string) => void;
|
|
readonly pending: boolean;
|
|
readonly title: string;
|
|
}
|
|
|
|
const CreateWorkForm = ({
|
|
body,
|
|
onBodyChange,
|
|
onSubmit,
|
|
onTitleChange,
|
|
pending,
|
|
title,
|
|
}: CreateWorkFormProps) => (
|
|
<form className="mt-4 rounded-[22px] bg-white p-3" onSubmit={onSubmit}>
|
|
<div className="flex items-center">
|
|
<p className="text-[10px] font-semibold uppercase text-[#807b76]">
|
|
Create project work
|
|
</p>
|
|
{pending ? (
|
|
<LoaderCircle className="ml-auto size-3 animate-spin" />
|
|
) : null}
|
|
</div>
|
|
<input
|
|
aria-label="Work title"
|
|
className="mt-2 h-9 w-full rounded-[12px] bg-[#f2f3ef] px-3 text-[13px] outline-none placeholder:text-[#b3afab]"
|
|
disabled={pending}
|
|
onChange={(event) => onTitleChange(event.target.value)}
|
|
placeholder="Outcome or issue title"
|
|
required
|
|
value={title}
|
|
/>
|
|
<textarea
|
|
aria-label="Work description"
|
|
className="mt-2 min-h-16 w-full resize-none rounded-[12px] bg-[#f2f3ef] px-3 py-2 text-[12px] leading-4 outline-none placeholder:text-[#b3afab]"
|
|
disabled={pending}
|
|
onChange={(event) => onBodyChange(event.target.value)}
|
|
placeholder="Describe the desired result or constraints"
|
|
required
|
|
value={body}
|
|
/>
|
|
<button
|
|
className="mt-2 h-9 w-full rounded-[12px] bg-black text-[12px] font-semibold text-white disabled:opacity-50"
|
|
disabled={pending || !title.trim() || !body.trim()}
|
|
type="submit"
|
|
>
|
|
{pending ? "Creating…" : "Create work"}
|
|
</button>
|
|
</form>
|
|
);
|
|
|
|
const ProjectSwitcher = ({
|
|
data,
|
|
onManageProjects,
|
|
onProjectSelect,
|
|
}: {
|
|
readonly data: MobileWorkspaceView;
|
|
readonly onManageProjects?: () => void;
|
|
readonly onProjectSelect?: (projectId: string) => void;
|
|
}) => (
|
|
<div className="flex items-center gap-2 border-b border-[#eff0ec] bg-white px-4 py-2">
|
|
<label className="sr-only" htmlFor="mobile-project-selector">
|
|
Active project
|
|
</label>
|
|
<select
|
|
className="min-w-0 flex-1 bg-transparent text-[12px] font-semibold outline-none"
|
|
id="mobile-project-selector"
|
|
onChange={(event) => onProjectSelect?.(event.target.value)}
|
|
value={data.selectedProjectId ?? ""}
|
|
>
|
|
{data.projects.length === 0 ? (
|
|
<option value="">No connected projects</option>
|
|
) : (
|
|
data.projects.map((project: MobileProjectView) => (
|
|
<option key={project.id} value={project.id}>
|
|
{project.repositoryPath ?? project.name}
|
|
{project.host ? ` · ${project.host}` : ""}
|
|
{project.connected ? "" : " (not connected)"}
|
|
</option>
|
|
))
|
|
)}
|
|
</select>
|
|
<button
|
|
aria-label="Manage projects"
|
|
className="flex size-8 items-center justify-center rounded-full bg-[#f2f3ef]"
|
|
onClick={onManageProjects}
|
|
type="button"
|
|
>
|
|
<MoreHorizontal className="size-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
const ProjectManagementPanel = ({
|
|
createIssueBody,
|
|
createIssueTitle,
|
|
data,
|
|
onClose,
|
|
onCreateBodyChange,
|
|
onCreateIssue,
|
|
onCreateTitleChange,
|
|
onProjectSelect,
|
|
onRepositoryChange,
|
|
onRepositoryConnect,
|
|
pendingAction,
|
|
repositoryValue = "",
|
|
statusMessage,
|
|
}: {
|
|
readonly createIssueBody: string;
|
|
readonly createIssueTitle: string;
|
|
readonly data: MobileWorkspaceView;
|
|
readonly onClose?: () => void;
|
|
readonly onCreateBodyChange: (value: string) => void;
|
|
readonly onCreateIssue: (event: FormEvent<HTMLFormElement>) => void;
|
|
readonly onCreateTitleChange: (value: string) => void;
|
|
readonly onProjectSelect?: (projectId: string) => void;
|
|
readonly onRepositoryChange?: (value: string) => void;
|
|
readonly onRepositoryConnect?: (event: FormEvent<HTMLFormElement>) => void;
|
|
readonly pendingAction?: string | null;
|
|
readonly repositoryValue?: string;
|
|
readonly statusMessage?: string;
|
|
}) => {
|
|
const connecting = pendingAction === "connect";
|
|
return (
|
|
<section
|
|
aria-label="Project workspace"
|
|
className="absolute inset-0 z-30 flex flex-col bg-[#f7f8f5] text-[#11110f]"
|
|
>
|
|
<header className="flex h-20 shrink-0 items-center border-b border-[#e9eae6] bg-white px-4">
|
|
<div>
|
|
<h2 className="text-[18px] font-semibold">Project workspace</h2>
|
|
<p className="text-[11px] text-[#807b76]">
|
|
Connect repositories and create durable work.
|
|
</p>
|
|
</div>
|
|
<button
|
|
aria-label="Close project workspace"
|
|
className="ml-auto flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
|
|
onClick={onClose}
|
|
type="button"
|
|
>
|
|
<X className="size-5" />
|
|
</button>
|
|
</header>
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
|
<div className="rounded-[22px] bg-white p-4">
|
|
<p className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase">
|
|
Connected project
|
|
</p>
|
|
<select
|
|
aria-label="Active project"
|
|
className="mt-2 h-11 w-full rounded-[14px] bg-[#f2f3ef] px-3 text-[13px] font-semibold outline-none"
|
|
disabled={data.projects.length === 0}
|
|
onChange={(event) => onProjectSelect?.(event.target.value)}
|
|
value={data.selectedProjectId ?? ""}
|
|
>
|
|
{data.projects.length === 0 ? (
|
|
<option value="">No connected projects</option>
|
|
) : (
|
|
data.projects.map((project) => (
|
|
<option key={project.id} value={project.id}>
|
|
{project.repositoryPath ?? project.name}
|
|
</option>
|
|
))
|
|
)}
|
|
</select>
|
|
<form className="mt-4" onSubmit={onRepositoryConnect}>
|
|
<label
|
|
className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase"
|
|
htmlFor="mobile-repository-url"
|
|
>
|
|
Connect another repository
|
|
</label>
|
|
<input
|
|
aria-label="Public Git repository URL"
|
|
className="mt-2 h-11 w-full rounded-[14px] bg-[#f2f3ef] px-3 text-[12px] outline-none placeholder:text-[#aaa7a3]"
|
|
disabled={connecting}
|
|
id="mobile-repository-url"
|
|
onChange={(event) => onRepositoryChange?.(event.target.value)}
|
|
placeholder="https://git.openputer.com/owner/repo.git"
|
|
required
|
|
type="url"
|
|
value={repositoryValue}
|
|
/>
|
|
<button
|
|
className="mt-2 flex h-10 w-full items-center justify-center gap-2 rounded-[14px] bg-[#c8ff00] text-[12px] font-semibold text-black disabled:opacity-50"
|
|
disabled={connecting || !repositoryValue.trim()}
|
|
type="submit"
|
|
>
|
|
{connecting ? (
|
|
<LoaderCircle className="size-3.5 animate-spin" />
|
|
) : null}
|
|
{connecting ? "Connecting…" : "Connect repository"}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
{data.selectedProjectId ? (
|
|
<div className="mt-4">
|
|
<CreateWorkForm
|
|
body={createIssueBody}
|
|
onBodyChange={onCreateBodyChange}
|
|
onSubmit={onCreateIssue}
|
|
onTitleChange={onCreateTitleChange}
|
|
pending={pendingAction === "issue"}
|
|
title={createIssueTitle}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
{statusMessage ? (
|
|
<p className="mt-3 text-center text-[11px] text-[#807b76]">
|
|
{statusMessage}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
const ChatSettingsPanel = ({
|
|
modelId = "openrouter/openai/gpt-oss-20b:free",
|
|
modelLabel = "GPT-OSS 20B Free",
|
|
onClose,
|
|
}: {
|
|
readonly modelId?: string;
|
|
readonly modelLabel?: string;
|
|
readonly onClose?: () => void;
|
|
}) => (
|
|
<section
|
|
aria-label="Chat settings"
|
|
className="absolute inset-0 z-40 flex flex-col bg-[#f7f8f5] text-[#11110f]"
|
|
>
|
|
<header className="flex h-20 shrink-0 items-center border-b border-[#e9eae6] bg-white px-4">
|
|
<div>
|
|
<h2 className="text-[18px] font-semibold">Settings</h2>
|
|
<p className="text-[11px] text-[#807b76]">
|
|
Configure how Zopu responds.
|
|
</p>
|
|
</div>
|
|
<button
|
|
aria-label="Close settings"
|
|
className="ml-auto flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
|
|
onClick={onClose}
|
|
type="button"
|
|
>
|
|
<X className="size-5" />
|
|
</button>
|
|
</header>
|
|
<div className="p-4">
|
|
<div className="rounded-[22px] bg-white p-4">
|
|
<label
|
|
className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase"
|
|
htmlFor="mobile-chat-model"
|
|
>
|
|
Chat model
|
|
</label>
|
|
<select
|
|
className="mt-2 h-11 w-full rounded-[14px] bg-[#f2f3ef] px-3 text-[13px] font-semibold outline-none"
|
|
id="mobile-chat-model"
|
|
value={modelId}
|
|
>
|
|
<option value={modelId}>{modelLabel}</option>
|
|
</select>
|
|
<p className="mt-2 text-[11px] leading-4 text-[#807b76]">
|
|
A tool-capable free OpenRouter model is active for the local Zopu
|
|
agent.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
|
|
const WorkUnitActions = ({
|
|
onReview,
|
|
onStart,
|
|
pending,
|
|
workUnit,
|
|
}: {
|
|
readonly onReview?: (reviewUrl: string) => void;
|
|
readonly onStart?: (workUnitId: string) => void;
|
|
readonly pending: boolean;
|
|
readonly workUnit?: MobileWorkUnitView;
|
|
}) => {
|
|
if (!workUnit) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div className="mt-3 flex gap-2">
|
|
{workUnit.canStart || workUnit.canRetry ? (
|
|
<button
|
|
className="flex h-9 flex-1 items-center justify-center gap-2 rounded-[12px] bg-black text-[12px] font-semibold text-white disabled:opacity-50"
|
|
disabled={pending}
|
|
onClick={() => onStart?.(workUnit.id)}
|
|
type="button"
|
|
>
|
|
{pending ? <LoaderCircle className="size-3 animate-spin" /> : null}
|
|
{workUnit.canRetry ? "Retry work" : "Start work"}
|
|
</button>
|
|
) : null}
|
|
{workUnit.reviewUrl ? (
|
|
<button
|
|
className="flex h-9 flex-1 items-center justify-center gap-2 rounded-[12px] bg-[#c8ff00] text-[12px] font-semibold text-black"
|
|
onClick={() => onReview?.(workUnit.reviewUrl as string)}
|
|
type="button"
|
|
>
|
|
<ExternalLink className="size-3.5" />
|
|
Review PR
|
|
{workUnit.pullRequestNumber ? ` #${workUnit.pullRequestNumber}` : ""}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const workUnitToneClass: Record<MobileWorkUnitTone, string> = {
|
|
blue: "bg-[#e8efff] text-[#14265f]",
|
|
green: "bg-[#e8f7ef] text-[#105c35]",
|
|
orange: "bg-[#fff0e8] text-[#8a2e13]",
|
|
purple: "bg-[#f1e8ff] text-[#3e0b74]",
|
|
};
|
|
|
|
const workUnitStatusClass: Record<MobileWorkUnitTone, string> = {
|
|
blue: "text-[#315dc0]",
|
|
green: "text-[#238050]",
|
|
orange: "text-[#c53c08]",
|
|
purple: "text-[#7137ef]",
|
|
};
|
|
|
|
const EmptyWorkState = ({
|
|
isLoading,
|
|
onManageProjects,
|
|
}: {
|
|
isLoading: boolean;
|
|
onManageProjects?: () => void;
|
|
}) => (
|
|
<article className="mt-3 grid h-[190px] place-items-center rounded-[27px] bg-white p-6 text-center">
|
|
<div>
|
|
<p className="text-[15px] font-semibold">
|
|
{isLoading ? "Loading your work…" : "No work units yet"}
|
|
</p>
|
|
<p className="mt-1 text-[12px] leading-4 text-[#807b76]">
|
|
{isLoading
|
|
? "Zopu is syncing projects, signals, and activity."
|
|
: "Import a project and create an issue to start the flow."}
|
|
</p>
|
|
<button
|
|
className={cn(
|
|
"mt-4 rounded-[14px] bg-[#c8ff00] px-4 py-2 text-[12px] font-semibold",
|
|
isLoading && "invisible"
|
|
)}
|
|
disabled={isLoading}
|
|
onClick={onManageProjects}
|
|
type="button"
|
|
>
|
|
Manage projects
|
|
</button>
|
|
</div>
|
|
</article>
|
|
);
|
|
|
|
const WorkFeedScreen = ({
|
|
createIssueBody,
|
|
createIssueTitle,
|
|
data,
|
|
onChange,
|
|
onCreateBodyChange,
|
|
onCreateIssue,
|
|
onCreateTitleChange,
|
|
onManageProjects,
|
|
onOpenAssistant,
|
|
onOpen,
|
|
onProjectSelect,
|
|
onReviewUnit,
|
|
onStartUnit,
|
|
onSubmit,
|
|
onViewWork,
|
|
pendingAction,
|
|
statusMessage,
|
|
value,
|
|
}: ComposerProps & {
|
|
createIssueBody: string;
|
|
createIssueTitle: string;
|
|
data: MobileWorkspaceView;
|
|
onCreateBodyChange: (value: string) => void;
|
|
onCreateIssue: (event: FormEvent<HTMLFormElement>) => void;
|
|
onCreateTitleChange: (value: string) => void;
|
|
onManageProjects?: () => void;
|
|
onOpen?: (workUnitId?: string) => void;
|
|
onOpenAssistant?: () => void;
|
|
onProjectSelect?: (projectId: string) => void;
|
|
onReviewUnit?: (reviewUrl: string) => void;
|
|
onStartUnit?: (workUnitId: string) => void;
|
|
pendingAction?: string | null;
|
|
onViewWork?: () => void;
|
|
}) => {
|
|
const primaryWorkUnit = data.selectedWorkUnit;
|
|
const secondaryWorkUnits = data.workUnits
|
|
.filter((workUnit) => workUnit.id !== primaryWorkUnit?.id)
|
|
.slice(0, 2);
|
|
|
|
return (
|
|
<div className="mx-auto flex h-[min(100dvh,844px)] w-full max-w-[390px] flex-col overflow-hidden bg-white text-[#0b0b0a]">
|
|
<ProductHeader
|
|
onAssistant={onOpenAssistant}
|
|
onManageProjects={onManageProjects}
|
|
subtitle={`${data.activeCount} active work units`}
|
|
/>
|
|
<ProjectSwitcher
|
|
data={data}
|
|
onManageProjects={onManageProjects}
|
|
onProjectSelect={onProjectSelect}
|
|
/>
|
|
<main className="min-h-0 flex-1 bg-[#f7f8f5] px-4 pt-4">
|
|
<h2 className="text-[29px] leading-9 font-semibold tracking-[-0.05em]">
|
|
Good morning.
|
|
</h2>
|
|
<p className="text-[14px] leading-5 text-[#807b76]">
|
|
{data.projectName
|
|
? `${data.projectName} is moving. ${data.needsAttentionCount} things need you.`
|
|
: "Connect a project to put work in motion."}
|
|
</p>
|
|
<div className="mt-2 grid h-[55px] grid-cols-3 gap-2">
|
|
<div className="rounded-[18px] bg-white px-2.5 py-2">
|
|
<p className="text-[9px] font-semibold text-[#aaa6a1]">ACTIVE</p>
|
|
<p className="text-[17px] leading-5 font-semibold">
|
|
{data.activeCount} units
|
|
</p>
|
|
</div>
|
|
<div className="rounded-[18px] bg-[#fff0e8] px-2.5 py-2 text-[#9a3215]">
|
|
<p className="text-[9px] font-semibold">NEEDS YOU</p>
|
|
<p className="text-[17px] leading-5 font-semibold">
|
|
{data.needsAttentionCount} blockers
|
|
</p>
|
|
</div>
|
|
<div className="rounded-[18px] bg-[#e8f7ef] px-2.5 py-2 text-[#23633d]">
|
|
<p className="text-[9px] font-semibold">SHIPPED</p>
|
|
<p className="text-[17px] leading-5 font-semibold">
|
|
{data.shippedCount} total
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-[18px] flex items-center">
|
|
<h3 className="text-[14px] font-semibold">Now</h3>
|
|
<button
|
|
className="ml-auto text-[12px] text-[#807b76]"
|
|
onClick={onViewWork}
|
|
type="button"
|
|
>
|
|
View all {data.totalCount}
|
|
</button>
|
|
</div>
|
|
{primaryWorkUnit ? (
|
|
<article
|
|
className={cn(
|
|
"mt-3 h-[190px] rounded-[27px] p-[14px]",
|
|
workUnitToneClass[primaryWorkUnit.tone]
|
|
)}
|
|
>
|
|
<div className="flex items-center">
|
|
<span className="flex size-[26px] items-center justify-center rounded-[9px] bg-white text-[#315dc0]">
|
|
↗
|
|
</span>
|
|
<div className="ml-2">
|
|
<p
|
|
className={cn(
|
|
"text-[10px] font-semibold uppercase",
|
|
workUnitStatusClass[primaryWorkUnit.tone]
|
|
)}
|
|
>
|
|
{primaryWorkUnit.statusLabel} · {primaryWorkUnit.progress}%
|
|
</p>
|
|
<p className="text-[10px] text-[#60718e]">
|
|
Updated {primaryWorkUnit.updatedLabel}
|
|
</p>
|
|
</div>
|
|
<ChevronRight className="ml-auto size-4 text-[#315dc0]" />
|
|
</div>
|
|
<h3 className="mt-3 text-[20px] leading-6 font-semibold tracking-[-0.035em]">
|
|
{primaryWorkUnit.title}
|
|
</h3>
|
|
<p className="mt-1 overflow-hidden text-[13px] leading-5 whitespace-nowrap text-[#526482]">
|
|
{data.latestSignal?.summary ?? primaryWorkUnit.summary}
|
|
</p>
|
|
<div className="mt-3 h-[7px] rounded-full bg-[#315dc0]/15">
|
|
<div
|
|
className="h-full rounded-full bg-[#315dc0]"
|
|
style={{ width: `${primaryWorkUnit.progress}%` }}
|
|
/>
|
|
</div>
|
|
<div className="mt-[10px] flex h-[42px] gap-2">
|
|
<div className="min-w-0 flex-1 rounded-[14px] bg-white px-2 py-1.5">
|
|
<p className="text-[9px] font-semibold text-[#8a97b5]">
|
|
NEXT ACTION
|
|
</p>
|
|
<p className="truncate text-[12px] font-semibold">
|
|
{primaryWorkUnit.nextAction}
|
|
</p>
|
|
</div>
|
|
<button
|
|
className="rounded-[14px] bg-[#c8ff00] px-4 text-[12px] font-semibold text-black"
|
|
onClick={() => onOpen?.(primaryWorkUnit.id)}
|
|
type="button"
|
|
>
|
|
Open unit →
|
|
</button>
|
|
</div>
|
|
<WorkUnitActions
|
|
onReview={onReviewUnit}
|
|
onStart={onStartUnit}
|
|
pending={pendingAction === `issue:${primaryWorkUnit.id}`}
|
|
workUnit={primaryWorkUnit}
|
|
/>
|
|
</article>
|
|
) : (
|
|
<EmptyWorkState
|
|
isLoading={data.isLoading}
|
|
onManageProjects={onManageProjects}
|
|
/>
|
|
)}
|
|
<CreateWorkForm
|
|
body={createIssueBody}
|
|
onBodyChange={onCreateBodyChange}
|
|
onSubmit={onCreateIssue}
|
|
onTitleChange={onCreateTitleChange}
|
|
pending={pendingAction === "issue"}
|
|
title={createIssueTitle}
|
|
/>
|
|
{secondaryWorkUnits[0] ? (
|
|
<article
|
|
className={cn(
|
|
"mt-[14px] h-[116px] rounded-[25px] p-[14px]",
|
|
workUnitToneClass[secondaryWorkUnits[0].tone]
|
|
)}
|
|
>
|
|
<div className="flex items-center text-[10px] font-semibold text-[#c53c08]">
|
|
<span className="mr-2 size-2 rounded-full bg-[#ff7620]" />
|
|
{secondaryWorkUnits[0].statusLabel.toUpperCase()}
|
|
<ChevronRight className="ml-auto size-4" />
|
|
</div>
|
|
<h3 className="mt-[13px] text-[18px] leading-5 font-semibold">
|
|
{secondaryWorkUnits[0].title}
|
|
</h3>
|
|
<div className="mt-2 flex items-center">
|
|
<div>
|
|
<p className="max-w-[240px] truncate text-[12px]">
|
|
{secondaryWorkUnits[0].summary}
|
|
</p>
|
|
<p className="text-[10px] text-[#b3aaa5]">
|
|
Updated {secondaryWorkUnits[0].updatedLabel}
|
|
</p>
|
|
</div>
|
|
<button
|
|
className="ml-auto rounded-[13px] bg-white px-5 py-2 text-[11px] font-semibold"
|
|
onClick={() => onOpen?.(secondaryWorkUnits[0]?.id)}
|
|
type="button"
|
|
>
|
|
Open
|
|
</button>
|
|
</div>
|
|
</article>
|
|
) : null}
|
|
<div className="mt-[14px] grid h-[94px] grid-cols-2 gap-[10px]">
|
|
{secondaryWorkUnits.slice(1).map((workUnit) => (
|
|
<article
|
|
className={cn(
|
|
"rounded-[24px] p-3",
|
|
workUnitToneClass[workUnit.tone]
|
|
)}
|
|
key={workUnit.id}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex text-[10px] font-semibold uppercase",
|
|
workUnitStatusClass[workUnit.tone]
|
|
)}
|
|
>
|
|
{workUnit.statusLabel}
|
|
<span className="ml-auto font-normal">
|
|
{workUnit.progress}%
|
|
</span>
|
|
</div>
|
|
<h3 className="mt-3 text-[15px] font-semibold">
|
|
{workUnit.title}
|
|
</h3>
|
|
<p className="mt-1 truncate text-[11px] opacity-65">
|
|
{workUnit.summary}
|
|
</p>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</main>
|
|
<footer className="h-[100px] shrink-0 border-t border-[#eff0ec] bg-white px-3 pt-[10px]">
|
|
<div className="mb-[7px] flex h-5 w-fit items-center gap-2 rounded-full bg-[#f1f2ef] px-2 text-[10px] font-semibold text-[#5f5b57]">
|
|
<span className="size-1.5 rounded-full bg-[#c8ff00]" />
|
|
GLOBAL MODE
|
|
</div>
|
|
<form
|
|
className="flex h-[52px] items-center rounded-[26px] bg-[#f2f3ef] p-2"
|
|
onSubmit={onSubmit}
|
|
>
|
|
<button
|
|
aria-label="Add attachment"
|
|
className="flex size-9 items-center justify-center rounded-full bg-white"
|
|
type="button"
|
|
>
|
|
<Plus className="size-5" />
|
|
</button>
|
|
<input
|
|
className="min-w-0 flex-1 bg-transparent px-2 text-[14px] outline-none placeholder:text-[#b3afab]"
|
|
onChange={(event) => onChange(event.target.value)}
|
|
placeholder="Ask anything or create new work..."
|
|
value={value}
|
|
/>
|
|
<button
|
|
aria-label="Send"
|
|
className="flex size-9 items-center justify-center rounded-full bg-black text-white"
|
|
type="submit"
|
|
>
|
|
<ArrowUp className="size-5" />
|
|
</button>
|
|
</form>
|
|
{statusMessage ? (
|
|
<span className="sr-only">{statusMessage}</span>
|
|
) : null}
|
|
</footer>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DeviceFrame402 = ({ children }: { children: ReactNode }) => (
|
|
<div className="mx-auto h-[min(100dvh,855px)] w-full max-w-[402px] overflow-hidden bg-black p-[5px] pb-0">
|
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-white">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
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;
|
|
data: MobileWorkspaceView;
|
|
workUnit?: MobileWorkUnitView;
|
|
}) => {
|
|
if (compact) {
|
|
return (
|
|
<div className="mt-[14px] flex items-center gap-3">
|
|
<span className="flex size-[30px] items-center justify-center rounded-[10px] bg-white text-[#315dc0]">
|
|
Z
|
|
</span>
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-[#33405c]">
|
|
{workUnit?.title ?? "Project workspace ready"}
|
|
</p>
|
|
<p className="text-[10px] text-[#6e83b4]">
|
|
Zopu · {workUnit?.updatedLabel ?? "now"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="mt-[14px] flex text-[10px] font-semibold text-[#6e83b4]">
|
|
RECENT ACTIVITY
|
|
<span className="ml-auto font-normal text-[#315dc0]">View all</span>
|
|
</div>
|
|
<div className="mt-3 grid gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<span className="flex size-7 items-center justify-center rounded-[9px] bg-white text-[#315dc0]">
|
|
Z
|
|
</span>
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-[#33405c]">
|
|
{data.latestSignal?.title ?? "Zopu updated the project signal"}
|
|
</p>
|
|
<p className="text-[10px] text-[#6e83b4]">
|
|
{workUnit?.updatedLabel ?? "Recently"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="flex size-7 items-center justify-center rounded-[9px] bg-white text-[#315dc0]">
|
|
↗
|
|
</span>
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-[#33405c]">
|
|
{data.artifactCount} project artifacts available
|
|
</p>
|
|
<p className="text-[10px] text-[#6e83b4]">
|
|
Synced from {data.projectName ?? data.organizationLabel}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const ExpandedWorkUnitContent = ({
|
|
compact = false,
|
|
data,
|
|
onReview,
|
|
onStart,
|
|
pendingAction,
|
|
}: {
|
|
compact?: boolean;
|
|
data: MobileWorkspaceView;
|
|
onReview?: (reviewUrl: string) => void;
|
|
onStart?: (workUnitId: string) => void;
|
|
pendingAction?: string | null;
|
|
}) => {
|
|
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 (
|
|
<article
|
|
className={cn(
|
|
"rounded-[30px] p-5",
|
|
layout.articleTone,
|
|
layout.articleHeight
|
|
)}
|
|
>
|
|
<div className="flex items-center">
|
|
<span className="rounded-full bg-[#d6e2ff] px-2 py-1 text-[10px] font-semibold text-[#315dc0]">
|
|
{workUnit?.statusLabel.toUpperCase() ?? "WORKSPACE"}
|
|
</span>
|
|
<span className="ml-auto text-[11px] text-[#6e83b4]">{progress}%</span>
|
|
</div>
|
|
<h1 className={cn("font-semibold tracking-[-0.045em]", layout.titleSize)}>
|
|
{title}
|
|
</h1>
|
|
<p
|
|
className={cn(
|
|
"text-[14px] leading-5 text-[#60718e]",
|
|
layout.summarySpacing
|
|
)}
|
|
>
|
|
{summary}
|
|
</p>
|
|
<div className="mt-[14px] h-[7px] rounded-full bg-[#315dc0]/15">
|
|
<div
|
|
className="h-full rounded-full bg-[#315dc0]"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
<div className="mt-1.5 flex text-[10px] font-semibold text-[#315dc0]">
|
|
{workUnit?.statusLabel.toUpperCase() ?? "READY"}
|
|
<span className="ml-auto font-normal text-[#6e83b4]">
|
|
{getUpdatedLabel(workUnit, compact)}
|
|
</span>
|
|
</div>
|
|
<div className="mt-[18px] border-t border-[#315dc0]/15 pt-[14px]">
|
|
<p className="text-[10px] font-semibold text-[#6e83b4]">
|
|
CURRENT STATE
|
|
</p>
|
|
<p className="mt-1 text-[14px] leading-5 text-[#33405c]">{summary}</p>
|
|
</div>
|
|
<section
|
|
className={cn(
|
|
"flex items-center rounded-[19px] bg-white p-3",
|
|
layout.milestoneSpacing
|
|
)}
|
|
>
|
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-[13px] bg-[#c8ff00] text-black">
|
|
<Check className="size-5" />
|
|
</span>
|
|
<div className="ml-3 min-w-0">
|
|
<p className="text-[10px] font-semibold text-[#6e83b4]">
|
|
NEXT MILESTONE
|
|
</p>
|
|
<p className="truncate text-[14px] font-semibold">{nextAction}</p>
|
|
<p className="text-[10px] text-[#6e83b4]">{milestoneNote}</p>
|
|
</div>
|
|
</section>
|
|
<div className="mt-[14px] grid h-[78px] grid-cols-2 gap-[10px]">
|
|
<div className="rounded-[19px] bg-[#dce6fc] p-3">
|
|
<p className="text-[10px] font-semibold text-[#6e83b4]">ARTIFACTS</p>
|
|
<strong className="mt-1 block text-xl">{data.artifactCount}</strong>
|
|
<span className="text-[10px] text-[#60718e]">Project context</span>
|
|
</div>
|
|
<div className="rounded-[19px] bg-[#dce6fc] p-3">
|
|
<p className="text-[10px] font-semibold text-[#6e83b4]">ACTIVITY</p>
|
|
<strong className="mt-1 block text-xl">{data.activityCount}</strong>
|
|
<span className="text-[10px] text-[#60718e]">Issues and signals</span>
|
|
</div>
|
|
</div>
|
|
<ExpandedWorkUnitActivity
|
|
compact={compact}
|
|
data={data}
|
|
workUnit={workUnit}
|
|
/>
|
|
<WorkUnitActions
|
|
onReview={onReview}
|
|
onStart={onStart}
|
|
pending={pendingAction === `issue:${workUnit?.id}`}
|
|
workUnit={workUnit}
|
|
/>
|
|
</article>
|
|
);
|
|
};
|
|
|
|
const WorkUnitScreen = ({
|
|
createIssueBody,
|
|
createIssueTitle,
|
|
data,
|
|
onBack,
|
|
onChange,
|
|
onCreateBodyChange,
|
|
onCreateIssue,
|
|
onCreateTitleChange,
|
|
onManageProjects,
|
|
onSubmit,
|
|
onProjectSelect,
|
|
onReviewUnit,
|
|
onStartUnit,
|
|
pendingAction,
|
|
statusMessage,
|
|
value,
|
|
}: ComposerProps & {
|
|
createIssueBody: string;
|
|
createIssueTitle: string;
|
|
data: MobileWorkspaceView;
|
|
onCreateBodyChange: (value: string) => void;
|
|
onCreateIssue: (event: FormEvent<HTMLFormElement>) => void;
|
|
onCreateTitleChange: (value: string) => void;
|
|
onBack?: () => void;
|
|
onManageProjects?: () => void;
|
|
onProjectSelect?: (projectId: string) => void;
|
|
onReviewUnit?: (reviewUrl: string) => void;
|
|
onStartUnit?: (workUnitId: string) => void;
|
|
pendingAction?: string | null;
|
|
}) => (
|
|
<DeviceFrame402>
|
|
<header className="flex h-20 shrink-0 items-center bg-white px-4">
|
|
<button
|
|
aria-label="Back to work units"
|
|
className="flex size-10 items-center justify-center rounded-full bg-[#fbfcf9]"
|
|
onClick={onBack}
|
|
type="button"
|
|
>
|
|
<ArrowLeft className="size-5" />
|
|
</button>
|
|
<div className="min-w-0 flex-1 text-center">
|
|
<p className="text-[16px] font-semibold">Work unit</p>
|
|
<p className="text-[11px] text-[#807b76]">
|
|
{data.selectedWorkUnit
|
|
? `${data.selectedWorkUnit.code} · ${data.selectedWorkUnit.statusLabel}`
|
|
: data.organizationLabel}
|
|
</p>
|
|
</div>
|
|
<button
|
|
aria-label="More work unit actions"
|
|
className="flex size-10 items-center justify-center rounded-full bg-[#fbfcf9]"
|
|
onClick={onManageProjects}
|
|
type="button"
|
|
>
|
|
<MoreHorizontal className="size-5" />
|
|
</button>
|
|
</header>
|
|
<ProjectSwitcher
|
|
data={data}
|
|
onManageProjects={onManageProjects}
|
|
onProjectSelect={onProjectSelect}
|
|
/>
|
|
<main className="min-h-0 flex-1 bg-[#f7f8f5] px-[17px] pt-4">
|
|
<ExpandedWorkUnitContent
|
|
data={data}
|
|
onReview={onReviewUnit}
|
|
onStart={onStartUnit}
|
|
pendingAction={pendingAction}
|
|
/>
|
|
</main>
|
|
<CreateWorkForm
|
|
body={createIssueBody}
|
|
onBodyChange={onCreateBodyChange}
|
|
onSubmit={onCreateIssue}
|
|
onTitleChange={onCreateTitleChange}
|
|
pending={pendingAction === "issue"}
|
|
title={createIssueTitle}
|
|
/>
|
|
<ReferenceComposer
|
|
contextLabel={
|
|
data.selectedWorkUnit?.title.toUpperCase() ?? "PROJECT WORK"
|
|
}
|
|
hint="Zopu can use your project signals"
|
|
onChange={onChange}
|
|
onSubmit={onSubmit}
|
|
placeholder="Ask about this work unit..."
|
|
sendBlue
|
|
statusMessage={statusMessage}
|
|
value={value}
|
|
/>
|
|
</DeviceFrame402>
|
|
);
|
|
|
|
const VerticalStack = ({
|
|
data,
|
|
onChange,
|
|
onManageProjects,
|
|
onOpen,
|
|
onProjectSelect,
|
|
onSubmit,
|
|
statusMessage,
|
|
value,
|
|
}: ComposerProps & {
|
|
data: MobileWorkspaceView;
|
|
onManageProjects?: () => void;
|
|
onOpen?: (workUnitId?: string) => void;
|
|
onProjectSelect?: (projectId: 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 (
|
|
<DeviceFrame402>
|
|
<ProductHeader
|
|
avatar
|
|
letterLogo
|
|
onManageProjects={onManageProjects}
|
|
profileInset
|
|
subtitle={`${data.activeCount} active work units`}
|
|
/>
|
|
<ProjectSwitcher
|
|
data={data}
|
|
onManageProjects={onManageProjects}
|
|
onProjectSelect={onProjectSelect}
|
|
/>
|
|
<main className="relative min-h-0 flex-1 bg-[#f7f8f5] px-5 pt-8">
|
|
<div className="flex items-center">
|
|
<h2 className="text-[29px] leading-9 font-semibold tracking-[-0.05em]">
|
|
Work in motion
|
|
</h2>
|
|
<span className="ml-auto max-w-28 truncate rounded-full bg-white px-3 py-1.5 text-[11px] font-semibold text-[#5f5b57]">
|
|
{data.projectName ?? data.organizationLabel}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-[14px] leading-5 text-[#807b76]">
|
|
Scroll vertically. Tap a card to expand it in place.
|
|
</p>
|
|
<div className="mt-[34px] flex text-[12px]">
|
|
<span className="font-semibold">ACTIVE WORK</span>
|
|
<span className="ml-auto text-[#807b76]">
|
|
{data.totalCount} units
|
|
</span>
|
|
</div>
|
|
<section className="absolute inset-x-5 top-[142px] h-[500px]">
|
|
{secondaryWorkUnits.map((workUnit, index) => (
|
|
<article
|
|
className={cn(
|
|
"absolute h-[150px] rounded-[28px] p-5",
|
|
previewLayout[index],
|
|
workUnitToneClass[workUnit.tone]
|
|
)}
|
|
key={workUnit.id}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"text-[10px] font-semibold uppercase",
|
|
workUnitStatusClass[workUnit.tone]
|
|
)}
|
|
>
|
|
{workUnit.statusLabel}
|
|
</span>
|
|
<span className="float-right text-[10px] opacity-60">
|
|
{workUnit.code}
|
|
</span>
|
|
<strong className="mt-4 block truncate text-[22px]">
|
|
{workUnit.title}
|
|
</strong>
|
|
</article>
|
|
))}
|
|
{selectedWorkUnit ? (
|
|
<article
|
|
className={cn(
|
|
"absolute inset-x-px top-[218px] h-[285px] rounded-[30px] p-5",
|
|
workUnitToneClass[selectedWorkUnit.tone]
|
|
)}
|
|
>
|
|
<div className="flex items-center">
|
|
<span className="rounded-full bg-[#d6e2ff] px-2 py-1 text-[10px] font-semibold text-[#315dc0]">
|
|
{selectedWorkUnit.statusLabel.toUpperCase()}
|
|
</span>
|
|
<span className="ml-auto text-[10px] text-[#6e83b4]">
|
|
{selectedWorkUnit.code}
|
|
</span>
|
|
</div>
|
|
<h3 className="mt-4 truncate text-[24px] leading-[27px] font-semibold tracking-[-0.04em]">
|
|
{selectedWorkUnit.title}
|
|
</h3>
|
|
<p className="mt-2 truncate text-[13px] text-[#60718e]">
|
|
{data.latestSignal?.summary ?? selectedWorkUnit.summary}
|
|
</p>
|
|
<div className="mt-4 h-[7px] rounded-full bg-[#315dc0]/15">
|
|
<div
|
|
className="h-full rounded-full bg-[#315dc0]"
|
|
style={{ width: `${selectedWorkUnit.progress}%` }}
|
|
/>
|
|
</div>
|
|
<div className="mt-2 flex text-[10px] font-semibold text-[#315dc0]">
|
|
{selectedWorkUnit.progress}% COMPLETE
|
|
<span className="ml-auto font-normal text-[#6e83b4]">
|
|
{selectedWorkUnit.updatedLabel}
|
|
</span>
|
|
</div>
|
|
<button
|
|
className="mt-5 flex w-full items-center border-t border-[#315dc0]/15 pt-4 text-left"
|
|
onClick={() => onOpen?.(selectedWorkUnit.id)}
|
|
type="button"
|
|
>
|
|
<span className="flex size-10 items-center justify-center rounded-[14px] bg-white text-[#315dc0]">
|
|
↗
|
|
</span>
|
|
<span className="ml-3 min-w-0">
|
|
<span className="block text-[10px] font-semibold text-[#6e83b4]">
|
|
NEXT MILESTONE
|
|
</span>
|
|
<span className="block truncate text-[14px] font-semibold">
|
|
{selectedWorkUnit.nextAction}
|
|
</span>
|
|
<span className="block text-[10px] text-[#6e83b4]">
|
|
{data.artifactCount} artifacts · {data.activityCount} events
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</article>
|
|
) : (
|
|
<EmptyWorkState
|
|
isLoading={data.isLoading}
|
|
onManageProjects={onManageProjects}
|
|
/>
|
|
)}
|
|
</section>
|
|
</main>
|
|
<ReferenceComposer
|
|
deviceHomeIndicator
|
|
hint="Use @ to reference a work unit"
|
|
homeIndicator
|
|
onChange={onChange}
|
|
onSubmit={onSubmit}
|
|
placeholder="Ask Zopu or create new work..."
|
|
statusMessage={statusMessage}
|
|
value={value}
|
|
/>
|
|
</DeviceFrame402>
|
|
);
|
|
};
|
|
|
|
const HomeExpanded = ({
|
|
data,
|
|
onChange,
|
|
onManageProjects,
|
|
onOpen,
|
|
onProjectSelect,
|
|
onSubmit,
|
|
statusMessage,
|
|
value,
|
|
}: ComposerProps & {
|
|
data: MobileWorkspaceView;
|
|
onManageProjects?: () => void;
|
|
onOpen?: (workUnitId?: string) => void;
|
|
onProjectSelect?: (projectId: string) => void;
|
|
}) => (
|
|
<DeviceFrame402>
|
|
<ProductHeader
|
|
avatar
|
|
letterLogo
|
|
onManageProjects={onManageProjects}
|
|
profileInset
|
|
subtitle={`${data.activeCount} active work units`}
|
|
/>
|
|
<ProjectSwitcher
|
|
data={data}
|
|
onManageProjects={onManageProjects}
|
|
onProjectSelect={onProjectSelect}
|
|
/>
|
|
<main className="relative min-h-0 flex-1 bg-[#f7f8f5] px-5 pt-[27px]">
|
|
<div className="flex items-center">
|
|
<h2 className="text-[29px] leading-9 font-semibold tracking-[-0.05em]">
|
|
Work in motion
|
|
</h2>
|
|
<span className="ml-auto rounded-full bg-white px-3 py-1.5 text-[11px] font-semibold text-[#5f5b57]">
|
|
MON 20
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-[14px] leading-5 text-[#807b76]">
|
|
One work unit is expanded in the stack.
|
|
</p>
|
|
<div className="absolute inset-x-[31px] top-[97px] h-24 rounded-[28px] bg-[#f1e8ff] px-4 pt-4 text-[10px] font-semibold text-[#7137ef]">
|
|
RESEARCHING
|
|
</div>
|
|
<div className="absolute inset-x-[26px] top-[117px] h-24 rounded-[28px] bg-[#fff0e8] px-4 pt-4 text-[10px] font-semibold text-[#c53c08]">
|
|
BLOCKED
|
|
</div>
|
|
<div className="absolute inset-x-[17px] top-[139px]">
|
|
<ExpandedWorkUnitContent compact data={data} />
|
|
<button
|
|
aria-label={`Open ${data.selectedWorkUnit?.title ?? "selected work unit"}`}
|
|
className="absolute inset-0 rounded-[30px] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#315dc0]"
|
|
onClick={() => onOpen?.(data.selectedWorkUnit?.id)}
|
|
type="button"
|
|
/>
|
|
</div>
|
|
</main>
|
|
<ReferenceComposer
|
|
contextLabel={
|
|
data.selectedWorkUnit?.title.toUpperCase() ?? "PROJECT WORK"
|
|
}
|
|
hint="Zopu can use your project signals"
|
|
onChange={onChange}
|
|
onSubmit={onSubmit}
|
|
placeholder="Ask about this work unit..."
|
|
sendBlue
|
|
statusMessage={statusMessage}
|
|
value={value}
|
|
/>
|
|
</DeviceFrame402>
|
|
);
|
|
|
|
const assistantStatusDotClass: Record<
|
|
MobileWorkspaceView["assistant"]["statusTone"],
|
|
string
|
|
> = {
|
|
amber: "bg-[#e3a127]",
|
|
green: "bg-[#18c973]",
|
|
red: "bg-[#e04b3f]",
|
|
};
|
|
|
|
const ChatWorkUnitCard = ({
|
|
onOpen,
|
|
onReview,
|
|
onStart,
|
|
pendingAction,
|
|
workUnit,
|
|
}: {
|
|
readonly onOpen?: (workUnitId?: string) => void;
|
|
readonly onReview?: (reviewUrl: string) => void;
|
|
readonly onStart?: (workUnitId: string) => void;
|
|
readonly pendingAction?: string | null;
|
|
readonly workUnit?: MobileWorkUnitView;
|
|
}) => {
|
|
if (!workUnit) {
|
|
return null;
|
|
}
|
|
return (
|
|
<article
|
|
className={cn(
|
|
"mt-5 rounded-[22px] p-[14px]",
|
|
workUnitToneClass[workUnit.tone]
|
|
)}
|
|
>
|
|
<button
|
|
className="block w-full text-left"
|
|
onClick={() => onOpen?.(workUnit.id)}
|
|
type="button"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={cn(
|
|
"text-[10px] font-semibold uppercase",
|
|
workUnitStatusClass[workUnit.tone]
|
|
)}
|
|
>
|
|
{workUnit.statusLabel}
|
|
</span>
|
|
<span className="text-[10px] opacity-60">{workUnit.code}</span>
|
|
<ChevronRight className="ml-auto size-4" />
|
|
</div>
|
|
<h2 className="mt-2 text-[17px] leading-5 font-semibold">
|
|
{workUnit.title}
|
|
</h2>
|
|
<p className="mt-1 line-clamp-2 text-[12px] leading-4 opacity-70">
|
|
{workUnit.summary}
|
|
</p>
|
|
<div className="mt-3 h-1.5 rounded-full bg-current/10">
|
|
<div
|
|
className="h-full rounded-full bg-current"
|
|
style={{ width: `${workUnit.progress}%` }}
|
|
/>
|
|
</div>
|
|
<div className="mt-1.5 flex text-[10px] font-semibold">
|
|
<span>{workUnit.progress}% complete</span>
|
|
<span className="ml-auto opacity-60">{workUnit.nextAction}</span>
|
|
</div>
|
|
</button>
|
|
<WorkUnitActions
|
|
onReview={onReview}
|
|
onStart={onStart}
|
|
pending={pendingAction === `issue:${workUnit.id}`}
|
|
workUnit={workUnit}
|
|
/>
|
|
</article>
|
|
);
|
|
};
|
|
|
|
const CharacterChat = ({
|
|
createIssueBody,
|
|
createIssueTitle,
|
|
data,
|
|
modelId,
|
|
modelLabel,
|
|
onChange,
|
|
onMessageSubmit,
|
|
onCreateIssueFromSignal,
|
|
onCreateBodyChange,
|
|
onCreateIssue,
|
|
onCreateTitleChange,
|
|
onManageProjects,
|
|
onOpen,
|
|
onProjectManagerClose,
|
|
onProjectSelect,
|
|
onRepositoryChange,
|
|
onRepositoryConnect,
|
|
onReview,
|
|
onSettingsClose,
|
|
onSettingsOpen,
|
|
onStart,
|
|
pendingAction,
|
|
projectManagerOpen,
|
|
repositoryValue,
|
|
settingsOpen,
|
|
statusMessage,
|
|
value,
|
|
}: ComposerProps & {
|
|
createIssueBody: string;
|
|
createIssueTitle: string;
|
|
data: MobileWorkspaceView;
|
|
modelId?: string;
|
|
modelLabel?: string;
|
|
onCreateBodyChange: (value: string) => void;
|
|
onCreateIssue: (event: FormEvent<HTMLFormElement>) => void;
|
|
onCreateIssueFromSignal?: () => void;
|
|
onCreateTitleChange: (value: string) => void;
|
|
onManageProjects?: () => void;
|
|
onOpen?: (workUnitId?: string) => void;
|
|
onProjectManagerClose?: () => void;
|
|
onProjectSelect?: (projectId: string) => void;
|
|
onRepositoryChange?: (value: string) => void;
|
|
onRepositoryConnect?: (event: FormEvent<HTMLFormElement>) => void;
|
|
onMessageSubmit?: (message: string) => Promise<void>;
|
|
onReview?: (reviewUrl: string) => void;
|
|
onSettingsClose?: () => void;
|
|
onSettingsOpen?: () => void;
|
|
onStart?: (workUnitId: string) => void;
|
|
pendingAction?: string | null;
|
|
projectManagerOpen?: boolean;
|
|
repositoryValue?: string;
|
|
settingsOpen?: boolean;
|
|
}) => (
|
|
<div className="mx-auto h-[min(100dvh,858px)] w-full max-w-[390px] overflow-hidden bg-black">
|
|
<main className="relative h-full overflow-hidden rounded-[30px] bg-white px-4 text-[#11110f]">
|
|
<header className="absolute inset-x-4 top-8 flex items-center">
|
|
<span className="flex size-9 items-center justify-center rounded-[13px] bg-[#c8ff00]">
|
|
<Sparkles className="size-5" />
|
|
</span>
|
|
<div className="ml-3">
|
|
<h1 className="text-[18px] leading-5 font-semibold">Zopu</h1>
|
|
<p className="mt-0.5 flex items-center gap-2 text-[12px] text-[#55524f]">
|
|
<span
|
|
className={cn(
|
|
"size-1.5 rounded-full",
|
|
assistantStatusDotClass[data.assistant.statusTone]
|
|
)}
|
|
/>
|
|
{data.assistant.statusLabel}
|
|
</p>
|
|
</div>
|
|
<button
|
|
aria-label="Open settings"
|
|
className="ml-auto flex size-9 items-center justify-center rounded-full bg-[#f2f3ef]"
|
|
onClick={onSettingsOpen}
|
|
type="button"
|
|
>
|
|
<MoreHorizontal className="size-5" />
|
|
</button>
|
|
</header>
|
|
<div className="absolute inset-x-0 top-[82px]">
|
|
<ProjectSwitcher
|
|
data={data}
|
|
onManageProjects={onManageProjects}
|
|
onProjectSelect={onProjectSelect}
|
|
/>
|
|
</div>
|
|
<Conversation className="absolute inset-x-4 top-[127px] bottom-[105px]">
|
|
<ConversationContent className="min-h-full gap-0 p-0 pb-6">
|
|
{data.assistant.messages.length === 0 ? (
|
|
<>
|
|
<div className="w-fit rounded-full bg-[#f4f4f2] px-2.5 py-1.5 text-[10px] text-[#aaa7a3]">
|
|
TODAY
|
|
</div>
|
|
<div className="mt-10 flex items-center gap-2 text-[14px] text-[#55524f]">
|
|
<span className="flex size-6 items-center justify-center rounded-[8px] bg-[#f4f4f2]">
|
|
<Sparkles className="size-4" />
|
|
</span>
|
|
<strong>Zopu</strong>
|
|
<span className="text-[#b5b2ae]">• a fresh start</span>
|
|
</div>
|
|
<p className="mt-3 text-[17px] leading-5">
|
|
Good morning. What are we making clearer today?
|
|
</p>
|
|
<div className="mt-4 flex gap-7 text-[12px] font-semibold text-[#55524f]">
|
|
<button
|
|
className="flex items-center gap-2"
|
|
onClick={() =>
|
|
onChange("Summarize my latest project signals")
|
|
}
|
|
type="button"
|
|
>
|
|
<FileText className="size-4" />
|
|
Summarize
|
|
</button>
|
|
<button
|
|
className="flex items-center gap-2"
|
|
onClick={() => onChange("Plan the next steps for my project")}
|
|
type="button"
|
|
>
|
|
<ListChecks className="size-4" />
|
|
Plan
|
|
</button>
|
|
<button
|
|
className="flex items-center gap-2"
|
|
onClick={() => onChange("Explore risks in my active work")}
|
|
type="button"
|
|
>
|
|
◉ Explore
|
|
</button>
|
|
</div>
|
|
{data.latestSignal ? (
|
|
<section className="mt-12 rounded-[22px] bg-[#f4f4f2] p-[14px]">
|
|
<div className="flex items-center">
|
|
<span className="flex size-8 items-center justify-center rounded-[10px] bg-[#c8ff00]">
|
|
↗
|
|
</span>
|
|
<div className="ml-3 min-w-0">
|
|
<h2 className="truncate text-[15px] leading-4 font-semibold">
|
|
{data.latestSignal.title}
|
|
</h2>
|
|
<p className="text-[10px] text-[#b5b2ae]">
|
|
Latest project signal
|
|
</p>
|
|
</div>
|
|
<ChevronRight className="ml-auto size-4" />
|
|
</div>
|
|
<p className="mt-3 text-[13px] leading-[18px]">
|
|
{data.latestSignal.summary}
|
|
</p>
|
|
<button
|
|
className="mt-3 h-8 rounded-[11px] bg-black px-3 text-[11px] font-semibold text-white"
|
|
onClick={onCreateIssueFromSignal}
|
|
type="button"
|
|
>
|
|
Create work from signal
|
|
</button>
|
|
</section>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<div className="grid gap-5">
|
|
{data.assistant.messages.map((message) => (
|
|
<Message
|
|
className="max-w-full gap-0"
|
|
from={message.role}
|
|
key={message.id}
|
|
>
|
|
<MessageContent
|
|
className={cn(
|
|
"max-w-full gap-0 overflow-visible p-0",
|
|
message.role === "user"
|
|
? "ml-auto w-fit max-w-[86%] rounded-[22px] bg-[#0c0c0b] px-4 py-3 text-[16px] leading-6 text-white group-[.is-user]:rounded-[22px] group-[.is-user]:bg-[#0c0c0b] group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-white"
|
|
: "w-full bg-transparent text-[#11110f] dark:text-[#11110f]"
|
|
)}
|
|
>
|
|
{message.role === "assistant" ? (
|
|
<>
|
|
<div className="flex items-center gap-2">
|
|
<span className="flex size-7 items-center justify-center rounded-[9px] bg-[#c8ff00]">
|
|
<Sparkles className="size-4" />
|
|
</span>
|
|
<strong className="text-[14px]">Zopu</strong>
|
|
<span className="rounded-full bg-[#f1f1ef] px-2 py-1 text-[9px] text-[#aaa7a3]">
|
|
THINKING PARTNER
|
|
</span>
|
|
</div>
|
|
<MessageResponse
|
|
className="mt-3 text-[16px] leading-[21px] text-[#11110f] dark:text-[#11110f]"
|
|
isAnimating={data.assistant.isBusy}
|
|
>
|
|
{message.text}
|
|
</MessageResponse>
|
|
</>
|
|
) : (
|
|
<span className="whitespace-pre-wrap">
|
|
{message.text}
|
|
</span>
|
|
)}
|
|
</MessageContent>
|
|
</Message>
|
|
))}
|
|
{data.assistant.isBusy ? (
|
|
<p className="text-[13px] text-[#807b76]">Zopu is thinking…</p>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
<ChatWorkUnitCard
|
|
onOpen={onOpen}
|
|
onReview={onReview}
|
|
onStart={onStart}
|
|
pendingAction={pendingAction}
|
|
workUnit={data.selectedWorkUnit}
|
|
/>
|
|
</ConversationContent>
|
|
<ConversationScrollButton
|
|
aria-label="Scroll to latest message"
|
|
className="bottom-2 size-9 rounded-full border border-[#dededb] bg-white text-black shadow-sm"
|
|
/>
|
|
</Conversation>
|
|
<PromptInput
|
|
className="absolute inset-x-3 bottom-[34px] w-auto [&_[data-slot=input-group]]:min-h-[56px] [&_[data-slot=input-group]]:rounded-full [&_[data-slot=input-group]]:border-0 [&_[data-slot=input-group]]:bg-[#f4f4f2] [&_[data-slot=input-group]]:px-2 [&_[data-slot=input-group]]:py-1.5 [&_[data-slot=input-group]]:shadow-none [&_[data-slot=input-group]]:dark:bg-[#f4f4f2]"
|
|
onSubmit={async (message) => {
|
|
if (onMessageSubmit) {
|
|
await onMessageSubmit(message.text);
|
|
return;
|
|
}
|
|
onChange(message.text);
|
|
}}
|
|
>
|
|
<PromptInputBody>
|
|
<PromptInputButton
|
|
aria-label="Attach file"
|
|
className="size-11 shrink-0 rounded-full bg-white text-black hover:bg-white/80"
|
|
size="icon-sm"
|
|
>
|
|
<Paperclip className="size-5" />
|
|
</PromptInputButton>
|
|
<PromptInputTextarea
|
|
aria-label="Ask anything..."
|
|
className="max-h-20 min-h-10 flex-1 resize-none bg-transparent px-2 py-2.5 text-[15px] leading-5 shadow-none outline-none placeholder:text-[#aaa7a3] dark:bg-transparent"
|
|
onChange={(event) => onChange(event.currentTarget.value)}
|
|
placeholder="Ask anything..."
|
|
value={value}
|
|
/>
|
|
<PromptInputSubmit
|
|
aria-label="Send"
|
|
className="size-11 shrink-0 rounded-full bg-black text-white hover:bg-black/85 disabled:bg-black disabled:text-white disabled:opacity-100"
|
|
disabled={!value.trim() || data.assistant.isBusy}
|
|
size="icon-sm"
|
|
status={data.assistant.isBusy ? "streaming" : "ready"}
|
|
>
|
|
<ArrowUp className="size-5" />
|
|
</PromptInputSubmit>
|
|
</PromptInputBody>
|
|
</PromptInput>
|
|
<p className="absolute inset-x-0 bottom-[13px] text-center text-[9px] text-[#b5b2ae]">
|
|
{statusMessage ?? "Press Enter to send · Shift + Enter for a new line"}
|
|
</p>
|
|
{projectManagerOpen ? (
|
|
<ProjectManagementPanel
|
|
createIssueBody={createIssueBody}
|
|
createIssueTitle={createIssueTitle}
|
|
data={data}
|
|
onClose={onProjectManagerClose}
|
|
onCreateBodyChange={onCreateBodyChange}
|
|
onCreateIssue={onCreateIssue}
|
|
onCreateTitleChange={onCreateTitleChange}
|
|
onProjectSelect={onProjectSelect}
|
|
onRepositoryChange={onRepositoryChange}
|
|
onRepositoryConnect={onRepositoryConnect}
|
|
pendingAction={pendingAction}
|
|
repositoryValue={repositoryValue}
|
|
statusMessage={statusMessage}
|
|
/>
|
|
) : null}
|
|
{settingsOpen ? (
|
|
<ChatSettingsPanel
|
|
modelId={modelId}
|
|
modelLabel={modelLabel}
|
|
onClose={onSettingsClose}
|
|
/>
|
|
) : null}
|
|
</main>
|
|
</div>
|
|
);
|
|
|
|
export const MobileWorkspaceScreenRenderer = ({
|
|
composerValue,
|
|
createIssueBody,
|
|
createIssueTitle,
|
|
data,
|
|
modelId,
|
|
modelLabel,
|
|
onBack,
|
|
onComposerChange,
|
|
onComposerMessageSubmit,
|
|
onComposerSubmit,
|
|
onCreateBodyChange,
|
|
onCreateIssue,
|
|
onCreateIssueFromSignal,
|
|
onCreateTitleChange,
|
|
onManageProjects,
|
|
onProjectManagerClose,
|
|
onOpenAssistant,
|
|
onOpenUnit,
|
|
onProjectSelect,
|
|
onRepositoryChange,
|
|
onRepositoryConnect,
|
|
onReviewUnit,
|
|
onSettingsClose,
|
|
onSettingsOpen,
|
|
onStartUnit,
|
|
onViewWork,
|
|
pendingAction,
|
|
projectManagerOpen,
|
|
repositoryValue,
|
|
settingsOpen,
|
|
statusMessage,
|
|
variant,
|
|
}: MobileWorkspaceRendererProps) => {
|
|
const composerProps = {
|
|
onChange: onComposerChange,
|
|
onSubmit: onComposerSubmit,
|
|
placeholder: "",
|
|
statusMessage,
|
|
value: composerValue,
|
|
};
|
|
|
|
if (variant === "character-pass") {
|
|
return (
|
|
<CharacterChat
|
|
{...composerProps}
|
|
createIssueBody={createIssueBody}
|
|
createIssueTitle={createIssueTitle}
|
|
data={data}
|
|
modelId={modelId}
|
|
modelLabel={modelLabel}
|
|
onCreateBodyChange={onCreateBodyChange}
|
|
onCreateIssue={onCreateIssue}
|
|
onCreateIssueFromSignal={onCreateIssueFromSignal}
|
|
onCreateTitleChange={onCreateTitleChange}
|
|
onManageProjects={onManageProjects}
|
|
onMessageSubmit={onComposerMessageSubmit}
|
|
onOpen={onOpenUnit}
|
|
onProjectManagerClose={onProjectManagerClose}
|
|
onProjectSelect={onProjectSelect}
|
|
onRepositoryChange={onRepositoryChange}
|
|
onRepositoryConnect={onRepositoryConnect}
|
|
onReview={onReviewUnit}
|
|
onSettingsClose={onSettingsClose}
|
|
onSettingsOpen={onSettingsOpen}
|
|
onStart={onStartUnit}
|
|
pendingAction={pendingAction}
|
|
projectManagerOpen={projectManagerOpen}
|
|
repositoryValue={repositoryValue}
|
|
settingsOpen={settingsOpen}
|
|
/>
|
|
);
|
|
}
|
|
if (variant === "expanded-work-unit") {
|
|
return (
|
|
<WorkUnitScreen
|
|
{...composerProps}
|
|
data={data}
|
|
onBack={onBack}
|
|
createIssueBody={createIssueBody}
|
|
createIssueTitle={createIssueTitle}
|
|
onCreateBodyChange={onCreateBodyChange}
|
|
onCreateIssue={onCreateIssue}
|
|
onCreateTitleChange={onCreateTitleChange}
|
|
onManageProjects={onManageProjects}
|
|
onProjectSelect={onProjectSelect}
|
|
onReviewUnit={onReviewUnit}
|
|
onStartUnit={onStartUnit}
|
|
pendingAction={pendingAction}
|
|
/>
|
|
);
|
|
}
|
|
if (variant === "home-expanded-in-place") {
|
|
return (
|
|
<HomeExpanded
|
|
{...composerProps}
|
|
data={data}
|
|
onManageProjects={onManageProjects}
|
|
onOpen={onOpenUnit}
|
|
onProjectSelect={onProjectSelect}
|
|
/>
|
|
);
|
|
}
|
|
if (variant === "vertical-work-stack") {
|
|
return (
|
|
<VerticalStack
|
|
{...composerProps}
|
|
data={data}
|
|
onManageProjects={onManageProjects}
|
|
onOpen={onOpenUnit}
|
|
onProjectSelect={onProjectSelect}
|
|
/>
|
|
);
|
|
}
|
|
if (variant === "work-units-home") {
|
|
return (
|
|
<WorkFeedScreen
|
|
{...composerProps}
|
|
data={data}
|
|
onManageProjects={onManageProjects}
|
|
onOpen={onOpenUnit}
|
|
onOpenAssistant={onOpenAssistant}
|
|
onProjectSelect={onProjectSelect}
|
|
onReviewUnit={onReviewUnit}
|
|
onStartUnit={onStartUnit}
|
|
createIssueBody={createIssueBody}
|
|
createIssueTitle={createIssueTitle}
|
|
onCreateBodyChange={onCreateBodyChange}
|
|
onCreateIssue={onCreateIssue}
|
|
onCreateTitleChange={onCreateTitleChange}
|
|
pendingAction={pendingAction}
|
|
onViewWork={onViewWork}
|
|
/>
|
|
);
|
|
}
|
|
variant satisfies never;
|
|
return null;
|
|
};
|