feat: wire mobile workspace into project loop
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import {
|
||||
MobileAssistantChatScreen,
|
||||
MobileExpandedWorkScreen,
|
||||
@@ -27,6 +28,8 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
||||
selectedWorkUnitId: workUnitId,
|
||||
});
|
||||
const workspace = useMobileWorkspace({
|
||||
onCreateIssue: (title, body) =>
|
||||
projectWorkspace.raiseIssue({ body, title }),
|
||||
onSend: projectWorkspace.sendMessage,
|
||||
});
|
||||
const workPath = "/work";
|
||||
@@ -48,14 +51,38 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
||||
|
||||
const screenProps = {
|
||||
composerValue: workspace.composerValue,
|
||||
createIssueBody: workspace.issueBody,
|
||||
createIssueTitle: workspace.issueTitle,
|
||||
data: projectWorkspace.data,
|
||||
onBack: () => navigate(workPath),
|
||||
onComposerChange: workspace.handleComposerChange,
|
||||
onComposerSubmit: workspace.handleComposerSubmit,
|
||||
onCreateBodyChange: workspace.setIssueBody,
|
||||
onCreateIssue: workspace.handleCreateIssueSubmit,
|
||||
onCreateIssueFromSignal: projectWorkspace.data.latestSignal?.projectId
|
||||
? () =>
|
||||
void projectWorkspace.raiseIssueFromSignal(
|
||||
projectWorkspace.data.latestSignal?.id ?? ""
|
||||
)
|
||||
: undefined,
|
||||
onCreateTitleChange: workspace.setIssueTitle,
|
||||
onManageProjects: () => navigate("/dashboard"),
|
||||
onOpenAssistant: () => navigate(chatPath),
|
||||
onOpenUnit: handleOpenUnit,
|
||||
onProjectSelect: (projectId: string) =>
|
||||
projectWorkspace.projectWorkspace.setSelectedProjectId(
|
||||
projectId as Id<"projects">
|
||||
),
|
||||
onReviewUnit: (reviewUrl: string) => {
|
||||
window.open(reviewUrl, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
onStartUnit: (selectedIssueId: string) => {
|
||||
void projectWorkspace.startWorkUnit(
|
||||
selectedIssueId as Id<"projectIssues">
|
||||
);
|
||||
},
|
||||
onViewWork: () => navigate(workPath),
|
||||
pendingAction: projectWorkspace.projectWorkspace.pendingAction,
|
||||
statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import type {
|
||||
MobileAssistantMessageView,
|
||||
MobileAssistantView,
|
||||
@@ -70,6 +71,7 @@ export const useMobileProjectWorkspace = ({
|
||||
data: buildMobileWorkspaceView({
|
||||
artifacts: projectWorkspace.artifacts,
|
||||
assistant,
|
||||
events: projectWorkspace.events,
|
||||
issues: projectWorkspace.issues,
|
||||
organizationLabel: organization.organizationName,
|
||||
projects: projectWorkspace.projects,
|
||||
@@ -78,6 +80,12 @@ export const useMobileProjectWorkspace = ({
|
||||
signals,
|
||||
}),
|
||||
error: organization.error ?? chatAgent.error,
|
||||
organization,
|
||||
projectWorkspace,
|
||||
raiseIssue: projectWorkspace.raiseIssue,
|
||||
raiseIssueFromSignal: (signalId: string) =>
|
||||
projectWorkspace.raiseIssueFromSignal(signalId as Id<"signals">),
|
||||
sendMessage: chatAgent.sendMessage,
|
||||
startWorkUnit: projectWorkspace.startWorkUnit,
|
||||
} as const;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
||||
|
||||
interface UseMobileWorkspaceOptions {
|
||||
readonly initialExpanded?: boolean;
|
||||
readonly onCreateIssue: (title: string, body: string) => Promise<void>;
|
||||
readonly onSend: (message: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -11,9 +12,12 @@ const errorMessage = (error: unknown) =>
|
||||
|
||||
export const useMobileWorkspace = ({
|
||||
initialExpanded = false,
|
||||
onCreateIssue,
|
||||
onSend,
|
||||
}: UseMobileWorkspaceOptions) => {
|
||||
const [composerValue, setComposerValue] = useState("");
|
||||
const [issueBody, setIssueBody] = useState("");
|
||||
const [issueTitle, setIssueTitle] = useState("");
|
||||
const [expanded, setExpanded] = useState(initialExpanded);
|
||||
const [statusMessage, setStatusMessage] = useState<string>();
|
||||
|
||||
@@ -41,12 +45,38 @@ export const useMobileWorkspace = ({
|
||||
void sendMessage();
|
||||
};
|
||||
|
||||
const handleCreateIssueSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const title = issueTitle.trim();
|
||||
const body = issueBody.trim();
|
||||
if (!title || !body) {
|
||||
return;
|
||||
}
|
||||
setStatusMessage("Creating project work…");
|
||||
const createIssue = async () => {
|
||||
try {
|
||||
await onCreateIssue(title, body);
|
||||
setIssueTitle("");
|
||||
setIssueBody("");
|
||||
setStatusMessage("Project work created");
|
||||
} catch (error) {
|
||||
setStatusMessage(errorMessage(error));
|
||||
}
|
||||
};
|
||||
void createIssue();
|
||||
};
|
||||
|
||||
return {
|
||||
composerValue,
|
||||
expanded,
|
||||
handleComposerChange,
|
||||
handleComposerSubmit,
|
||||
handleCreateIssueSubmit,
|
||||
issueBody,
|
||||
issueTitle,
|
||||
setExpanded,
|
||||
setIssueBody,
|
||||
setIssueTitle,
|
||||
statusMessage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ export const useProjectWorkspace = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const createIssue = useMutation(api.projectIssues.create);
|
||||
const createIssueFromSignal = useMutation(api.projectIssues.createFromSignal);
|
||||
const beginIssue = useMutation(api.projectIssues.begin);
|
||||
const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
|
||||
const activeProjectId =
|
||||
@@ -79,17 +80,22 @@ export const useProjectWorkspace = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const raiseIssue = async () => {
|
||||
const raiseIssue = async (input?: {
|
||||
readonly body?: string;
|
||||
readonly title?: string;
|
||||
}) => {
|
||||
if (!activeProjectId) {
|
||||
return;
|
||||
}
|
||||
const nextTitle = input?.title ?? issueTitle;
|
||||
const nextBody = input?.body ?? issueBody;
|
||||
setPendingAction("issue");
|
||||
setError(null);
|
||||
try {
|
||||
const issueId = await createIssue({
|
||||
body: issueBody,
|
||||
body: nextBody,
|
||||
projectId: activeProjectId,
|
||||
title: issueTitle,
|
||||
title: nextTitle,
|
||||
});
|
||||
setSelectedIssueId(issueId);
|
||||
setIssueTitle("");
|
||||
@@ -101,6 +107,19 @@ export const useProjectWorkspace = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const raiseIssueFromSignal = async (signalId: Id<"signals">) => {
|
||||
setPendingAction(`signal:${signalId}`);
|
||||
setError(null);
|
||||
try {
|
||||
const outcome = await createIssueFromSignal({ signalId });
|
||||
setSelectedIssueId(outcome.issueId);
|
||||
} catch (caughtError) {
|
||||
setError(errorMessage(caughtError));
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startIssue = async (
|
||||
issueId: Id<"projectIssues">,
|
||||
issueNumber: number,
|
||||
@@ -122,6 +141,15 @@ export const useProjectWorkspace = () => {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startWorkUnit = async (issueId: Id<"projectIssues">) => {
|
||||
const issue = issues?.find((candidate) => candidate._id === issueId);
|
||||
if (!issue) {
|
||||
setError("That work unit is no longer available");
|
||||
return;
|
||||
}
|
||||
await startIssue(issue._id, issue.number, issue.title);
|
||||
};
|
||||
const selectedProject =
|
||||
projects?.find(
|
||||
(project) => project.id === (activeProjectId as unknown as string)
|
||||
@@ -149,6 +177,7 @@ export const useProjectWorkspace = () => {
|
||||
projects,
|
||||
pullRequest,
|
||||
raiseIssue,
|
||||
raiseIssueFromSignal,
|
||||
repository,
|
||||
selectedIssue,
|
||||
selectedIssueId,
|
||||
@@ -160,5 +189,6 @@ export const useProjectWorkspace = () => {
|
||||
setSelectedIssueId,
|
||||
setSelectedProjectId,
|
||||
startIssue,
|
||||
startWorkUnit,
|
||||
} as const;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Doc } from "@code/backend/convex/_generated/dataModel";
|
||||
import {
|
||||
getProjectWorkNextAction,
|
||||
getProjectWorkProgress,
|
||||
getProjectWorkStatus,
|
||||
} from "@code/primitives/project-work";
|
||||
import type {
|
||||
MobileAssistantView,
|
||||
MobileProjectView,
|
||||
MobileSignalView,
|
||||
MobileWorkspaceView,
|
||||
MobileWorkUnitTone,
|
||||
@@ -10,11 +16,13 @@ import type {
|
||||
import type { FunctionReturnType } from "convex/server";
|
||||
|
||||
type SignalList = FunctionReturnType<typeof api.signals.list>;
|
||||
type ProjectEventList = FunctionReturnType<typeof api.projectIssues.events>;
|
||||
type ProjectView = FunctionReturnType<typeof api.projects.list>[number];
|
||||
|
||||
interface BuildMobileWorkspaceViewInput {
|
||||
readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined;
|
||||
readonly assistant: MobileAssistantView;
|
||||
readonly events: ProjectEventList | undefined;
|
||||
readonly issues: readonly Doc<"projectIssues">[] | undefined;
|
||||
readonly organizationLabel?: string;
|
||||
readonly projects: readonly ProjectView[] | undefined;
|
||||
@@ -28,68 +36,64 @@ const updatedDateFormatter = new Intl.DateTimeFormat("en", {
|
||||
month: "short",
|
||||
});
|
||||
|
||||
const statusPresentation: Record<
|
||||
Doc<"projectIssues">["status"],
|
||||
{
|
||||
readonly nextAction: string;
|
||||
readonly progress: number;
|
||||
readonly statusLabel: string;
|
||||
readonly tone: MobileWorkUnitTone;
|
||||
const statusTone = (
|
||||
status: ReturnType<typeof getProjectWorkStatus>
|
||||
): MobileWorkUnitTone => {
|
||||
if (status.phase === "completed") {
|
||||
return "green";
|
||||
}
|
||||
> = {
|
||||
completed: {
|
||||
nextAction: "Review completed work",
|
||||
progress: 100,
|
||||
statusLabel: "Ready for review",
|
||||
tone: "green",
|
||||
},
|
||||
failed: {
|
||||
nextAction: "Retry this work unit",
|
||||
progress: 45,
|
||||
statusLabel: "Blocked",
|
||||
tone: "orange",
|
||||
},
|
||||
"needs-input": {
|
||||
nextAction: "Add the missing context",
|
||||
progress: 55,
|
||||
statusLabel: "Needs you",
|
||||
tone: "orange",
|
||||
},
|
||||
open: {
|
||||
nextAction: "Start this work unit",
|
||||
progress: 25,
|
||||
statusLabel: "Researching",
|
||||
tone: "purple",
|
||||
},
|
||||
queued: {
|
||||
nextAction: "Zopu is preparing",
|
||||
progress: 40,
|
||||
statusLabel: "Queued",
|
||||
tone: "blue",
|
||||
},
|
||||
working: {
|
||||
nextAction: "Review current progress",
|
||||
progress: 68,
|
||||
statusLabel: "In progress",
|
||||
tone: "blue",
|
||||
},
|
||||
if (status.phase === "failed" || status.phase === "waiting") {
|
||||
return "orange";
|
||||
}
|
||||
if (status.phase === "captured") {
|
||||
return "purple";
|
||||
}
|
||||
return "blue";
|
||||
};
|
||||
|
||||
const isPullRequestData = (
|
||||
value: unknown
|
||||
): value is {
|
||||
pullRequest: {
|
||||
number: number;
|
||||
status: "open" | "closed" | "merged";
|
||||
url: string;
|
||||
};
|
||||
} =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"pullRequest" in value &&
|
||||
typeof value.pullRequest === "object" &&
|
||||
value.pullRequest !== null &&
|
||||
"number" in value.pullRequest &&
|
||||
typeof value.pullRequest.number === "number" &&
|
||||
"url" in value.pullRequest &&
|
||||
typeof value.pullRequest.url === "string";
|
||||
|
||||
const toWorkUnit = (
|
||||
issue: Doc<"projectIssues">,
|
||||
artifactCount: number
|
||||
artifactCount: number,
|
||||
events: ProjectEventList | undefined
|
||||
): MobileWorkUnitView => {
|
||||
const presentation = statusPresentation[issue.status];
|
||||
const status = getProjectWorkStatus(issue.status);
|
||||
const pullRequest = events
|
||||
?.filter((event) => event.issueId === issue._id)
|
||||
.map((event) => event.data)
|
||||
.find(isPullRequestData)?.pullRequest;
|
||||
return {
|
||||
artifactCount,
|
||||
canRetry: status.phase === "failed" || status.phase === "waiting",
|
||||
canStart: status.phase === "captured",
|
||||
code: `#${issue.number}`,
|
||||
id: issue._id,
|
||||
nextAction: presentation.nextAction,
|
||||
progress: presentation.progress,
|
||||
statusLabel: presentation.statusLabel,
|
||||
nextAction: getProjectWorkNextAction(issue.status),
|
||||
progress: getProjectWorkProgress(issue.status),
|
||||
pullRequestNumber: pullRequest?.number,
|
||||
reviewUrl: pullRequest?.url,
|
||||
statusLabel: status.label,
|
||||
summary: issue.body,
|
||||
title: issue.title,
|
||||
tone: presentation.tone,
|
||||
tone: statusTone(status),
|
||||
updatedLabel: updatedDateFormatter.format(issue.updatedAt),
|
||||
};
|
||||
};
|
||||
@@ -103,14 +107,34 @@ const toLatestSignalView = (
|
||||
return {
|
||||
desiredOutcome: signal.problemStatement.desiredOutcome,
|
||||
id: signal._id,
|
||||
projectId: signal.projectId ?? undefined,
|
||||
summary: signal.problemStatement.summary,
|
||||
title: signal.problemStatement.title,
|
||||
};
|
||||
};
|
||||
|
||||
const relevantSignalsForProject = (
|
||||
signals: SignalList | undefined,
|
||||
projectId: ProjectView["id"] | undefined
|
||||
) =>
|
||||
signals?.filter(
|
||||
({ signal }) =>
|
||||
!signal.projectId || String(signal.projectId) === String(projectId)
|
||||
) ?? [];
|
||||
|
||||
const makeProjectViews = (
|
||||
projects: readonly ProjectView[] | undefined
|
||||
): readonly MobileProjectView[] =>
|
||||
projects?.map((project) => ({
|
||||
connected: project.sources.length > 0,
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
})) ?? [];
|
||||
|
||||
export const buildMobileWorkspaceView = ({
|
||||
artifacts,
|
||||
assistant,
|
||||
events,
|
||||
issues,
|
||||
organizationLabel = "Personal workspace",
|
||||
projects,
|
||||
@@ -120,18 +144,13 @@ export const buildMobileWorkspaceView = ({
|
||||
}: BuildMobileWorkspaceViewInput): MobileWorkspaceView => {
|
||||
const artifactCount = artifacts?.length ?? 0;
|
||||
const workUnits =
|
||||
issues?.map((issue) => toWorkUnit(issue, artifactCount)) ?? [];
|
||||
issues?.map((issue) => toWorkUnit(issue, artifactCount, events)) ?? [];
|
||||
const selectedWorkUnit =
|
||||
workUnits.find((workUnit) => workUnit.id === selectedWorkUnitId) ??
|
||||
workUnits.find((workUnit) => workUnit.tone === "blue") ??
|
||||
workUnits[0];
|
||||
const selectedProjectId = selectedProject?.id;
|
||||
const relevantSignals =
|
||||
signals?.filter(
|
||||
({ signal }) =>
|
||||
!signal.projectId ||
|
||||
String(signal.projectId) === String(selectedProjectId)
|
||||
) ?? [];
|
||||
const relevantSignals = relevantSignalsForProject(signals, selectedProjectId);
|
||||
const latestSignal = relevantSignals[0]?.signal;
|
||||
const activeCount = workUnits.filter(
|
||||
(workUnit) => workUnit.progress < 100
|
||||
@@ -143,6 +162,7 @@ export const buildMobileWorkspaceView = ({
|
||||
(issue) => issue.status === "completed"
|
||||
).length;
|
||||
const hasSelectedProject = Boolean(selectedProject);
|
||||
const projectViews = makeProjectViews(projects);
|
||||
|
||||
return {
|
||||
activeCount,
|
||||
@@ -156,6 +176,8 @@ export const buildMobileWorkspaceView = ({
|
||||
needsAttentionCount: needsAttentionCount ?? 0,
|
||||
organizationLabel,
|
||||
projectName: selectedProject?.name,
|
||||
projects: projectViews,
|
||||
selectedProjectId: selectedProject?.id,
|
||||
selectedWorkUnit,
|
||||
shippedCount: shippedCount ?? 0,
|
||||
totalCount: workUnits.length,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Doc } from "@code/backend/convex/_generated/dataModel";
|
||||
import type { ProjectIssueStatus } from "@code/primitives/project-issue";
|
||||
import {
|
||||
getProjectWorkNextAction,
|
||||
getProjectWorkProgress,
|
||||
getProjectWorkStatus,
|
||||
} from "@code/primitives/project-work";
|
||||
@@ -65,15 +66,6 @@ const STATUS_TEXT: Readonly<Record<ProjectIssueStatus, string>> = {
|
||||
"The project manager is working through the issue and recording durable evidence.",
|
||||
};
|
||||
|
||||
const NEXT_ACTION: Readonly<Record<ProjectIssueStatus, 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",
|
||||
working: "Wait for verification",
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number): string =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
@@ -312,7 +304,7 @@ export const buildProjectLoopView = ({
|
||||
return {
|
||||
activity: buildActivity(issue, resolvedArtifacts, status.label),
|
||||
currentState: STATUS_TEXT[issue.status],
|
||||
nextAction: NEXT_ACTION[issue.status],
|
||||
nextAction: getProjectWorkNextAction(issue.status),
|
||||
progress: getProjectWorkProgress(issue.status),
|
||||
pullRequest,
|
||||
status,
|
||||
|
||||
Reference in New Issue
Block a user