feat: wire mobile workspace into project loop
This commit is contained in:
8
packages/backend/convex/_generated/api.d.ts
vendored
8
packages/backend/convex/_generated/api.d.ts
vendored
@@ -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,10 @@ 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 publicGit from "../publicGit.js";
|
||||
import type * as signals from "../signals.js";
|
||||
import type * as todos from "../todos.js";
|
||||
|
||||
import type {
|
||||
@@ -36,6 +40,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 +51,10 @@ declare const fullApi: ApiFromModules<{
|
||||
privateData: typeof privateData;
|
||||
projectArtifacts: typeof projectArtifacts;
|
||||
projectIssues: typeof projectIssues;
|
||||
projectStore: typeof projectStore;
|
||||
projects: typeof projects;
|
||||
publicGit: typeof publicGit;
|
||||
signals: typeof signals;
|
||||
todos: typeof todos;
|
||||
}>;
|
||||
|
||||
|
||||
125
packages/backend/convex/publicGit.ts
Normal file
125
packages/backend/convex/publicGit.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
CONTEXT_KINDS,
|
||||
contextPathForKind,
|
||||
MAX_CONTEXT_DOCUMENT_CHARACTERS,
|
||||
type PreparedPublicGitSource,
|
||||
type PublicGitImportResult,
|
||||
type RepositoryContextDocument,
|
||||
} from "@code/primitives/project";
|
||||
|
||||
const GITHUB_HOST = "github.com";
|
||||
const REQUEST_HEADERS = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "zopu-project-importer",
|
||||
} as const;
|
||||
|
||||
const candidatePaths = {
|
||||
agents: ["AGENTS.md"],
|
||||
business: ["business.md", "docs/business.md"],
|
||||
design: ["design.md", "docs/design.md"],
|
||||
product: ["product.md", "docs/product.md"],
|
||||
readme: ["README.md", "README", "readme.md"],
|
||||
tech: ["tech.md", "docs/tech.md"],
|
||||
} as const;
|
||||
|
||||
const encodePath = (path: string) =>
|
||||
path
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
|
||||
const fetchTextCandidate = async (
|
||||
repositoryPath: string,
|
||||
branch: string,
|
||||
path: string
|
||||
): Promise<string | null> => {
|
||||
const url = `https://raw.githubusercontent.com/${encodePath(repositoryPath)}/${encodePath(branch)}/${encodePath(path)}`;
|
||||
const response = await fetch(url, { headers: REQUEST_HEADERS });
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub returned ${response.status} for ${path}`);
|
||||
}
|
||||
const content = await response.text();
|
||||
if (content.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (content.length > MAX_CONTEXT_DOCUMENT_CHARACTERS) {
|
||||
throw new Error(`${path} exceeds the 200000 character import limit`);
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
const fetchContextDocument = async (
|
||||
source: PreparedPublicGitSource,
|
||||
branch: string,
|
||||
kind: (typeof CONTEXT_KINDS)[number]
|
||||
): Promise<RepositoryContextDocument | null> => {
|
||||
for (const candidate of candidatePaths[kind]) {
|
||||
const content = await fetchTextCandidate(
|
||||
source.repositoryPath,
|
||||
branch,
|
||||
candidate
|
||||
);
|
||||
if (content) {
|
||||
return {
|
||||
content,
|
||||
kind,
|
||||
path: contextPathForKind(kind),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const readDefaultBranch = async (
|
||||
source: PreparedPublicGitSource
|
||||
): Promise<string> => {
|
||||
if (source.host !== GITHUB_HOST) {
|
||||
throw new Error("Repository import currently supports public GitHub URLs");
|
||||
}
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${encodePath(source.repositoryPath)}`,
|
||||
{ headers: REQUEST_HEADERS }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
response.status === 404
|
||||
? "Public GitHub repository not found"
|
||||
: `GitHub returned ${response.status} while reading the repository`
|
||||
);
|
||||
}
|
||||
const metadata = (await response.json()) as { default_branch?: unknown };
|
||||
if (
|
||||
typeof metadata.default_branch !== "string" ||
|
||||
metadata.default_branch.length === 0
|
||||
) {
|
||||
throw new Error("GitHub did not return a default branch");
|
||||
}
|
||||
return metadata.default_branch;
|
||||
};
|
||||
|
||||
export const inspectPublicGit = async (
|
||||
source: PreparedPublicGitSource
|
||||
): Promise<PublicGitImportResult> => {
|
||||
const defaultBranch = await readDefaultBranch(source);
|
||||
const candidates = await Promise.all(
|
||||
CONTEXT_KINDS.map((kind) =>
|
||||
fetchContextDocument(source, defaultBranch, kind)
|
||||
)
|
||||
);
|
||||
const documents = candidates.filter(
|
||||
(document): document is RepositoryContextDocument => document !== null
|
||||
);
|
||||
return {
|
||||
defaultBranch,
|
||||
documents,
|
||||
warnings: CONTEXT_KINDS.filter(
|
||||
(kind) => !documents.some((document) => document.kind === kind)
|
||||
).map((kind) => ({
|
||||
message: "Canonical context file was not found in the repository",
|
||||
path: contextPathForKind(kind),
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getProjectWorkProgress, getProjectWorkStatus } from "./project-work";
|
||||
import {
|
||||
getProjectWorkNextAction,
|
||||
getProjectWorkProgress,
|
||||
getProjectWorkStatus,
|
||||
} from "./project-work";
|
||||
|
||||
describe("project work state mapping", () => {
|
||||
test("maps issue and run statuses to the same product language", () => {
|
||||
@@ -27,4 +31,14 @@ describe("project work state mapping", () => {
|
||||
getProjectWorkProgress("completed")
|
||||
);
|
||||
});
|
||||
|
||||
test("maps each state to the next product action", () => {
|
||||
expect(getProjectWorkNextAction("open")).toBe("Start the project manager");
|
||||
expect(getProjectWorkNextAction("failed")).toBe(
|
||||
"Review the failure and retry"
|
||||
);
|
||||
expect(getProjectWorkNextAction("completed")).toBe(
|
||||
"Review the pull request"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,18 @@ export interface ProjectWorkStatus {
|
||||
readonly verification: ProjectVerificationStatus;
|
||||
}
|
||||
|
||||
const NEXT_ACTION_MAP: Readonly<
|
||||
Record<ProjectIssueStatus | ProjectRunStatus, string>
|
||||
> = {
|
||||
completed: "Review the pull request",
|
||||
failed: "Review the failure and retry",
|
||||
"needs-input": "Resolve the open question",
|
||||
open: "Start the project manager",
|
||||
queued: "Watch the run progress",
|
||||
ready: "Watch the run progress",
|
||||
working: "Wait for verification",
|
||||
};
|
||||
|
||||
const STATUS_MAP: Readonly<
|
||||
Record<ProjectIssueStatus | ProjectRunStatus, ProjectWorkStatus>
|
||||
> = {
|
||||
@@ -113,3 +125,7 @@ export const getProjectWorkProgress = (
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getProjectWorkNextAction = (
|
||||
status: ProjectIssueStatus | ProjectRunStatus
|
||||
): string => NEXT_ACTION_MAP[status];
|
||||
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
export type {
|
||||
MobileAssistantMessageView,
|
||||
MobileAssistantView,
|
||||
MobileProjectView,
|
||||
MobileSignalView,
|
||||
MobileWorkspaceScreenProps,
|
||||
MobileWorkspaceView,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ export { MobileWorkUnitDetailScreen } from "./mobile-work-unit-detail-screen";
|
||||
export type {
|
||||
MobileAssistantMessageView,
|
||||
MobileAssistantView,
|
||||
MobileProjectView,
|
||||
MobileSignalView,
|
||||
MobileWorkspaceScreenProps,
|
||||
MobileWorkspaceView,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,7 @@ export type MobileWorkspaceScreenProps = Omit<
|
||||
export type {
|
||||
MobileAssistantMessageView,
|
||||
MobileAssistantView,
|
||||
MobileProjectView,
|
||||
MobileSignalView,
|
||||
MobileWorkspaceView,
|
||||
MobileWorkUnitTone,
|
||||
|
||||
Reference in New Issue
Block a user