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

View File

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

View File

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

View File

@@ -10,7 +10,8 @@ export const CHAT_AGENT = {
name: "zopu", name: "zopu",
} as const; } 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 = [ export const SUGGESTIONS = [
["Think it through", "Help me make a difficult decision"], ["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"; import { createFlueFetch } from "./flue-transport";
const BASE_URL = new URL("https://flue.example/api/"); 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", () => { describe("createFlueFetch", () => {
test("overlapping SDK agent sends keep distinct request IDs", async () => { 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 () => { test("history and stream observation requests remain untagged", async () => {
const capturedHeaders: Headers[] = []; const capturedHeaders: Headers[] = [];
const fetchImpl: typeof fetch = (_input, init) => { const fetchImpl: typeof fetch = (_input, init) => {

View File

@@ -4,6 +4,31 @@ interface FlueFetchOptions {
readonly generateRequestId?: () => string; 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. * Build the Flue transport used by the browser client.
* *
@@ -15,7 +40,7 @@ interface FlueFetchOptions {
export const createFlueFetch = ({ export const createFlueFetch = ({
baseUrl, baseUrl,
fetchImpl = fetch, fetchImpl = fetch,
generateRequestId = crypto.randomUUID, generateRequestId = () => crypto.randomUUID(),
}: FlueFetchOptions): typeof fetch => { }: FlueFetchOptions): typeof fetch => {
const basePath = baseUrl.pathname.replace(/\/+$/u, ""); const basePath = baseUrl.pathname.replace(/\/+$/u, "");
@@ -47,11 +72,9 @@ export const createFlueFetch = ({
requestInit = { ...init, headers }; requestInit = { ...init, headers };
} }
const response = await fetchImpl(input, requestInit); const rawResponse = await fetchImpl.call(globalThis, input, requestInit);
if ( const response = await addRequestContext(rawResponse, method, requestUrl);
!response.ok || if (!response.headers.get("content-type")?.includes("application/json")) {
!response.headers.get("content-type")?.includes("application/json")
) {
return response; return response;
} }

View File

@@ -125,11 +125,16 @@ const relevantSignalsForProject = (
const makeProjectViews = ( const makeProjectViews = (
projects: readonly ProjectView[] | undefined projects: readonly ProjectView[] | undefined
): readonly MobileProjectView[] => ): readonly MobileProjectView[] =>
projects?.map((project) => ({ projects?.map((project) => {
connected: project.sources.length > 0, const [source] = project.sources;
id: project.id, return {
name: project.name, connected: Boolean(source),
})) ?? []; host: source?.host,
id: project.id,
name: project.name,
repositoryPath: source?.repositoryPath,
};
}) ?? [];
export const buildMobileWorkspaceView = ({ export const buildMobileWorkspaceView = ({
artifacts, 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 default function MobileLandingRedirect() {
return <Navigate replace to="/chat" />;
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" />;
} }

View File

@@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "code", "name": "code",

View File

@@ -2,7 +2,6 @@ import path from "node:path";
import { parseAgentEnv } from "@code/env/agent"; import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime"; import { defineAgent } from "@flue/runtime";
// import { agentos } from "../sandboxes/agentos"; // import { agentos } from "../sandboxes/agentos";
import { local } from "@flue/runtime/node"; import { local } from "@flue/runtime/node";

View File

@@ -9,18 +9,25 @@ const agentEnv = parseAgentEnv(process.env);
registerProvider(agentEnv.AGENT_MODEL_PROVIDER, { registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
api: agentEnv.AGENT_MODEL_API, api: agentEnv.AGENT_MODEL_API,
apiKey: agentEnv.AGENT_MODEL_API_KEY, apiKey:
agentEnv.AGENT_MODEL_PROVIDER === "openrouter"
? (agentEnv.OPENROUTER_API_KEY ?? agentEnv.AGENT_MODEL_API_KEY)
: agentEnv.AGENT_MODEL_API_KEY,
baseUrl: agentEnv.AGENT_MODEL_BASE_URL, baseUrl: agentEnv.AGENT_MODEL_BASE_URL,
models: { models: {
[agentEnv.AGENT_MODEL_NAME]: { [agentEnv.AGENT_MODEL_NAME]: {
contextWindow: agentEnv.AGENT_MODEL_CONTEXT_WINDOW, contextWindow: agentEnv.AGENT_MODEL_CONTEXT_WINDOW,
maxTokens: agentEnv.AGENT_MODEL_MAX_TOKENS, maxTokens: agentEnv.AGENT_MODEL_MAX_TOKENS,
}, },
"openai/gpt-oss-20b:free": {
contextWindow: 131_072,
maxTokens: agentEnv.AGENT_MODEL_MAX_TOKENS,
},
}, },
}); });
const app = new Hono(); const app = new Hono();
app.post("/project-requests", projectRequestRoute); app.post("/project-requests", projectRequestRoute);
app.route("/", flue()); app.route("/", flue() as unknown as Hono);
export default app; export default app;

View File

@@ -317,4 +317,5 @@ export const authenticatedAgentRoute: AgentRouteHandler = async (c, next) => {
clientRequestId, clientRequestId,
organizationId: organization._id, organizationId: organization._id,
}); });
return c.res;
}; };

View File

@@ -1,7 +1,9 @@
import { parseAgentEnv } from "@code/env/agent"; import { parseAgentEnv } from "@code/env/agent";
import { defineTool } from "@flue/runtime"; import { ProjectIssueDispatchInput } from "@code/primitives/project-issue";
import { defineTool, dispatch } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser"; import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server"; import { makeFunctionReference } from "convex/server";
import { Schema } from "effect";
import * as v from "valibot"; import * as v from "valibot";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -103,9 +105,24 @@ const beginIssueRef = makeFunctionReference<
readonly issueId: string; readonly issueId: string;
readonly token: string; readonly token: string;
}, },
"queued" | "working" {
readonly dispatchRequired: boolean;
readonly projectId: string;
readonly status: "queued" | "working";
}
>("signalRouting:beginIssue"); >("signalRouting:beginIssue");
const markDispatchFailedRef = makeFunctionReference<
"mutation",
{
readonly error: string;
readonly issueId: string;
readonly organizationId: string;
readonly token: string;
},
null
>("signalRouting:markDispatchFailed");
const getProjectContextRef = makeFunctionReference< const getProjectContextRef = makeFunctionReference<
"query", "query",
{ {
@@ -306,11 +323,45 @@ export const createSignalRoutingTools = (
}), }),
name: "begin_issue", name: "begin_issue",
async run({ input }) { async run({ input }) {
return await client.mutation(beginIssueRef, { const outcome = await client.mutation(beginIssueRef, {
issueId: input.issueId, issueId: input.issueId,
organizationId, organizationId,
token, token,
}); });
const dispatchInput = Schema.decodeUnknownSync(
ProjectIssueDispatchInput
)({
issueId: input.issueId,
kind: "project.issue.started",
projectId: outcome.projectId,
});
if (!outcome.dispatchRequired) {
return {
acceptedAt: "",
dispatchId: "",
status: outcome.status,
};
}
try {
const receipt = await dispatch({
agent: "project-manager",
id: input.issueId,
input: dispatchInput,
});
return {
acceptedAt: receipt.acceptedAt,
dispatchId: receipt.dispatchId,
status: outcome.status,
};
} catch (error) {
await client.mutation(markDispatchFailedRef, {
error: error instanceof Error ? error.message : String(error),
issueId: input.issueId,
organizationId,
token,
});
throw error;
}
}, },
}), }),
]; ];

View File

@@ -26,8 +26,10 @@ import type * as projectIssues from "../projectIssues.js";
import type * as projectStore from "../projectStore.js"; import type * as projectStore from "../projectStore.js";
import type * as projects from "../projects.js"; import type * as projects from "../projects.js";
import type * as publicGit from "../publicGit.js"; import type * as publicGit from "../publicGit.js";
import type * as signalRouting from "../signalRouting.js";
import type * as signals from "../signals.js"; import type * as signals from "../signals.js";
import type * as todos from "../todos.js"; import type * as todos from "../todos.js";
import type * as workflows from "../workflows.js";
import type { import type {
ApiFromModules, ApiFromModules,
@@ -54,8 +56,10 @@ declare const fullApi: ApiFromModules<{
projectStore: typeof projectStore; projectStore: typeof projectStore;
projects: typeof projects; projects: typeof projects;
publicGit: typeof publicGit; publicGit: typeof publicGit;
signalRouting: typeof signalRouting;
signals: typeof signals; signals: typeof signals;
todos: typeof todos; todos: typeof todos;
workflows: typeof workflows;
}>; }>;
/** /**

View File

@@ -26,6 +26,8 @@ import type { DataModel } from "./dataModel.js";
*/ */
type Env = { type Env = {
readonly FLUE_DB_TOKEN: string; readonly FLUE_DB_TOKEN: string;
readonly GITEA_TOKEN: string | undefined;
readonly GITEA_URL: string | undefined;
readonly NATIVE_APP_URL: string | undefined; readonly NATIVE_APP_URL: string | undefined;
readonly SITE_URL: string; readonly SITE_URL: string;
}; };

View File

@@ -1,4 +1,5 @@
import { import {
decodePublicGitImportResult,
preparePublicGitSource, preparePublicGitSource,
type ProjectImportOutcome, type ProjectImportOutcome,
type ProjectView, type ProjectView,
@@ -8,15 +9,17 @@ import {
import { v } from "convex/values"; import { v } from "convex/values";
import { Effect } from "effect"; import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel"; import type { Id } from "./_generated/dataModel";
import { action, internalMutation, query } from "./_generated/server"; import { action, internalMutation, query } from "./_generated/server";
import { createInitialArtifacts } from "./artifactModel"; import { createInitialArtifacts } from "./artifactModel";
import { requireCurrentOrganization } from "./authz"; import { requireAuthUserId, requireCurrentOrganization } from "./authz";
import { import {
makeProjectMutationStore, makeProjectMutationStore,
makeProjectQueryStore, makeProjectQueryStore,
persistPublicGitImportTransaction, persistPublicGitImportTransaction,
} from "./projectStore"; } from "./projectStore";
import { inspectPublicGit } from "./publicGit";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Internal: persist a public Git import in one transaction // Internal: persist a public Git import in one transaction
@@ -182,18 +185,31 @@ export const get = query({
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public action: import a public Git repository // Public action: import a supported public Git repository
// (daemon wiring added in the Daemon phase)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const importPublicGit = action({ export const importPublicGit = action({
args: { repositoryUrl: v.string() }, args: { repositoryUrl: v.string() },
handler: async (_ctx, args): Promise<ProjectImportOutcome> => { handler: async (ctx, args): Promise<ProjectImportOutcome> => {
// Normalize through the domain layer to validate the URL before the const userId = await requireAuthUserId(ctx);
// daemon adapter is wired. const source = await Effect.runPromise(
await Effect.runPromise(preparePublicGitSource(args.repositoryUrl)); preparePublicGitSource(args.repositoryUrl)
// The daemon PublicGit adapter is wired in the Daemon phase. );
throw new Error("PublicGit import is not yet wired to the daemon adapter"); const remote = await inspectPublicGit(source);
const validatedRemote = await Effect.runPromise(
decodePublicGitImportResult(remote)
);
return await ctx.runMutation(internal.projects.persistPublicGitImport, {
remote: {
defaultBranch: validatedRemote.defaultBranch,
documents: validatedRemote.documents.map((document) => ({
...document,
})),
warnings: validatedRemote.warnings.map((warning) => ({ ...warning })),
},
source,
userId,
});
}, },
}); });

View File

@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import { inspectPublicGit } from "./publicGit";
const source = {
host: "git.openputer.com",
normalizedUrl: "https://git.openputer.com/sai-karthik/hello-world",
projectName: "hello-world",
repositoryPath: "Sai-karthik/hello-world",
url: "https://git.openputer.com/Sai-karthik/hello-world.git",
};
describe("public Git repository inspection", () => {
test("imports metadata for an empty public Gitea repository", async () => {
const requests: string[] = [];
const result = await inspectPublicGit(source, {
fetch(input) {
const url = String(input);
requests.push(url);
if (url.endsWith("/api/v1/repos/Sai-karthik/hello-world")) {
return Promise.resolve(
Response.json({
default_branch: "main",
empty: true,
})
);
}
return Promise.resolve(new Response("not found", { status: 404 }));
},
});
expect(result).toMatchObject({
defaultBranch: "main",
documents: [],
});
expect(result.warnings).toHaveLength(6);
expect(requests).toContain(
"https://git.openputer.com/api/v1/repos/Sai-karthik/hello-world"
);
expect(requests).toContain(
"https://git.openputer.com/api/v1/repos/Sai-karthik/hello-world/raw/README.md?ref=main"
);
});
test("rejects hosts outside the supported provider allowlist", async () => {
await expect(
inspectPublicGit({
...source,
host: "example.com",
normalizedUrl: "https://example.com/example/repository",
repositoryPath: "example/repository",
url: "https://example.com/example/repository.git",
})
).rejects.toThrow(
"Repository import supports public GitHub and git.openputer.com URLs"
);
});
});

View File

@@ -8,10 +8,31 @@ import {
} from "@code/primitives/project"; } from "@code/primitives/project";
const GITHUB_HOST = "github.com"; const GITHUB_HOST = "github.com";
const REQUEST_HEADERS = { const GITEA_HOST = "git.openputer.com";
const GITHUB_REQUEST_HEADERS = {
Accept: "application/vnd.github+json", Accept: "application/vnd.github+json",
"User-Agent": "zopu-project-importer", "User-Agent": "zopu-project-importer",
} as const; } as const;
const GITEA_REQUEST_HEADERS = {
Accept: "application/json",
"User-Agent": "zopu-project-importer",
} as const;
type Fetch = (
input: string | URL | Request,
init?: RequestInit
) => Promise<Response>;
interface PublicGitProvider {
readonly headers: Readonly<Record<string, string>>;
readonly metadataUrl: (source: PreparedPublicGitSource) => string;
readonly rawFileUrl: (
source: PreparedPublicGitSource,
branch: string,
path: string
) => string;
readonly repositoryNotFoundMessage: string;
}
const candidatePaths = { const candidatePaths = {
agents: ["AGENTS.md"], agents: ["AGENTS.md"],
@@ -28,18 +49,51 @@ const encodePath = (path: string) =>
.map((segment) => encodeURIComponent(segment)) .map((segment) => encodeURIComponent(segment))
.join("/"); .join("/");
const providerForSource = (
source: PreparedPublicGitSource
): PublicGitProvider => {
if (source.host === GITHUB_HOST) {
return {
headers: GITHUB_REQUEST_HEADERS,
metadataUrl: (candidate) =>
`https://api.github.com/repos/${encodePath(candidate.repositoryPath)}`,
rawFileUrl: (candidate, branch, path) =>
`https://raw.githubusercontent.com/${encodePath(candidate.repositoryPath)}/${encodePath(branch)}/${encodePath(path)}`,
repositoryNotFoundMessage: "Public GitHub repository not found",
};
}
if (source.host === GITEA_HOST) {
return {
headers: GITEA_REQUEST_HEADERS,
metadataUrl: (candidate) =>
`https://${GITEA_HOST}/api/v1/repos/${encodePath(candidate.repositoryPath)}`,
rawFileUrl: (candidate, branch, path) =>
`https://${GITEA_HOST}/api/v1/repos/${encodePath(candidate.repositoryPath)}/raw/${encodePath(path)}?ref=${encodeURIComponent(branch)}`,
repositoryNotFoundMessage: "Public Gitea repository not found",
};
}
throw new Error(
"Repository import supports public GitHub and git.openputer.com URLs"
);
};
const fetchTextCandidate = async ( const fetchTextCandidate = async (
repositoryPath: string, fetcher: Fetch,
provider: PublicGitProvider,
source: PreparedPublicGitSource,
branch: string, branch: string,
path: string path: string
): Promise<string | null> => { ): Promise<string | null> => {
const url = `https://raw.githubusercontent.com/${encodePath(repositoryPath)}/${encodePath(branch)}/${encodePath(path)}`; const response = await fetcher(provider.rawFileUrl(source, branch, path), {
const response = await fetch(url, { headers: REQUEST_HEADERS }); headers: provider.headers,
});
if (response.status === 404) { if (response.status === 404) {
return null; return null;
} }
if (!response.ok) { if (!response.ok) {
throw new Error(`GitHub returned ${response.status} for ${path}`); throw new Error(
`${source.host} returned ${response.status} while reading ${path}`
);
} }
const content = await response.text(); const content = await response.text();
if (content.trim().length === 0) { if (content.trim().length === 0) {
@@ -52,13 +106,17 @@ const fetchTextCandidate = async (
}; };
const fetchContextDocument = async ( const fetchContextDocument = async (
fetcher: Fetch,
provider: PublicGitProvider,
source: PreparedPublicGitSource, source: PreparedPublicGitSource,
branch: string, branch: string,
kind: (typeof CONTEXT_KINDS)[number] kind: (typeof CONTEXT_KINDS)[number]
): Promise<RepositoryContextDocument | null> => { ): Promise<RepositoryContextDocument | null> => {
for (const candidate of candidatePaths[kind]) { for (const candidate of candidatePaths[kind]) {
const content = await fetchTextCandidate( const content = await fetchTextCandidate(
source.repositoryPath, fetcher,
provider,
source,
branch, branch,
candidate candidate
); );
@@ -74,20 +132,18 @@ const fetchContextDocument = async (
}; };
const readDefaultBranch = async ( const readDefaultBranch = async (
fetcher: Fetch,
provider: PublicGitProvider,
source: PreparedPublicGitSource source: PreparedPublicGitSource
): Promise<string> => { ): Promise<string> => {
if (source.host !== GITHUB_HOST) { const response = await fetcher(provider.metadataUrl(source), {
throw new Error("Repository import currently supports public GitHub URLs"); headers: provider.headers,
} });
const response = await fetch(
`https://api.github.com/repos/${encodePath(source.repositoryPath)}`,
{ headers: REQUEST_HEADERS }
);
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
response.status === 404 response.status === 404
? "Public GitHub repository not found" ? provider.repositoryNotFoundMessage
: `GitHub returned ${response.status} while reading the repository` : `${source.host} returned ${response.status} while reading the repository`
); );
} }
const metadata = (await response.json()) as { default_branch?: unknown }; const metadata = (await response.json()) as { default_branch?: unknown };
@@ -95,18 +151,21 @@ const readDefaultBranch = async (
typeof metadata.default_branch !== "string" || typeof metadata.default_branch !== "string" ||
metadata.default_branch.length === 0 metadata.default_branch.length === 0
) { ) {
throw new Error("GitHub did not return a default branch"); throw new Error(`${source.host} did not return a default branch`);
} }
return metadata.default_branch; return metadata.default_branch;
}; };
export const inspectPublicGit = async ( export const inspectPublicGit = async (
source: PreparedPublicGitSource source: PreparedPublicGitSource,
options: { readonly fetch?: Fetch } = {}
): Promise<PublicGitImportResult> => { ): Promise<PublicGitImportResult> => {
const defaultBranch = await readDefaultBranch(source); const fetcher = options.fetch ?? globalThis.fetch;
const provider = providerForSource(source);
const defaultBranch = await readDefaultBranch(fetcher, provider, source);
const candidates = await Promise.all( const candidates = await Promise.all(
CONTEXT_KINDS.map((kind) => CONTEXT_KINDS.map((kind) =>
fetchContextDocument(source, defaultBranch, kind) fetchContextDocument(fetcher, provider, source, defaultBranch, kind)
) )
); );
const documents = candidates.filter( const documents = candidates.filter(

View File

@@ -491,7 +491,22 @@ describe("signalRouting begin issue", () => {
token: TOKEN, token: TOKEN,
}); });
expect(status).toBe("queued"); expect(status).toEqual({
dispatchRequired: true,
projectId,
status: "queued",
});
const repeated = await t.mutation(api.signalRouting.beginIssue, {
issueId: result.issueId as string,
organizationId: orgId,
token: TOKEN,
});
expect(repeated).toEqual({
dispatchRequired: false,
projectId,
status: "queued",
});
}); });
test("begin rejects invalid token", async () => { test("begin rejects invalid token", async () => {

View File

@@ -613,7 +613,14 @@ export const beginIssue = mutation({
issueId: v.id("projectIssues"), issueId: v.id("projectIssues"),
token: v.string(), token: v.string(),
}, },
handler: async (ctx, args): Promise<"queued" | "working"> => { handler: async (
ctx,
args
): Promise<{
dispatchRequired: boolean;
projectId: Id<"projects">;
status: "queued" | "working";
}> => {
requireAgent(args.token); requireAgent(args.token);
const issue = await ctx.db.get(args.issueId); const issue = await ctx.db.get(args.issueId);
if (!issue) { if (!issue) {
@@ -629,7 +636,11 @@ export const beginIssue = mutation({
); );
}); });
if (status === issue.status) { if (status === issue.status) {
return status; return {
dispatchRequired: false,
projectId: issue.projectId,
status,
};
} }
const timestamp = Date.now(); const timestamp = Date.now();
@@ -644,12 +655,50 @@ export const beginIssue = mutation({
kind: "issue.queued", kind: "issue.queued",
projectId: issue.projectId, projectId: issue.projectId,
}); });
return status; return {
dispatchRequired: true,
projectId: issue.projectId,
status,
};
}, },
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 8. Get project summary + context documents for routing decisions. // 8. Mark a failed Flue dispatch so retrying can safely queue it again.
// ---------------------------------------------------------------------------
export const markDispatchFailed = mutation({
args: {
error: v.string(),
issueId: v.id("projectIssues"),
organizationId: v.id("organizations"),
token: v.string(),
},
handler: async (ctx, args) => {
requireAgent(args.token);
const issue = await ctx.db.get(args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId);
const timestamp = Date.now();
await ctx.db.patch(issue._id, {
status: "failed",
updatedAt: timestamp,
});
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
issueId: issue._id,
kind: "agent.failed",
projectId: issue.projectId,
});
return null;
},
});
// ---------------------------------------------------------------------------
// 9. Get project summary + context documents for routing decisions.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const getProjectContext = query({ export const getProjectContext = query({

View File

@@ -13,6 +13,7 @@ const agentEnvSchema = z.object({
FLUE_DB_TOKEN: z.string().min(1), FLUE_DB_TOKEN: z.string().min(1),
GITEA_TOKEN: z.string().min(1).optional(), GITEA_TOKEN: z.string().min(1).optional(),
GITEA_URL: z.url().default("https://git.openputer.com"), GITEA_URL: z.url().default("https://git.openputer.com"),
OPENROUTER_API_KEY: z.string().min(1).optional(),
}); });
export type AgentEnv = z.infer<typeof agentEnvSchema>; export type AgentEnv = z.infer<typeof agentEnvSchema>;

