Integrate mobile chat workspace and OpenRouter agent flow

This commit is contained in:
sai karthik
2026-07-25 17:49:11 +05:30
parent a17aa5c283
commit 48200a11df
24 changed files with 1142 additions and 211 deletions

View File

@@ -6,10 +6,12 @@ import {
MobileWorkListScreen,
MobileWorkUnitDetailScreen,
} from "@code/ui/components/mobile-product";
import type { FormEvent } from "react";
import { useNavigate, useParams } from "react-router";
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
import { MODEL_ID, MODEL_LABEL } from "@/lib/chat/constants";
export type MobileFlowScreen =
| "assistant-chat"
@@ -39,7 +41,7 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
const targetId =
selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id;
if (!targetId) {
navigate("/dashboard");
workspace.setProjectManagerOpen(true);
return;
}
if (screen === "work-list" && !workspace.expanded) {
@@ -54,8 +56,11 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
createIssueBody: workspace.issueBody,
createIssueTitle: workspace.issueTitle,
data: projectWorkspace.data,
modelId: MODEL_ID,
modelLabel: MODEL_LABEL,
onBack: () => navigate(workPath),
onComposerChange: workspace.handleComposerChange,
onComposerMessageSubmit: workspace.handleComposerMessageSubmit,
onComposerSubmit: workspace.handleComposerSubmit,
onCreateBodyChange: workspace.setIssueBody,
onCreateIssue: workspace.handleCreateIssueSubmit,
@@ -66,16 +71,24 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
)
: undefined,
onCreateTitleChange: workspace.setIssueTitle,
onManageProjects: () => navigate("/dashboard"),
onManageProjects: () => workspace.setProjectManagerOpen(true),
onOpenAssistant: () => navigate(chatPath),
onOpenUnit: handleOpenUnit,
onProjectManagerClose: () => workspace.setProjectManagerOpen(false),
onProjectSelect: (projectId: string) =>
projectWorkspace.projectWorkspace.setSelectedProjectId(
projectId as Id<"projects">
),
onRepositoryChange: projectWorkspace.projectWorkspace.setRepository,
onRepositoryConnect: (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
void projectWorkspace.projectWorkspace.connectRepository();
},
onReviewUnit: (reviewUrl: string) => {
window.open(reviewUrl, "_blank", "noopener,noreferrer");
},
onSettingsClose: () => workspace.setSettingsOpen(false),
onSettingsOpen: () => workspace.setSettingsOpen(true),
onStartUnit: (selectedIssueId: string) => {
void projectWorkspace.startWorkUnit(
selectedIssueId as Id<"projectIssues">
@@ -83,6 +96,9 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
},
onViewWork: () => navigate(workPath),
pendingAction: projectWorkspace.projectWorkspace.pendingAction,
projectManagerOpen: workspace.projectManagerOpen,
repositoryValue: projectWorkspace.projectWorkspace.repository,
settingsOpen: workspace.settingsOpen,
statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message,
};

View File

@@ -79,7 +79,10 @@ export const useMobileProjectWorkspace = ({
selectedWorkUnitId,
signals,
}),
error: organization.error ?? chatAgent.error,
error:
organization.error ??
chatAgent.error ??
(projectWorkspace.error ? new Error(projectWorkspace.error) : undefined),
organization,
projectWorkspace,
raiseIssue: projectWorkspace.raiseIssue,

View File

