Integrate mobile chat workspace and OpenRouter agent flow
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user