View File

@@ -0,0 +1,161 @@
"use client";
import {
InputGroup,
InputGroupButton,
InputGroupTextarea,
} from "@code/ui/components/input-group";
import { cn } from "@code/ui/lib/utils";
import type { ChatStatus } from "ai";
import { ArrowUpIcon, LoaderCircleIcon, SquareIcon } from "lucide-react";
import type {
ComponentProps,
FormEvent,
FormEventHandler,
HTMLAttributes,
KeyboardEventHandler,
ReactNode,
} from "react";
export interface PromptInputMessage {
files: [];
text: string;
}
export type PromptInputProps = Omit<ComponentProps<"form">, "onSubmit"> & {
onSubmit: (
message: PromptInputMessage,
event: FormEvent<HTMLFormElement>
) => Promise<void> | void;
};
export const PromptInput = ({
children,
className,
onSubmit,
...props
}: PromptInputProps) => {
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const text = new FormData(event.currentTarget)
.get("message")
?.toString()
.trim();
if (!text) {
return;
}
const submitMessage = async () => {
try {
await onSubmit({ files: [], text }, event);
} catch {
// The owning chat hook exposes submission failures in the composer status.
}
};
void submitMessage();
};
return (
<form
className={cn("w-full", className)}
onSubmit={handleSubmit}
{...props}
>
<InputGroup>{children}</InputGroup>
</form>
);
};
export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputBody = ({
className,
...props
}: PromptInputBodyProps) => (
<div className={cn("contents", className)} {...props} />
);
export type PromptInputTextareaProps = ComponentProps<
typeof InputGroupTextarea
>;
export const PromptInputTextarea = ({
className,
onKeyDown,
...props
}: PromptInputTextareaProps) => {
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (event) => {
onKeyDown?.(event);
if (
event.defaultPrevented ||
event.key !== "Enter" ||
event.shiftKey ||
event.nativeEvent.isComposing
) {
return;
}
event.preventDefault();
event.currentTarget.form?.requestSubmit();
};
return (
<InputGroupTextarea
className={cn("field-sizing-content", className)}
name="message"
onKeyDown={handleKeyDown}
{...props}
/>
);
};
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
export const PromptInputButton = ({
className,
size = "icon-sm",
...props
}: PromptInputButtonProps) => (
<InputGroupButton
className={cn(className)}
size={size}
type="button"
{...props}
/>
);
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
status?: ChatStatus;
};
export const PromptInputSubmit = ({
children,
className,
disabled,
size = "icon-sm",
status = "ready",
...props
}: PromptInputSubmitProps) => {
const isBusy = status === "submitted" || status === "streaming";
let content: ReactNode = children ?? <ArrowUpIcon className="size-4" />;
if (status === "streaming") {
content = <SquareIcon className="size-4 fill-current" />;
} else if (status === "submitted") {
content = <LoaderCircleIcon className="size-4 animate-spin" />;
}
return (
<InputGroupButton
aria-busy={isBusy}
className={cn(className)}
disabled={disabled}
size={size}
type="submit"
{...props}
>
{content}
</InputGroupButton>
);
};

