feat: wire mobile workspace into project loop

This commit is contained in:
-Puter
2026-07-24 03:17:59 +05:30
parent 9d54470774
commit fb438ba9a7
16 changed files with 780 additions and 85 deletions

View File

@@ -8,6 +8,7 @@ export {
export type {
MobileAssistantMessageView,
MobileAssistantView,
MobileProjectView,
MobileSignalView,
MobileWorkspaceScreenProps,
MobileWorkspaceView,

View File

@@ -4,8 +4,10 @@ import {
ArrowUp,
Check,
ChevronRight,
ExternalLink,
FileText,
ListChecks,
LoaderCircle,
Mic,
MoreHorizontal,
Paperclip,
@@ -16,6 +18,7 @@ import {
import type { FormEvent, ReactNode } from "react";
import type {
MobileProjectView,
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
@@ -30,14 +33,24 @@ export type MobileWorkspaceVariant =
export interface MobileWorkspaceRendererProps {
composerValue: string;
createIssueBody: string;
createIssueTitle: string;
data: MobileWorkspaceView;
onBack?: () => void;
onComposerChange: (value: string) => void;
onComposerSubmit: (event: FormEvent<HTMLFormElement>) => void;
onCreateBodyChange: (value: string) => void;
onCreateIssue: (event: FormEvent<HTMLFormElement>) => void;
onCreateIssueFromSignal?: () => void;
onCreateTitleChange: (value: string) => void;
onManageProjects?: () => void;
onOpenAssistant?: () => void;
onOpenUnit?: (workUnitId?: string) => void;
onProjectSelect?: (projectId: string) => void;
onReviewUnit?: (reviewUrl: string) => void;
onStartUnit?: (workUnitId: string) => void;
onViewWork?: () => void;
pendingAction?: string | null;
statusMessage?: string;
variant: MobileWorkspaceVariant;
}
@@ -203,6 +216,142 @@ const ReferenceComposer = ({
</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.name} {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 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]",
@@ -250,20 +399,38 @@ const EmptyWorkState = ({
);
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;
@@ -278,6 +445,11 @@ const WorkFeedScreen = ({
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.
@@ -372,6 +544,12 @@ const WorkFeedScreen = ({
Open unit
</button>
</div>
<WorkUnitActions
onReview={onReviewUnit}
onStart={onStartUnit}
pending={pendingAction === `issue:${primaryWorkUnit.id}`}
workUnit={primaryWorkUnit}
/>
</article>
) : (
<EmptyWorkState
@@ -379,6 +557,14 @@ const WorkFeedScreen = ({
onManageProjects={onManageProjects}
/>
)}
<CreateWorkForm
body={createIssueBody}
onBodyChange={onCreateBodyChange}
onSubmit={onCreateIssue}
onTitleChange={onCreateTitleChange}
pending={pendingAction === "issue"}
title={createIssueTitle}
/>
{secondaryWorkUnits[0] ? (
<article
className={cn(
@@ -592,9 +778,15 @@ const ExpandedWorkUnitActivity = ({
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);
@@ -689,22 +881,46 @@ const ExpandedWorkUnitContent = ({
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">
@@ -733,9 +949,27 @@ const WorkUnitScreen = ({
<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} />
<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"
@@ -756,6 +990,7 @@ const VerticalStack = ({
onChange,
onManageProjects,
onOpen,
onProjectSelect,
onSubmit,
statusMessage,
value,
@@ -763,6 +998,7 @@ const VerticalStack = ({
data: MobileWorkspaceView;
onManageProjects?: () => void;
onOpen?: (workUnitId?: string) => void;
onProjectSelect?: (projectId: string) => void;
}) => {
const { selectedWorkUnit } = data;
const secondaryWorkUnits = data.workUnits
@@ -783,6 +1019,11 @@ const VerticalStack = ({
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]">
@@ -908,6 +1149,7 @@ const HomeExpanded = ({
onChange,
onManageProjects,
onOpen,
onProjectSelect,
onSubmit,
statusMessage,
value,
@@ -915,6 +1157,7 @@ const HomeExpanded = ({
data: MobileWorkspaceView;
onManageProjects?: () => void;
onOpen?: (workUnitId?: string) => void;
onProjectSelect?: (projectId: string) => void;
}) => (
<DeviceFrame402>
<ProductHeader
@@ -924,6 +1167,11 @@ const HomeExpanded = ({
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]">
@@ -979,10 +1227,14 @@ const assistantStatusDotClass: Record<
const CharacterChat = ({
data,
onChange,
onCreateIssueFromSignal,
onSubmit,
statusMessage,
value,
}: ComposerProps & { data: MobileWorkspaceView }) => (
}: ComposerProps & {
data: MobileWorkspaceView;
onCreateIssueFromSignal?: () => void;
}) => (
<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">
@@ -1063,6 +1315,13 @@ const CharacterChat = ({
<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}
</>
@@ -1140,14 +1399,24 @@ const CharacterChat = ({
export const MobileWorkspaceScreenRenderer = ({
composerValue,
createIssueBody,
createIssueTitle,
data,
onBack,
onComposerChange,
onComposerSubmit,
onCreateBodyChange,
onCreateIssue,
onCreateIssueFromSignal,
onCreateTitleChange,
onManageProjects,
onOpenAssistant,
onOpenUnit,
onProjectSelect,
onReviewUnit,
onStartUnit,
onViewWork,
pendingAction,
statusMessage,
variant,
}: MobileWorkspaceRendererProps) => {
@@ -1160,7 +1429,13 @@ export const MobileWorkspaceScreenRenderer = ({
};
if (variant === "character-pass") {
return <CharacterChat {...composerProps} data={data} />;
return (
<CharacterChat
{...composerProps}
data={data}
onCreateIssueFromSignal={onCreateIssueFromSignal}
/>
);
}
if (variant === "expanded-work-unit") {
return (
@@ -1168,7 +1443,16 @@ export const MobileWorkspaceScreenRenderer = ({
{...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}
/>
);
}
@@ -1179,6 +1463,7 @@ export const MobileWorkspaceScreenRenderer = ({
data={data}
onManageProjects={onManageProjects}
onOpen={onOpenUnit}
onProjectSelect={onProjectSelect}
/>
);
}
@@ -1189,6 +1474,7 @@ export const MobileWorkspaceScreenRenderer = ({
data={data}
onManageProjects={onManageProjects}
onOpen={onOpenUnit}
onProjectSelect={onProjectSelect}
/>
);
}
@@ -1200,6 +1486,15 @@ export const MobileWorkspaceScreenRenderer = ({
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}
/>
);

View File

@@ -6,6 +6,7 @@ export { MobileWorkUnitDetailScreen } from "./mobile-work-unit-detail-screen";
export type {
MobileAssistantMessageView,
MobileAssistantView,
MobileProjectView,
MobileSignalView,
MobileWorkspaceScreenProps,
MobileWorkspaceView,

View File

@@ -1,11 +1,21 @@
export type MobileWorkUnitTone = "blue" | "green" | "orange" | "purple";
export interface MobileProjectView {
readonly connected: boolean;
readonly id: string;
readonly name: string;
}
export interface MobileWorkUnitView {
readonly artifactCount: number;
readonly code: string;
readonly id: string;
readonly canRetry: boolean;
readonly canStart: boolean;
readonly nextAction: string;
readonly pullRequestNumber?: number;
readonly progress: number;
readonly reviewUrl?: string;
readonly statusLabel: string;
readonly summary: string;
readonly title: string;
@@ -16,6 +26,7 @@ export interface MobileWorkUnitView {
export interface MobileSignalView {
readonly desiredOutcome: string;
readonly id: string;
readonly projectId?: string;
readonly summary: string;
readonly title: string;
}
@@ -42,7 +53,9 @@ export interface MobileWorkspaceView {
readonly latestSignal?: MobileSignalView;
readonly needsAttentionCount: number;
readonly organizationLabel: string;
readonly projects: readonly MobileProjectView[];
readonly projectName?: string;
readonly selectedProjectId?: string;
readonly selectedWorkUnit?: MobileWorkUnitView;
readonly shippedCount: number;
readonly totalCount: number;

View File

@@ -8,6 +8,7 @@ export type MobileWorkspaceScreenProps = Omit<
export type {
MobileAssistantMessageView,
MobileAssistantView,
MobileProjectView,
MobileSignalView,
MobileWorkspaceView,
MobileWorkUnitTone,