@@ -19,6 +19,8 @@ export const useMobileWorkspace = ({
const [issueBody, setIssueBody] = useState("");
const [issueTitle, setIssueTitle] = useState("");
const [expanded, setExpanded] = useState(initialExpanded);
const [projectManagerOpen, setProjectManagerOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [statusMessage, setStatusMessage] = useState<string>();
const handleComposerChange = (value: string) => {
@@ -26,23 +28,32 @@ export const useMobileWorkspace = ({
setStatusMessage(undefined);
};
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const message = composerValue.trim();
if (!message) {
const submitMessage = async (message: string) => {
const trimmedMessage = message.trim();
if (!trimmedMessage) {
return;
}
setStatusMessage("Sending to Zopu…");
const sendMessage = async () => {
try {
await onSend(trimmedMessage);
setComposerValue("");
setStatusMessage("Sent to Zopu");
} catch (error) {
setStatusMessage(errorMessage(error));
throw error;
}
};
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const sendCurrentMessage = async () => {
try {
await onSend(message);
setComposerValue("");
setStatusMessage("Sent to Zopu");
} catch (error) {
setStatusMessage(errorMessage(error));
await submitMessage(composerValue);
} catch {
// submitMessage already exposes the failure through statusMessage.
}
};
void sendMessage();
void sendCurrentMessage();
};
const handleCreateIssueSubmit = (event: FormEvent<HTMLFormElement>) => {
@@ -70,13 +81,18 @@ export const useMobileWorkspace = ({
composerValue,
expanded,
handleComposerChange,
handleComposerMessageSubmit: submitMessage,
handleComposerSubmit,
handleCreateIssueSubmit,
issueBody,
issueTitle,
projectManagerOpen,
setExpanded,
setIssueBody,
setIssueTitle,
setProjectManagerOpen,
setSettingsOpen,
settingsOpen,
statusMessage,
};
};

View File

@@ -10,7 +10,8 @@ export const CHAT_AGENT = {
name: "zopu",
} as const;
export const MODEL_LABEL = "GLM-5.2";
export const MODEL_ID = "openrouter/openai/gpt-oss-20b:free";
export const MODEL_LABEL = "GPT-OSS 20B Free";
export const SUGGESTIONS = [
["Think it through", "Help me make a difficult decision"],

View File

@@ -4,6 +4,12 @@ import { describe, expect, test } from "vitest";
import { createFlueFetch } from "./flue-transport";
const BASE_URL = new URL("https://flue.example/api/");
const GLOBAL_RECEIVER_FETCH = function globalReceiverFetch(this: unknown) {
if (this !== globalThis) {
throw new TypeError("fetch receiver must be globalThis");
}
return Promise.resolve(new Response(null, { status: 204 }));
} as typeof fetch;
describe("createFlueFetch", () => {
test("overlapping SDK agent sends keep distinct request IDs", async () => {
@@ -87,6 +93,31 @@ describe("createFlueFetch", () => {
]);
});
test("invokes fetch with the browser global receiver", async () => {
await expect(
createFlueFetch({ baseUrl: BASE_URL, fetchImpl: GLOBAL_RECEIVER_FETCH })(
"https://flue.example/api/agents/zopu/org-a?view=history"
)
).resolves.toBeInstanceOf(Response);
});
test("uses the default browser request ID generator without losing its receiver", async () => {
const capturedHeaders: Headers[] = [];
const fetchImpl: typeof fetch = (_input, init) => {
capturedHeaders.push(new Headers(init?.headers));
return Promise.resolve(new Response(null, { status: 204 }));
};
await createFlueFetch({ baseUrl: BASE_URL, fetchImpl })(
"https://flue.example/api/agents/zopu/org-a",
{ method: "POST" }
);
expect(capturedHeaders[0]?.get("x-zopu-request-id")).toMatch(
/^[0-9a-f-]{36}$/u
);
});
test("history and stream observation requests remain untagged", async () => {
const capturedHeaders: Headers[] = [];
const fetchImpl: typeof fetch = (_input, init) => {

View File

@@ -4,6 +4,31 @@ interface FlueFetchOptions {
readonly generateRequestId?: () => string;
}
const addRequestContext = async (
response: Response,
method: string,
requestUrl: URL
): Promise<Response> => {
if (response.ok) {
return response;
}
const responseClone = response.clone();
const responseText = await responseClone.text();
const detail = responseText.trim() || "request failed";
return Response.json(
{
error: {
message: `${method} ${requestUrl.pathname}${requestUrl.search}: ${detail}`,
},
},
{
headers: response.headers,
status: response.status,
statusText: response.statusText,
}
);
};
/**
* Build the Flue transport used by the browser client.
*
@@ -15,7 +40,7 @@ interface FlueFetchOptions {
export const createFlueFetch = ({
baseUrl,
fetchImpl = fetch,
generateRequestId = crypto.randomUUID,
generateRequestId = () => crypto.randomUUID(),
}: FlueFetchOptions): typeof fetch => {
const basePath = baseUrl.pathname.replace(/\/+$/u, "");
@@ -47,11 +72,9 @@ export const createFlueFetch = ({
requestInit = { ...init, headers };
}
const response = await fetchImpl(input, requestInit);
if (
!response.ok ||
!response.headers.get("content-type")?.includes("application/json")
) {
const rawResponse = await fetchImpl.call(globalThis, input, requestInit);
const response = await addRequestContext(rawResponse, method, requestUrl);
if (!response.headers.get("content-type")?.includes("application/json")) {
return response;
}

View File

@@ -125,11 +125,16 @@ const relevantSignalsForProject = (
const makeProjectViews = (
projects: readonly ProjectView[] | undefined
): readonly MobileProjectView[] =>
projects?.map((project) => ({
connected: project.sources.length > 0,
id: project.id,
name: project.name,
})) ?? [];
projects?.map((project) => {
const [source] = project.sources;
return {
connected: Boolean(source),
host: source?.host,
id: project.id,
name: project.name,
repositoryPath: source?.repositoryPath,
};
}) ?? [];
export const buildMobileWorkspaceView = ({
artifacts,

View File

@@ -1,15 +1,5 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import { Navigate } from "react-router";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Daily focus | Zopu" },
{
content: "Supervise active work and open the next action",
name: "description",
},
];
export default function MobileHomePage() {
return <MobileFlowPage screen="home" />;
export default function MobileLandingRedirect() {
return <Navigate replace to="/chat" />;
}