View File

@@ -8,7 +8,6 @@ import {
FileText, FileText,
ListChecks, ListChecks,
LoaderCircle, LoaderCircle,
Mic,
MoreHorizontal, MoreHorizontal,
Paperclip, Paperclip,
Plus, Plus,
@@ -17,6 +16,23 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { FormEvent, ReactNode } from "react"; import type { FormEvent, ReactNode } from "react";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "./ai-elements/conversation";
import {
Message,
MessageContent,
MessageResponse,
} from "./ai-elements/message";
import {
PromptInput,
PromptInputBody,
PromptInputButton,
PromptInputSubmit,
PromptInputTextarea,
} from "./ai-elements/prompt-input";
import type { import type {
MobileProjectView, MobileProjectView,
MobileWorkspaceView, MobileWorkspaceView,
@@ -36,21 +52,32 @@ export interface MobileWorkspaceRendererProps {
createIssueBody: string; createIssueBody: string;
createIssueTitle: string; createIssueTitle: string;
data: MobileWorkspaceView; data: MobileWorkspaceView;
modelId?: string;
modelLabel?: string;
onBack?: () => void; onBack?: () => void;
onComposerChange: (value: string) => void; onComposerChange: (value: string) => void;
onComposerMessageSubmit?: (message: string) => Promise<void>;
onComposerSubmit: (event: FormEvent<HTMLFormElement>) => void; onComposerSubmit: (event: FormEvent<HTMLFormElement>) => void;
onCreateBodyChange: (value: string) => void; onCreateBodyChange: (value: string) => void;
onCreateIssue: (event: FormEvent<HTMLFormElement>) => void; onCreateIssue: (event: FormEvent<HTMLFormElement>) => void;
onCreateIssueFromSignal?: () => void; onCreateIssueFromSignal?: () => void;
onCreateTitleChange: (value: string) => void; onCreateTitleChange: (value: string) => void;
onManageProjects?: () => void; onManageProjects?: () => void;
onProjectManagerClose?: () => void;
onOpenAssistant?: () => void; onOpenAssistant?: () => void;
onOpenUnit?: (workUnitId?: string) => void; onOpenUnit?: (workUnitId?: string) => void;
onProjectSelect?: (projectId: string) => void; onProjectSelect?: (projectId: string) => void;
onRepositoryChange?: (value: string) => void;
onRepositoryConnect?: (event: FormEvent<HTMLFormElement>) => void;
onSettingsClose?: () => void;
onSettingsOpen?: () => void;
onReviewUnit?: (reviewUrl: string) => void; onReviewUnit?: (reviewUrl: string) => void;
onStartUnit?: (workUnitId: string) => void; onStartUnit?: (workUnitId: string) => void;
onViewWork?: () => void; onViewWork?: () => void;
pendingAction?: string | null; pendingAction?: string | null;
projectManagerOpen?: boolean;
repositoryValue?: string;
settingsOpen?: boolean;
statusMessage?: string; statusMessage?: string;
variant: MobileWorkspaceVariant; variant: MobileWorkspaceVariant;
} }
@@ -294,7 +321,9 @@ const ProjectSwitcher = ({
) : ( ) : (
data.projects.map((project: MobileProjectView) => ( data.projects.map((project: MobileProjectView) => (
<option key={project.id} value={project.id}> <option key={project.id} value={project.id}>
{project.name} {project.connected ? "" : "(not connected)"} {project.repositoryPath ?? project.name}
{project.host ? ` · ${project.host}` : ""}
{project.connected ? "" : " (not connected)"}
</option> </option>
)) ))
)} )}
@@ -310,6 +339,184 @@ const ProjectSwitcher = ({
</div> </div>
); );
const ProjectManagementPanel = ({
createIssueBody,
createIssueTitle,
data,
onClose,
onCreateBodyChange,
onCreateIssue,
onCreateTitleChange,
onProjectSelect,
onRepositoryChange,
onRepositoryConnect,
pendingAction,
repositoryValue = "",
statusMessage,
}: {
readonly createIssueBody: string;
readonly createIssueTitle: string;
readonly data: MobileWorkspaceView;
readonly onClose?: () => void;
readonly onCreateBodyChange: (value: string) => void;
readonly onCreateIssue: (event: FormEvent<HTMLFormElement>) => void;
readonly onCreateTitleChange: (value: string) => void;
readonly onProjectSelect?: (projectId: string) => void;
readonly onRepositoryChange?: (value: string) => void;
readonly onRepositoryConnect?: (event: FormEvent<HTMLFormElement>) => void;
readonly pendingAction?: string | null;
readonly repositoryValue?: string;
readonly statusMessage?: string;
}) => {
const connecting = pendingAction === "connect";
return (
<section
aria-label="Project workspace"
className="absolute inset-0 z-30 flex flex-col bg-[#f7f8f5] text-[#11110f]"
>
<header className="flex h-20 shrink-0 items-center border-b border-[#e9eae6] bg-white px-4">
<div>
<h2 className="text-[18px] font-semibold">Project workspace</h2>
<p className="text-[11px] text-[#807b76]">
Connect repositories and create durable work.
</p>
</div>
<button
aria-label="Close project workspace"
className="ml-auto flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
onClick={onClose}
type="button"
>
<X className="size-5" />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<div className="rounded-[22px] bg-white p-4">
<p className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase">
Connected project
</p>
<select
aria-label="Active project"
className="mt-2 h-11 w-full rounded-[14px] bg-[#f2f3ef] px-3 text-[13px] font-semibold outline-none"
disabled={data.projects.length === 0}
onChange={(event) => onProjectSelect?.(event.target.value)}
value={data.selectedProjectId ?? ""}
>
{data.projects.length === 0 ? (
<option value="">No connected projects</option>
) : (
data.projects.map((project) => (
<option key={project.id} value={project.id}>
{project.repositoryPath ?? project.name}
</option>
))
)}
</select>
<form className="mt-4" onSubmit={onRepositoryConnect}>
<label
className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase"
htmlFor="mobile-repository-url"
>
Connect another repository
</label>
<input
aria-label="Public Git repository URL"
className="mt-2 h-11 w-full rounded-[14px] bg-[#f2f3ef] px-3 text-[12px] outline-none placeholder:text-[#aaa7a3]"
disabled={connecting}
id="mobile-repository-url"
onChange={(event) => onRepositoryChange?.(event.target.value)}
placeholder="https://git.openputer.com/owner/repo.git"
required
type="url"
value={repositoryValue}
/>
<button
className="mt-2 flex h-10 w-full items-center justify-center gap-2 rounded-[14px] bg-[#c8ff00] text-[12px] font-semibold text-black disabled:opacity-50"
disabled={connecting || !repositoryValue.trim()}
type="submit"
>
{connecting ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : null}
{connecting ? "Connecting…" : "Connect repository"}
</button>
</form>
</div>
{data.selectedProjectId ? (
<div className="mt-4">
<CreateWorkForm
body={createIssueBody}
onBodyChange={onCreateBodyChange}
onSubmit={onCreateIssue}
onTitleChange={onCreateTitleChange}
pending={pendingAction === "issue"}
title={createIssueTitle}
/>
</div>
) : null}
{statusMessage ? (
<p className="mt-3 text-center text-[11px] text-[#807b76]">
{statusMessage}
</p>
) : null}
</div>
</section>
);
};
const ChatSettingsPanel = ({
modelId = "openrouter/openai/gpt-oss-20b:free",
modelLabel = "GPT-OSS 20B Free",
onClose,
}: {
readonly modelId?: string;
readonly modelLabel?: string;
readonly onClose?: () => void;
}) => (
<section
aria-label="Chat settings"
className="absolute inset-0 z-40 flex flex-col bg-[#f7f8f5] text-[#11110f]"
>
<header className="flex h-20 shrink-0 items-center border-b border-[#e9eae6] bg-white px-4">
<div>
<h2 className="text-[18px] font-semibold">Settings</h2>
<p className="text-[11px] text-[#807b76]">
Configure how Zopu responds.
</p>
</div>
<button
aria-label="Close settings"
className="ml-auto flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
onClick={onClose}
type="button"
>
<X className="size-5" />
</button>
</header>
<div className="p-4">
<div className="rounded-[22px] bg-white p-4">
<label
className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase"
htmlFor="mobile-chat-model"
>
Chat model
</label>
<select
className="mt-2 h-11 w-full rounded-[14px] bg-[#f2f3ef] px-3 text-[13px] font-semibold outline-none"
id="mobile-chat-model"
value={modelId}
>
<option value={modelId}>{modelLabel}</option>
</select>
<p className="mt-2 text-[11px] leading-4 text-[#807b76]">
A tool-capable free OpenRouter model is active for the local Zopu
agent.
</p>
</div>
</div>
</section>
);
const WorkUnitActions = ({ const WorkUnitActions = ({
onReview, onReview,
onStart, onStart,
@@ -1224,16 +1431,126 @@ const assistantStatusDotClass: Record<
red: "bg-[#e04b3f]", red: "bg-[#e04b3f]",
}; };
const ChatWorkUnitCard = ({
onOpen,
onReview,
onStart,
pendingAction,
workUnit,
}: {
readonly onOpen?: (workUnitId?: string) => void;
readonly onReview?: (reviewUrl: string) => void;
readonly onStart?: (workUnitId: string) => void;
readonly pendingAction?: string | null;
readonly workUnit?: MobileWorkUnitView;
}) => {
if (!workUnit) {
return null;
}
return (
<article
className={cn(
"mt-5 rounded-[22px] p-[14px]",
workUnitToneClass[workUnit.tone]
)}
>
<button
className="block w-full text-left"
onClick={() => onOpen?.(workUnit.id)}
type="button"
>
<div className="flex items-center gap-2">
<span
className={cn(
"text-[10px] font-semibold uppercase",
workUnitStatusClass[workUnit.tone]
)}
>
{workUnit.statusLabel}
</span>
<span className="text-[10px] opacity-60">{workUnit.code}</span>
<ChevronRight className="ml-auto size-4" />
</div>
<h2 className="mt-2 text-[17px] leading-5 font-semibold">
{workUnit.title}
</h2>
<p className="mt-1 line-clamp-2 text-[12px] leading-4 opacity-70">
{workUnit.summary}
</p>
<div className="mt-3 h-1.5 rounded-full bg-current/10">
<div
className="h-full rounded-full bg-current"
style={{ width: `${workUnit.progress}%` }}
/>
</div>
<div className="mt-1.5 flex text-[10px] font-semibold">
<span>{workUnit.progress}% complete</span>
<span className="ml-auto opacity-60">{workUnit.nextAction}</span>
</div>
</button>
<WorkUnitActions
onReview={onReview}
onStart={onStart}
pending={pendingAction === `issue:${workUnit.id}`}
workUnit={workUnit}
/>
</article>
);
};
const CharacterChat = ({ const CharacterChat = ({
createIssueBody,
createIssueTitle,
data, data,
modelId,
modelLabel,
onChange, onChange,
onMessageSubmit,
onCreateIssueFromSignal, onCreateIssueFromSignal,
onSubmit, onCreateBodyChange,
onCreateIssue,
onCreateTitleChange,
onManageProjects,
onOpen,
onProjectManagerClose,
onProjectSelect,
onRepositoryChange,
onRepositoryConnect,
onReview,
onSettingsClose,
onSettingsOpen,
onStart,
pendingAction,
projectManagerOpen,
repositoryValue,
settingsOpen,
statusMessage, statusMessage,
value, value,
}: ComposerProps & { }: ComposerProps & {
createIssueBody: string;
createIssueTitle: string;
data: MobileWorkspaceView; data: MobileWorkspaceView;
modelId?: string;
modelLabel?: string;
onCreateBodyChange: (value: string) => void;
onCreateIssue: (event: FormEvent<HTMLFormElement>) => void;
onCreateIssueFromSignal?: () => void; onCreateIssueFromSignal?: () => void;
onCreateTitleChange: (value: string) => void;
onManageProjects?: () => void;
onOpen?: (workUnitId?: string) => void;
onProjectManagerClose?: () => void;
onProjectSelect?: (projectId: string) => void;
onRepositoryChange?: (value: string) => void;
onRepositoryConnect?: (event: FormEvent<HTMLFormElement>) => void;
onMessageSubmit?: (message: string) => Promise<void>;
onReview?: (reviewUrl: string) => void;
onSettingsClose?: () => void;
onSettingsOpen?: () => void;
onStart?: (workUnitId: string) => void;
pendingAction?: string | null;
projectManagerOpen?: boolean;
repositoryValue?: string;
settingsOpen?: boolean;
}) => ( }) => (
<div className="mx-auto h-[min(100dvh,858px)] w-full max-w-[390px] overflow-hidden bg-black"> <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]"> <main className="relative h-full overflow-hidden rounded-[30px] bg-white px-4 text-[#11110f]">
@@ -1253,146 +1570,218 @@ const CharacterChat = ({
{data.assistant.statusLabel} {data.assistant.statusLabel}
</p> </p>
</div> </div>
<MoreHorizontal className="ml-auto size-5" /> <button
aria-label="Open settings"
className="ml-auto flex size-9 items-center justify-center rounded-full bg-[#f2f3ef]"
onClick={onSettingsOpen}
type="button"
>
<MoreHorizontal className="size-5" />
</button>
</header> </header>
<section className="absolute inset-x-4 top-[106px] bottom-[105px] overflow-y-auto pb-6"> <div className="absolute inset-x-0 top-[82px]">
{data.assistant.messages.length === 0 ? ( <ProjectSwitcher
<> data={data}
<div className="w-fit rounded-full bg-[#f4f4f2] px-2.5 py-1.5 text-[10px] text-[#aaa7a3]"> onManageProjects={onManageProjects}
TODAY onProjectSelect={onProjectSelect}
</div> />
<div className="mt-10 flex items-center gap-2 text-[14px] text-[#55524f]"> </div>
<span className="flex size-6 items-center justify-center rounded-[8px] bg-[#f4f4f2]"> <Conversation className="absolute inset-x-4 top-[127px] bottom-[105px]">
<Sparkles className="size-4" /> <ConversationContent className="min-h-full gap-0 p-0 pb-6">
</span> {data.assistant.messages.length === 0 ? (
<strong>Zopu</strong> <>
<span className="text-[#b5b2ae]"> a fresh start</span> <div className="w-fit rounded-full bg-[#f4f4f2] px-2.5 py-1.5 text-[10px] text-[#aaa7a3]">
</div> TODAY
<p className="mt-3 text-[17px] leading-5"> </div>
Good morning. What are we making clearer today? <div className="mt-10 flex items-center gap-2 text-[14px] text-[#55524f]">
</p> <span className="flex size-6 items-center justify-center rounded-[8px] bg-[#f4f4f2]">
<div className="mt-4 flex gap-7 text-[12px] font-semibold text-[#55524f]"> <Sparkles className="size-4" />
<button </span>
className="flex items-center gap-2" <strong>Zopu</strong>
onClick={() => onChange("Summarize my latest project signals")} <span className="text-[#b5b2ae]"> a fresh start</span>
type="button" </div>
> <p className="mt-3 text-[17px] leading-5">
<FileText className="size-4" /> Good morning. What are we making clearer today?
Summarize </p>
</button> <div className="mt-4 flex gap-7 text-[12px] font-semibold text-[#55524f]">
<button
className="flex items-center gap-2"
onClick={() => onChange("Plan the next steps for my project")}
type="button"
>
<ListChecks className="size-4" />
Plan
</button>
<button
className="flex items-center gap-2"
onClick={() => onChange("Explore risks in my active work")}
type="button"
>
Explore
</button>
</div>
{data.latestSignal ? (
<section className="mt-12 rounded-[22px] bg-[#f4f4f2] p-[14px]">
<div className="flex items-center">
<span className="flex size-8 items-center justify-center rounded-[10px] bg-[#c8ff00]">
</span>
<div className="ml-3 min-w-0">
<h2 className="truncate text-[15px] leading-4 font-semibold">
{data.latestSignal.title}
</h2>
<p className="text-[10px] text-[#b5b2ae]">
Latest project signal
</p>
</div>
<ChevronRight className="ml-auto size-4" />
</div>
<p className="mt-3 text-[13px] leading-[18px]">
{data.latestSignal.summary}
</p>
<button <button
className="mt-3 h-8 rounded-[11px] bg-black px-3 text-[11px] font-semibold text-white" className="flex items-center gap-2"
onClick={onCreateIssueFromSignal} onClick={() =>
onChange("Summarize my latest project signals")
}
type="button" type="button"
> >
Create work from signal <FileText className="size-4" />
Summarize
</button> </button>
</section> <button
) : null} className="flex items-center gap-2"
</> onClick={() => onChange("Plan the next steps for my project")}
) : ( type="button"
<div className="grid gap-5"> >
{data.assistant.messages.map((message) => <ListChecks className="size-4" />
message.role === "user" ? ( Plan
<div </button>
className="ml-auto max-w-[86%] rounded-[22px] bg-[#0c0c0b] px-4 py-3 text-[16px] leading-6 whitespace-pre-wrap text-white" <button
className="flex items-center gap-2"
onClick={() => onChange("Explore risks in my active work")}
type="button"
>
Explore
</button>
</div>
{data.latestSignal ? (
<section className="mt-12 rounded-[22px] bg-[#f4f4f2] p-[14px]">
<div className="flex items-center">
<span className="flex size-8 items-center justify-center rounded-[10px] bg-[#c8ff00]">
</span>
<div className="ml-3 min-w-0">
<h2 className="truncate text-[15px] leading-4 font-semibold">
{data.latestSignal.title}
</h2>
<p className="text-[10px] text-[#b5b2ae]">
Latest project signal
</p>
</div>
<ChevronRight className="ml-auto size-4" />
</div>
<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}
</>
) : (
<div className="grid gap-5">
{data.assistant.messages.map((message) => (
<Message
className="max-w-full gap-0"
from={message.role}
key={message.id} key={message.id}
> >
{message.text} <MessageContent
</div> className={cn(
) : ( "max-w-full gap-0 overflow-visible p-0",
<div key={message.id}> message.role === "user"
<div className="flex items-center gap-2"> ? "ml-auto w-fit max-w-[86%] rounded-[22px] bg-[#0c0c0b] px-4 py-3 text-[16px] leading-6 text-white group-[.is-user]:rounded-[22px] group-[.is-user]:bg-[#0c0c0b] group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-white"
<span className="flex size-7 items-center justify-center rounded-[9px] bg-[#c8ff00]"> : "w-full bg-transparent text-[#11110f] dark:text-[#11110f]"
<Sparkles className="size-4" /> )}
</span> >
<strong className="text-[14px]">Zopu</strong> {message.role === "assistant" ? (
<span className="rounded-full bg-[#f1f1ef] px-2 py-1 text-[9px] text-[#aaa7a3]"> <>
THINKING PARTNER <div className="flex items-center gap-2">
</span> <span className="flex size-7 items-center justify-center rounded-[9px] bg-[#c8ff00]">
</div> <Sparkles className="size-4" />
<p className="mt-3 text-[16px] leading-[21px] whitespace-pre-wrap"> </span>
{message.text} <strong className="text-[14px]">Zopu</strong>
</p> <span className="rounded-full bg-[#f1f1ef] px-2 py-1 text-[9px] text-[#aaa7a3]">
</div> THINKING PARTNER
) </span>
)} </div>
{data.assistant.isBusy ? ( <MessageResponse
<p className="text-[13px] text-[#807b76]">Zopu is thinking</p> className="mt-3 text-[16px] leading-[21px] text-[#11110f] dark:text-[#11110f]"
) : null} isAnimating={data.assistant.isBusy}
</div> >
)} {message.text}
</section> </MessageResponse>
<form </>
className="absolute inset-x-3 bottom-[34px] flex h-[53px] items-center rounded-[27px] bg-[#f4f4f2] p-2" ) : (
onSubmit={onSubmit} <span className="whitespace-pre-wrap">
> {message.text}
<button </span>
aria-label="Attach file" )}
className="flex size-10 items-center justify-center rounded-full bg-white" </MessageContent>
type="button" </Message>
> ))}
<Paperclip className="size-5" /> {data.assistant.isBusy ? (
</button> <p className="text-[13px] text-[#807b76]">Zopu is thinking</p>
<input ) : null}
className="min-w-0 flex-1 bg-transparent px-2 text-[14px] outline-none placeholder:text-[#b5b2ae]" </div>
onChange={(event) => onChange(event.target.value)} )}
placeholder="Ask anything..." <ChatWorkUnitCard
value={value} onOpen={onOpen}
onReview={onReview}
onStart={onStart}
pendingAction={pendingAction}
workUnit={data.selectedWorkUnit}
/>
</ConversationContent>
<ConversationScrollButton
aria-label="Scroll to latest message"
className="bottom-2 size-9 rounded-full border border-[#dededb] bg-white text-black shadow-sm"
/> />
<button </Conversation>
aria-label="Record voice message" <PromptInput
className="flex size-10 items-center justify-center rounded-full bg-white" className="absolute inset-x-3 bottom-[34px] w-auto [&_[data-slot=input-group]]:min-h-[56px] [&_[data-slot=input-group]]:rounded-full [&_[data-slot=input-group]]:border-0 [&_[data-slot=input-group]]:bg-[#f4f4f2] [&_[data-slot=input-group]]:px-2 [&_[data-slot=input-group]]:py-1.5 [&_[data-slot=input-group]]:shadow-none [&_[data-slot=input-group]]:dark:bg-[#f4f4f2]"
type="button" onSubmit={async (message) => {
> if (onMessageSubmit) {
<Mic className="size-5" /> await onMessageSubmit(message.text);
</button> return;
<button }
aria-label="Send" onChange(message.text);
className="ml-1 flex size-10 items-center justify-center rounded-full bg-black text-white" }}
type="submit" >
> <PromptInputBody>
<ArrowUp className="size-5" /> <PromptInputButton
</button> aria-label="Attach file"
</form> className="size-11 shrink-0 rounded-full bg-white text-black hover:bg-white/80"
size="icon-sm"
>
<Paperclip className="size-5" />
</PromptInputButton>
<PromptInputTextarea
aria-label="Ask anything..."
className="max-h-20 min-h-10 flex-1 resize-none bg-transparent px-2 py-2.5 text-[15px] leading-5 shadow-none outline-none placeholder:text-[#aaa7a3] dark:bg-transparent"
onChange={(event) => onChange(event.currentTarget.value)}
placeholder="Ask anything..."
value={value}
/>
<PromptInputSubmit
aria-label="Send"
className="size-11 shrink-0 rounded-full bg-black text-white hover:bg-black/85 disabled:bg-black disabled:text-white disabled:opacity-100"
disabled={!value.trim() || data.assistant.isBusy}
size="icon-sm"
status={data.assistant.isBusy ? "streaming" : "ready"}
>
<ArrowUp className="size-5" />
</PromptInputSubmit>
</PromptInputBody>
</PromptInput>
<p className="absolute inset-x-0 bottom-[13px] text-center text-[9px] text-[#b5b2ae]"> <p className="absolute inset-x-0 bottom-[13px] text-center text-[9px] text-[#b5b2ae]">
{statusMessage ?? "Press Enter to send · Shift + Enter for a new line"} {statusMessage ?? "Press Enter to send · Shift + Enter for a new line"}
</p> </p>
{projectManagerOpen ? (
<ProjectManagementPanel
createIssueBody={createIssueBody}
createIssueTitle={createIssueTitle}
data={data}
onClose={onProjectManagerClose}
onCreateBodyChange={onCreateBodyChange}
onCreateIssue={onCreateIssue}
onCreateTitleChange={onCreateTitleChange}
onProjectSelect={onProjectSelect}
onRepositoryChange={onRepositoryChange}
onRepositoryConnect={onRepositoryConnect}
pendingAction={pendingAction}
repositoryValue={repositoryValue}
statusMessage={statusMessage}
/>
) : null}
{settingsOpen ? (
<ChatSettingsPanel
modelId={modelId}
modelLabel={modelLabel}
onClose={onSettingsClose}
/>
) : null}
</main> </main>
</div> </div>
); );
@@ -1402,21 +1791,32 @@ export const MobileWorkspaceScreenRenderer = ({
createIssueBody, createIssueBody,
createIssueTitle, createIssueTitle,
data, data,
modelId,
modelLabel,
onBack, onBack,
onComposerChange, onComposerChange,
onComposerMessageSubmit,
onComposerSubmit, onComposerSubmit,
onCreateBodyChange, onCreateBodyChange,
onCreateIssue, onCreateIssue,
onCreateIssueFromSignal, onCreateIssueFromSignal,
onCreateTitleChange, onCreateTitleChange,
onManageProjects, onManageProjects,
onProjectManagerClose,
onOpenAssistant, onOpenAssistant,
onOpenUnit, onOpenUnit,
onProjectSelect, onProjectSelect,
onRepositoryChange,
onRepositoryConnect,
onReviewUnit, onReviewUnit,
onSettingsClose,
onSettingsOpen,
onStartUnit, onStartUnit,
onViewWork, onViewWork,
pendingAction, pendingAction,
projectManagerOpen,
repositoryValue,
settingsOpen,
statusMessage, statusMessage,
variant, variant,
}: MobileWorkspaceRendererProps) => { }: MobileWorkspaceRendererProps) => {
@@ -1432,8 +1832,30 @@ export const MobileWorkspaceScreenRenderer = ({
return ( return (
<CharacterChat <CharacterChat
{...composerProps} {...composerProps}
createIssueBody={createIssueBody}
createIssueTitle={createIssueTitle}
data={data} data={data}
modelId={modelId}
modelLabel={modelLabel}
onCreateBodyChange={onCreateBodyChange}
onCreateIssue={onCreateIssue}
onCreateIssueFromSignal={onCreateIssueFromSignal} onCreateIssueFromSignal={onCreateIssueFromSignal}
onCreateTitleChange={onCreateTitleChange}
onManageProjects={onManageProjects}
onMessageSubmit={onComposerMessageSubmit}
onOpen={onOpenUnit}
onProjectManagerClose={onProjectManagerClose}
onProjectSelect={onProjectSelect}
onRepositoryChange={onRepositoryChange}
onRepositoryConnect={onRepositoryConnect}
onReview={onReviewUnit}
onSettingsClose={onSettingsClose}
onSettingsOpen={onSettingsOpen}
onStart={onStartUnit}
pendingAction={pendingAction}
projectManagerOpen={projectManagerOpen}
repositoryValue={repositoryValue}
settingsOpen={settingsOpen}
/> />
); );
} }

View File

@@ -2,8 +2,10 @@ export type MobileWorkUnitTone = "blue" | "green" | "orange" | "purple";
export interface MobileProjectView { export interface MobileProjectView {
readonly connected: boolean; readonly connected: boolean;
readonly host?: string;
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
readonly repositoryPath?: string;
} }
export interface MobileWorkUnitView { export interface MobileWorkUnitView {