feat(web): connect mobile workspace to live data

This commit is contained in:
sai karthik
2026-07-24 00:52:19 +05:30
parent 0607d13d4b
commit b49ca236cf
12 changed files with 1178 additions and 506 deletions

View File

@@ -5,8 +5,9 @@ import {
MobileWorkListScreen,
MobileWorkUnitDetailScreen,
} from "@code/ui/components/mobile-product";
import { useNavigate } from "react-router";
import { useNavigate, useParams } from "react-router";
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
export type MobileFlowScreen =
@@ -21,29 +22,41 @@ interface MobileFlowPageProps {
export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
const navigate = useNavigate();
const workspace = useMobileWorkspace();
const homePath = "/";
const { workUnitId } = useParams();
const projectWorkspace = useMobileProjectWorkspace({
selectedWorkUnitId: workUnitId,
});
const workspace = useMobileWorkspace({
onSend: projectWorkspace.sendMessage,
});
const workPath = "/work";
const chatPath = "/chat";
const detailPath = "/work/flow-08";
const handleOpenUnit = () => {
const handleOpenUnit = (selectedWorkUnitId?: string) => {
const targetId =
selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id;
if (!targetId) {
navigate("/dashboard");
return;
}
if (screen === "work-list" && !workspace.expanded) {
workspace.setExpanded(true);
return;
}
navigate(detailPath);
navigate(`/work/${targetId}`);
};
const screenProps = {
composerValue: workspace.composerValue,
onBack: () => navigate(homePath),
data: projectWorkspace.data,
onBack: () => navigate(workPath),
onComposerChange: workspace.handleComposerChange,
onComposerSubmit: workspace.handleComposerSubmit,
onManageProjects: () => navigate("/dashboard"),
onOpenAssistant: () => navigate(chatPath),
onOpenUnit: handleOpenUnit,
onViewWork: () => navigate(workPath),
statusMessage: workspace.statusMessage,
statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message,
};
if (screen === "assistant-chat") {

View File

@@ -1,13 +1,13 @@
import { useFlueAgent } from "@flue/react";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
import { CHAT_AGENT } from "@/lib/chat/constants";
import type { ChatAgentState } from "@/lib/chat/types";
export const useChatAgent = (): ChatAgentState => {
// The agent session is not created until the authenticated user's personal
// organization has been ensured and its id is available.
const organization = usePersonalOrganization();
export const useOrganizationChatAgent = (
organization: PersonalOrganizationState
): ChatAgentState => {
const agent = useFlueAgent({
...CHAT_AGENT,
id: organization.organizationId,
@@ -36,3 +36,6 @@ export const useChatAgent = (): ChatAgentState => {
status,
};
};
export const useChatAgent = (): ChatAgentState =>
useOrganizationChatAgent(usePersonalOrganization());

View File

@@ -0,0 +1,83 @@
import { api } from "@code/backend/convex/_generated/api";
import type {
MobileAssistantMessageView,
MobileAssistantView,
} from "@code/ui/components/mobile-product";
import { useQuery } from "convex/react";
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
import { STATUS_COPY } from "@/lib/chat/constants";
import { getMessageText } from "@/lib/chat/transforms";
import { buildMobileWorkspaceView } from "@/lib/mobile-workspace/build-mobile-workspace-view";
const toAssistantMessages = (
messages: ReturnType<typeof useOrganizationChatAgent>["messages"]
): MobileAssistantMessageView[] =>
messages.flatMap((message) => {
if (message.role !== "assistant" && message.role !== "user") {
return [];
}
const text = getMessageText(message);
return text
? [
{
id: message.id,
role: message.role,
text,
},
]
: [];
});
const getStatusTone = (
status: ReturnType<typeof useOrganizationChatAgent>["status"]
): MobileAssistantView["statusTone"] => {
if (status === "error") {
return "red";
}
return status === "connecting" ? "amber" : "green";
};
interface UseMobileProjectWorkspaceOptions {
readonly selectedWorkUnitId?: string;
}
export const useMobileProjectWorkspace = ({
selectedWorkUnitId,
}: UseMobileProjectWorkspaceOptions) => {
const organization = usePersonalOrganization();
const projectWorkspace = useProjectWorkspace();
const chatAgent = useOrganizationChatAgent(organization);
const signals = useQuery(
api.signals.list,
organization.organizationId
? { organizationId: organization.organizationId }
: "skip"
);
const assistant: MobileAssistantView = {
isBusy:
chatAgent.status === "connecting" ||
chatAgent.status === "streaming" ||
chatAgent.status === "submitted",
messages: toAssistantMessages(chatAgent.messages),
statusLabel: STATUS_COPY[chatAgent.status],
statusTone: getStatusTone(chatAgent.status),
};
return {
data: buildMobileWorkspaceView({
artifacts: projectWorkspace.artifacts,
assistant,
issues: projectWorkspace.issues,
organizationLabel: organization.organizationName,
projects: projectWorkspace.projects,
selectedProject: projectWorkspace.selectedProject,
selectedWorkUnitId,
signals,
}),
error: organization.error ?? chatAgent.error,
sendMessage: chatAgent.sendMessage,
} as const;
};

View File

@@ -1,8 +1,18 @@
import type { FormEvent } from "react";
import { useState } from "react";
export const useMobileWorkspace = (initialExpanded = false) => {
const [activeIndex, setActiveIndex] = useState(0);
interface UseMobileWorkspaceOptions {
readonly initialExpanded?: boolean;
readonly onSend: (message: string) => Promise<void>;
}
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : "Message could not be sent";
export const useMobileWorkspace = ({
initialExpanded = false,
onSend,
}: UseMobileWorkspaceOptions) => {
const [composerValue, setComposerValue] = useState("");
const [expanded, setExpanded] = useState(initialExpanded);
const [statusMessage, setStatusMessage] = useState<string>();
@@ -18,15 +28,22 @@ export const useMobileWorkspace = (initialExpanded = false) => {
if (!message) {
return;
}
setComposerValue("");
setStatusMessage("Added to Zopus queue");
setStatusMessage("Sending to Zopu…");
const sendMessage = async () => {
try {
await onSend(message);
setComposerValue("");
setStatusMessage("Sent to Zopu");
} catch (error) {
setStatusMessage(errorMessage(error));
}
};
void sendMessage();
};
return {
activeIndex,
composerValue,
expanded,
handleActiveIndexChange: setActiveIndex,
handleComposerChange,
handleComposerSubmit,
setExpanded,

View File

@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
export interface PersonalOrganizationState {
readonly error?: Error;
readonly organizationId?: Id<"organizations">;
readonly organizationName?: string;
}
/** Ensure the authenticated user has its personal tenancy boundary. */
@@ -16,10 +17,12 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
const ensurePersonalOrganization = useMutation(
api.organizations.ensurePersonalOrganization
);
const [organizationId, setOrganizationId] = useState<
Id<"organizations"> | undefined
>();
const [error, setError] = useState<Error | undefined>();
const [state, setState] = useState<{
readonly error?: Error;
readonly organizationId?: Id<"organizations">;
readonly organizationName?: string;
readonly userId: string;
}>();
useEffect(() => {
if (!userId) {
@@ -31,16 +34,21 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
try {
const organization = await ensurePersonalOrganization();
if (active) {
setOrganizationId(organization._id);
setError(undefined);
setState({
organizationId: organization._id,
organizationName: organization.name,
userId,
});
}
} catch (caughtError: unknown) {
if (active) {
setError(
caughtError instanceof Error
? caughtError
: new Error(String(caughtError))
);
setState({
error:
caughtError instanceof Error
? caughtError
: new Error(String(caughtError)),
userId,
});
}
}
};
@@ -51,9 +59,13 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
};
}, [ensurePersonalOrganization, userId]);
if (!userId) {
if (!userId || state?.userId !== userId) {
return {};
}
return { error, organizationId };
return {
error: state.error,
organizationId: state.organizationId,
organizationName: state.organizationName,
};
};

View File

@@ -0,0 +1,164 @@
import type { api } from "@code/backend/convex/_generated/api";
import type { Doc } from "@code/backend/convex/_generated/dataModel";
import type {
MobileAssistantView,
MobileSignalView,
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "@code/ui/components/mobile-product";
import type { FunctionReturnType } from "convex/server";
type SignalList = FunctionReturnType<typeof api.signals.list>;
type ProjectView = FunctionReturnType<typeof api.projects.list>[number];
interface BuildMobileWorkspaceViewInput {
readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined;
readonly assistant: MobileAssistantView;
readonly issues: readonly Doc<"projectIssues">[] | undefined;
readonly organizationLabel?: string;
readonly projects: readonly ProjectView[] | undefined;
readonly selectedProject: ProjectView | null;
readonly selectedWorkUnitId?: string;
readonly signals: SignalList | undefined;
}
const updatedDateFormatter = new Intl.DateTimeFormat("en", {
day: "numeric",
month: "short",
});
const statusPresentation: Record<
Doc<"projectIssues">["status"],
{
readonly nextAction: string;
readonly progress: number;
readonly statusLabel: string;
readonly tone: MobileWorkUnitTone;
}
> = {
completed: {
nextAction: "Review completed work",
progress: 100,
statusLabel: "Ready for review",
tone: "green",
},
failed: {
nextAction: "Retry this work unit",
progress: 45,
statusLabel: "Blocked",
tone: "orange",
},
"needs-input": {
nextAction: "Add the missing context",
progress: 55,
statusLabel: "Needs you",
tone: "orange",
},
open: {
nextAction: "Start this work unit",
progress: 25,
statusLabel: "Researching",
tone: "purple",
},
queued: {
nextAction: "Zopu is preparing",
progress: 40,
statusLabel: "Queued",
tone: "blue",
},
working: {
nextAction: "Review current progress",
progress: 68,
statusLabel: "In progress",
tone: "blue",
},
};
const toWorkUnit = (
issue: Doc<"projectIssues">,
artifactCount: number
): MobileWorkUnitView => {
const presentation = statusPresentation[issue.status];
return {
artifactCount,
code: `#${issue.number}`,
id: issue._id,
nextAction: presentation.nextAction,
progress: presentation.progress,
statusLabel: presentation.statusLabel,
summary: issue.body,
title: issue.title,
tone: presentation.tone,
updatedLabel: updatedDateFormatter.format(issue.updatedAt),
};
};
const toLatestSignalView = (
signal: SignalList[number]["signal"] | undefined
): MobileSignalView | undefined => {
if (!signal) {
return;
}
return {
desiredOutcome: signal.problemStatement.desiredOutcome,
id: signal._id,
summary: signal.problemStatement.summary,
title: signal.problemStatement.title,
};
};
export const buildMobileWorkspaceView = ({
artifacts,
assistant,
issues,
organizationLabel = "Personal workspace",
projects,
selectedProject,
selectedWorkUnitId,
signals,
}: BuildMobileWorkspaceViewInput): MobileWorkspaceView => {
const artifactCount = artifacts?.length ?? 0;
const workUnits =
issues?.map((issue) => toWorkUnit(issue, artifactCount)) ?? [];
const selectedWorkUnit =
workUnits.find((workUnit) => workUnit.id === selectedWorkUnitId) ??
workUnits.find((workUnit) => workUnit.tone === "blue") ??
workUnits[0];
const selectedProjectId = selectedProject?.id;
const relevantSignals =
signals?.filter(
({ signal }) =>
!signal.projectId ||
String(signal.projectId) === String(selectedProjectId)
) ?? [];
const latestSignal = relevantSignals[0]?.signal;
const activeCount = workUnits.filter(
(workUnit) => workUnit.progress < 100
).length;
const needsAttentionCount = issues?.filter(
(issue) => issue.status === "failed" || issue.status === "needs-input"
).length;
const shippedCount = issues?.filter(
(issue) => issue.status === "completed"
).length;
const hasSelectedProject = Boolean(selectedProject);
return {
activeCount,
activityCount: workUnits.length + relevantSignals.length,
artifactCount,
assistant,
isLoading:
projects === undefined ||
(hasSelectedProject && (artifacts === undefined || issues === undefined)),
latestSignal: toLatestSignalView(latestSignal),
needsAttentionCount: needsAttentionCount ?? 0,
organizationLabel,
projectName: selectedProject?.name,
selectedWorkUnit,
shippedCount: shippedCount ?? 0,
totalCount: workUnits.length,
workUnits,
};
};