diff --git a/.mailmap b/.mailmap new file mode 100644 index 000000000..2a55461eb --- /dev/null +++ b/.mailmap @@ -0,0 +1,2 @@ +# Map personal work email to GitHub noreply so it is not exposed on public surfaces. +Jason880902 <281131931+Jason880902@users.noreply.github.com> 韩大哥 diff --git a/apps/app/scripts/session-render-state.test.ts b/apps/app/scripts/session-render-state.test.ts index 510108a47..1bff3ede0 100644 --- a/apps/app/scripts/session-render-state.test.ts +++ b/apps/app/scripts/session-render-state.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { UIMessage } from "ai"; -import type { iPolloWorkSessionSnapshot } from "../src/app/lib/ipollowork-server"; +import type { ConversationSnapshot } from "../src/react-app/domains/session/engine/conversation-engine"; import { deriveRenderedSessionMessages, resolveRenderedSessionSnapshot, @@ -9,55 +9,38 @@ import { import { applyRevertCursor, reconcileTranscriptMessages, - resolveForkBoundaryId, } from "../src/react-app/domains/session/sync/transcript-reconcile"; -import { describeOpencodeSessionError } from "../src/react-app/domains/session/sync/usechat-adapter"; +import { resolveOpenCodeForkBoundaryId } from "../src/react-app/domains/session/engine/opencode-conversation-mapper"; +import { describeOpencodeSessionError } from "../src/react-app/domains/session/engine/opencode-message-adapter"; function snapshotWithMessages( messages: Array<{ id: string; role: "user" | "assistant"; text: string; created?: number }>, sessionId = "ses_test", -): iPolloWorkSessionSnapshot { +): ConversationSnapshot { return { session: { id: sessionId, - parentID: undefined, title: "Test session", time: { created: 1, updated: 2 }, - share: undefined, - version: "0", }, - messages: messages.map((message, index) => ({ - info: { - id: message.id, - role: message.role, - sessionID: sessionId, - time: { created: message.created ?? index + 1 }, - }, - parts: [ - { - id: `part_${message.id}`, - type: "text", - text: message.text, - sessionID: sessionId, - messageID: message.id, - }, - ], - })), + messages: messages.map((message, index) => + uiMessage(message.id, message.role, message.text, message.created ?? index + 1), + ), todos: [], status: { type: "idle" }, - } as unknown as iPolloWorkSessionSnapshot; + }; } function uiMessage(id: string, role: "user" | "assistant", text: string, created?: number): UIMessage { return { id, role, - ...(typeof created === "number" ? { metadata: { opencode: { created } } } : {}), + ...(typeof created === "number" ? { metadata: { ipollowork: { created } } } : {}), parts: [{ type: "text", text, state: "done" }], }; } -function snapshotWithText(text: string, sessionId = "ses_test"): iPolloWorkSessionSnapshot { +function snapshotWithText(text: string, sessionId = "ses_test"): ConversationSnapshot { return snapshotWithMessages([{ id: "msg_user", role: "user", text }], sessionId); } @@ -378,7 +361,7 @@ describe("deriveRenderedSessionMessages with revert", () => { { id: "msg_3", role: "user", text: "turn two" }, { id: "msg_4", role: "assistant", text: "answer two" }, ]); - (snapshot.session as { revert?: { messageID: string } }).revert = { messageID: "msg_3" }; + snapshot.session.revertMessageId = "msg_3"; const rendered = deriveRenderedSessionMessages({ transcriptState: [], @@ -389,7 +372,7 @@ describe("deriveRenderedSessionMessages with revert", () => { }); }); -describe("resolveForkBoundaryId", () => { +describe("resolveOpenCodeForkBoundaryId", () => { const transcript = [ uiMessage("msg_1", "user", "turn one"), uiMessage("msg_2", "assistant", "answer one"), @@ -398,16 +381,16 @@ describe("resolveForkBoundaryId", () => { ]; it("returns the next message so the fork includes the branch point", () => { - expect(resolveForkBoundaryId(transcript, "msg_2")).toBe("msg_3"); - expect(resolveForkBoundaryId(transcript, "msg_3")).toBe("msg_4"); + expect(resolveOpenCodeForkBoundaryId(transcript, "msg_2")).toBe("msg_3"); + expect(resolveOpenCodeForkBoundaryId(transcript, "msg_3")).toBe("msg_4"); }); it("returns null when branching at the last message (fork everything)", () => { - expect(resolveForkBoundaryId(transcript, "msg_4")).toBeNull(); + expect(resolveOpenCodeForkBoundaryId(transcript, "msg_4")).toBeNull(); }); it("returns null for unknown ids instead of corrupting the boundary", () => { - expect(resolveForkBoundaryId(transcript, "msg_missing")).toBeNull(); + expect(resolveOpenCodeForkBoundaryId(transcript, "msg_missing")).toBeNull(); }); it("skips synthetic session-error messages when picking the boundary", () => { @@ -416,8 +399,8 @@ describe("resolveForkBoundaryId", () => { uiMessage("session-error:msg_2", "assistant", "boom"), ...transcript.slice(2), ]; - expect(resolveForkBoundaryId(withSynthetic, "msg_2")).toBe("msg_3"); + expect(resolveOpenCodeForkBoundaryId(withSynthetic, "msg_2")).toBe("msg_3"); // Branching at the synthetic message itself falls through to the next real message. - expect(resolveForkBoundaryId(withSynthetic, "session-error:msg_2")).toBe("msg_3"); + expect(resolveOpenCodeForkBoundaryId(withSynthetic, "session-error:msg_2")).toBe("msg_3"); }); }); diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index e5ef45eeb..6a5e366f7 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -40,6 +40,12 @@ import type { WorkspaceList, } from "./desktop-types"; import type { BrowserPanelTab } from "./desktop-types"; +import type { + ScheduledTask, + ScheduledTaskCreateInput, + ScheduledTaskLogEntry, + ScheduledTaskUpdatePatch, +} from "@/react-app/domains/session/scheduled-tasks/scheduled-task"; export const LOCAL_IMAGE_FILE_EXTENSIONS = ["avif", "bmp", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"]; export const LOCAL_IMAGE_FILE_FILTERS = [{ name: "图片文件", extensions: LOCAL_IMAGE_FILE_EXTENSIONS }]; @@ -53,6 +59,17 @@ export type BrowserProxyState = { proxy: { rules: string; authenticated: boolean } | null; }; +export type LanPreviewState = { + enabled: boolean; + port: number; + addresses: string[]; + code: string | null; + codeExpiresAt: number; + sessionCount: number; + pendingChallengeCount?: number; + error?: string; +}; + // --------------------------------------------------------------------------- // Electron bridge surface // --------------------------------------------------------------------------- @@ -154,13 +171,51 @@ declare global { onPanelClosed?: (callback: () => void) => () => void; }; terminal?: { - create?: (options: { cwd: string; cols: number; rows: number }) => Promise<{ terminalId: string }>; + create?: (options: { + cwd: string; + cols: number; + rows: number; + /** Optional argv for the shell. When present the shell runs this + command instead of opening an interactive prompt, e.g. + `["ssh", "user@host"]` for the ops panel. */ + command?: string[]; + /** Optional explicit shell/executable path. */ + shell?: string; + }) => Promise<{ terminalId: string }>; write?: (terminalId: string, data: string) => Promise; resize?: (terminalId: string, cols: number, rows: number) => Promise; kill?: (terminalId: string) => Promise; onData?: (callback: (payload: { terminalId: string; data: string }) => void) => () => void; onExit?: (callback: (payload: { terminalId: string; exitCode: number | null; signal?: number }) => void) => () => void; }; + ssh?: { + listHosts?: () => Promise<{ hosts: string[]; configPath: string }>; + }; + git?: { + graph?: (options: { cwd: string; maxCommits?: number }) => Promise< + | { ok: true; repoRoot: string; count: number; totalCount: number | null; truncated: boolean; isRepo: true; commits: { sha: string; parents: string[] }[]; refs: { sha: string; refname: string; head: boolean }[]; headShas: string[] } + | { ok: false; isRepo: boolean; error: string } + >; + }; + scheduledTasks?: { + list?: () => Promise; + create?: (input: ScheduledTaskCreateInput) => Promise; + update?: (id: string, patch: ScheduledTaskUpdatePatch) => Promise; + setEnabled?: (id: string, enabled: boolean) => Promise; + remove?: (id: string) => Promise; + runNow?: (id: string) => Promise; + logs?: (id: string) => Promise; + preview?: (cron: string) => Promise<{ valid: boolean; nextRunAt: number | null }>; + onChanged?: (callback: (payload: { type: string; taskId?: string }) => void) => () => void; + }; + lanPreview?: { + getState?: () => Promise; + setEnabled?: (enabled: boolean) => Promise; + regenerateCode?: () => Promise; + disconnectAll?: () => Promise; + pushToIm?: (options: { mcpUrl: string }) => Promise<{ ok: boolean; tool?: string; error?: string }>; + onStateChanged?: (callback: (state: LanPreviewState) => void) => () => void; + }; hyperframes?: { start?: (options: { workspaceRoot: string; sessionId: string; projectDirectory: string; port: number }) => Promise<{ ok: boolean; port?: number; reused?: boolean }>; stop?: (sessionId: string, options?: { keepWarm?: boolean }) => Promise<{ ok: boolean }>; @@ -576,6 +631,9 @@ const { updaterEnvironment, readOpencodeConfig, writeOpencodeConfig, + routerGatewayGetConfig, + routerGatewayWriteConfig, + routerGatewayStatus, resetiPolloWorkState, resetOpencodeCache, opencodeMcpAuth, @@ -644,6 +702,9 @@ export { updaterEnvironment, readOpencodeConfig, writeOpencodeConfig, + routerGatewayGetConfig, + routerGatewayWriteConfig, + routerGatewayStatus, resetiPolloWorkState, resetOpencodeCache, opencodeMcpAuth, diff --git a/apps/app/src/app/lib/ipollowork-server.ts b/apps/app/src/app/lib/ipollowork-server.ts index e434a0ebe..87a18c5a6 100644 --- a/apps/app/src/app/lib/ipollowork-server.ts +++ b/apps/app/src/app/lib/ipollowork-server.ts @@ -696,27 +696,6 @@ export type iPolloWorkReloadEvent = { timestamp: number; }; -export type iPolloWorkSessionGroupDefinition = { - id: string; - label: string; -}; - -export type iPolloWorkSessionGroupState = { - groups: iPolloWorkSessionGroupDefinition[]; - assignments: Record; -}; - -export type iPolloWorkSessionGroupEvent = { - id: string; - seq: number; - workspaceId: string; - type: "session_groups.updated"; - action: "created" | "updated" | "deleted" | "assigned" | "reordered" | "imported"; - groupId?: string; - sessionId?: string; - timestamp: number; -}; - // Fallback for explicit server-mode URL derivation. Desktop local workers replace this // with the persisted runtime-discovered port once the host reports it. export const DEFAULT_IPOLLOWORK_SERVER_PORT = 8787; @@ -1416,56 +1395,6 @@ export function createiPolloWorkServerClient(options: { baseUrl: string; token?: { token, hostToken, timeoutMs: timeouts.sessionRead }, ); }, - getSessionGroups: (workspaceId: string) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number | null }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups`, - { token, hostToken, timeoutMs: timeouts.sessionRead }, - ), - putSessionGroups: (workspaceId: string, state: iPolloWorkSessionGroupState) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups`, - { token, hostToken, method: "PUT", body: { state }, timeoutMs: timeouts.config }, - ), - createSessionGroup: (workspaceId: string, input: { id?: string; label: string }) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups`, - { token, hostToken, method: "POST", body: input, timeoutMs: timeouts.config }, - ), - reorderSessionGroups: (workspaceId: string, groupIds: string[]) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups/reorder`, - { token, hostToken, method: "PATCH", body: { groupIds }, timeoutMs: timeouts.config }, - ), - assignSessionGroup: (workspaceId: string, sessionId: string, groupId: string | null) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups/assignments/${encodeURIComponent(sessionId)}`, - { token, hostToken, method: "PATCH", body: { groupId }, timeoutMs: timeouts.config }, - ), - renameSessionGroup: (workspaceId: string, groupId: string, label: string) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups/${encodeURIComponent(groupId)}`, - { token, hostToken, method: "PATCH", body: { label }, timeoutMs: timeouts.config }, - ), - removeSessionGroup: (workspaceId: string, groupId: string) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups/${encodeURIComponent(groupId)}`, - { token, hostToken, method: "DELETE", timeoutMs: timeouts.config }, - ), - listSessionGroupEvents: (workspaceId: string, options?: { since?: number }) => { - const query = typeof options?.since === "number" ? `?since=${options.since}` : ""; - return requestJson<{ items: iPolloWorkSessionGroupEvent[]; cursor?: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups/events${query}`, - { token, hostToken }, - ); - }, getSession: (workspaceId: string, sessionId: string) => requestJson<{ item: Session }>( baseUrl, diff --git a/apps/app/src/app/lib/opencode-session.ts b/apps/app/src/app/lib/opencode-session.ts deleted file mode 100644 index c5abff018..000000000 --- a/apps/app/src/app/lib/opencode-session.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Typed helpers for OpenCode session operations. - * - * The OpenCode SDK (v2) exposes `session.abort`, `session.revert`, - * `session.unrevert`, `session.shell`, and `command.list` as typed methods. - * This module provides thin wrappers that avoid `as any` casts by using the - * SDK types directly, and adds feature-detection for newer API surface - * (e.g. `shellAsync`) that may not be present in older SDK versions. - */ -import type { Session } from "@opencode-ai/sdk/v2/client"; -import type { Client, ModelRef } from "../types"; -import { unwrap } from "./opencode"; - -// --------------------------------------------------------------------------- -// Session helpers -// --------------------------------------------------------------------------- - -/** - * Abort an active session. Resolves to the server's answer: `true` when a - * live run was aborted, `false` when nothing matched (already idle, or the - * session lives in a different project/directory scope). Callers that show - * "stopped" feedback must check the result — a `200: false` response is NOT - * a successful stop (#2014). - * - * Pass the workspace `directory` whenever the prompt was sent through a - * directory-scoped client; omitting it can resolve the abort against the - * server's default project where no matching run exists. - */ -export async function abortSession( - client: Client, - sessionID: string, - directory?: string, -): Promise { - return unwrap(await client.session.abort({ sessionID, directory })) === true; -} - -/** - * Abort an active session, swallowing transport errors (useful before - * revert/undo). Returns `false` when the abort failed or nothing was - * aborted, so callers can avoid declaring success on a no-op. - */ -export async function abortSessionSafe( - client: Client, - sessionID: string, - directory?: string, -): Promise { - try { - return await abortSession(client, sessionID, directory); - } catch { - // The session may already be idle or the server unreachable; callers - // treat `false` as "nothing was aborted". - return false; - } -} - -/** - * Revert a session to a specific message boundary. - */ -export async function revertSession( - client: Client, - sessionID: string, - messageID: string, -): Promise { - return unwrap(await client.session.revert({ sessionID, messageID })) as Session; -} - -/** - * Fork a session at a specific message, creating a new session branch. - */ -export async function forkSession( - client: Client, - sessionID: string, - messageID?: string, -): Promise { - return unwrap(await client.session.fork({ sessionID, messageID })) as Session; -} - -/** - * Restore all previously reverted messages in a session. - */ -export async function unrevertSession( - client: Client, - sessionID: string, -): Promise { - return unwrap(await client.session.unrevert({ sessionID })) as Session; -} - -/** - * Archive or unarchive a session via OpenCode's native `time.archived` field. - * - * Passing `archived: true` stamps the current time; `false` clears it (sends - * `0`, which the server treats as "not archived"). Archiving is a non-destructive - * primitive — the session and its context are preserved, which matters for orgs - * with audit/retention policies that forbid deletion. - */ -export async function setSessionArchived( - client: Client, - sessionID: string, - archived: boolean, - directory?: string, -): Promise { - unwrap( - await client.session.update({ - sessionID, - directory, - time: { archived: archived ? Date.now() : 0 }, - }), - ); -} - -/** - * Compact/summarize a long session to reduce context size. - * Uses `session.summarize` when available and falls back to `/compact` command. - */ -export async function compactSession( - client: Client, - sessionID: string, - model: ModelRef, - options?: { directory?: string }, -): Promise { - const session = client.session as { summarize?: (input: { - sessionID: string; - directory?: string; - providerID: string; - modelID: string; - }) => Promise }; - - if (typeof session.summarize === "function") { - const result = await session.summarize({ - sessionID, - directory: options?.directory, - providerID: model.providerID, - modelID: model.modelID, - }); - assertNoClientError(result); - return; - } - - const modelString = `${model.providerID}/${model.modelID}`; - const result = await client.session.command({ - sessionID, - command: "compact", - arguments: "", - model: modelString, - directory: options?.directory, - }); - assertNoClientError(result); -} - -// --------------------------------------------------------------------------- -// Shell execution -// --------------------------------------------------------------------------- - -/** - * Execute a shell command in a session. Uses `shell` from the SDK. - * Falls back to `promptAsync` with a `!` prefix if `shell` is unavailable. - */ -export async function shellInSession( - client: Client, - sessionID: string, - command: string, - options?: { model?: { providerID: string; modelID: string }; agent?: string; variant?: string }, -): Promise { - const result = await client.session.shell({ sessionID, command }); - assertNoClientError(result); -} - -// --------------------------------------------------------------------------- -// Command listing -// --------------------------------------------------------------------------- - -export type CommandListItem = { - id: string; - name: string; - description?: string; - source?: "command" | "mcp" | "skill"; -}; - -/** - * List available slash commands for a workspace. - */ -export async function listCommands( - client: Client, - directory?: string, -): Promise { - try { - const result = await client.command.list({ directory }); - const list = result?.data ?? []; - if (!Array.isArray(list)) return []; - return list.map((cmd: Record) => ({ - id: `cmd:${cmd.name}`, - name: String(cmd.name ?? ""), - description: cmd.description ? String(cmd.description) : undefined, - source: cmd.source as CommandListItem["source"], - })); - } catch { - return []; - } -} - -// --------------------------------------------------------------------------- -// Internal -// --------------------------------------------------------------------------- - -function assertNoClientError(result: unknown): void { - const maybe = result as { error?: unknown } | null | undefined; - if (!maybe || maybe.error === undefined) return; - const message = - maybe.error instanceof Error - ? maybe.error.message - : typeof maybe.error === "string" - ? maybe.error - : JSON.stringify(maybe.error); - throw new Error(message || "Unknown error"); -} diff --git a/apps/app/src/app/lib/opencode.ts b/apps/app/src/app/lib/opencode.ts index d1142561e..bfb8517bf 100644 --- a/apps/app/src/app/lib/opencode.ts +++ b/apps/app/src/app/lib/opencode.ts @@ -189,32 +189,6 @@ async function wrapiPolloWorkRead( } } -function shouldFallbackToLegacySessionRead(error: unknown): boolean { - if (!(error instanceof iPolloWorkServerError)) return false; - return error.status === 404 || error.status === 405 || error.status === 501; -} - -async function wrapiPolloWorkReadWithFallback( - url: string, - read: () => Promise, - fallback: () => Promise>, - options?: { throwOnError?: boolean }, -): Promise> { - try { - return createSyntheticResult(url, "GET", { ok: true, data: await read() }); - } catch (error) { - if (!shouldFallbackToLegacySessionRead(error)) { - if (options?.throwOnError) throw error; - return createSyntheticResult(url, "GET", { - ok: false, - error, - status: error instanceof iPolloWorkServerError ? error.status : 500, - }); - } - return fallback(); - } -} - async function fetchWithTimeout( fetchImpl: typeof globalThis.fetch, input: RequestInfo | URL, @@ -376,8 +350,6 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod ipolloworkMount && auth?.token ? createiPolloWorkServerClient({ baseUrl: ipolloworkMount.baseUrl, token: auth.token }) : null; - // TODO(2026-04-12): remove the old-server compatibility path here once all - // iPolloWork servers expose the workspace-scoped session read APIs. const sessionOverrides = session as any as { list: (parameters?: SessionListParameters, options?: { throwOnError?: boolean }) => Promise>; get: (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => Promise>; @@ -398,10 +370,9 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod if (parameters?.search?.trim()) query.set("search", parameters.search.trim()); if (typeof parameters?.limit === "number") query.set("limit", String(parameters.limit)); const url = `${ipolloworkMount.baseUrl}/workspace/${encodeURIComponent(ipolloworkMount.workspaceId)}/sessions${query.size ? `?${query.toString()}` : ""}`; - return wrapiPolloWorkReadWithFallback( + return wrapiPolloWorkRead( url, async () => (await ipolloworkSessionClient.listSessions(ipolloworkMount.workspaceId, parameters)).items, - () => listOriginal(parameters, options), options, ); }; @@ -412,10 +383,9 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod return getOriginal(parameters, options); } const url = `${ipolloworkMount.baseUrl}/workspace/${encodeURIComponent(ipolloworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}`; - return wrapiPolloWorkReadWithFallback( + return wrapiPolloWorkRead( url, async () => (await ipolloworkSessionClient.getSession(ipolloworkMount.workspaceId, parameters.sessionID)).item, - () => getOriginal(parameters, options), options, ); }; @@ -428,13 +398,12 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod const query = new URLSearchParams(); if (typeof parameters.limit === "number") query.set("limit", String(parameters.limit)); const url = `${ipolloworkMount.baseUrl}/workspace/${encodeURIComponent(ipolloworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}/messages${query.size ? `?${query.toString()}` : ""}`; - return wrapiPolloWorkReadWithFallback( + return wrapiPolloWorkRead( url, async () => (await ipolloworkSessionClient.getSessionMessages(ipolloworkMount.workspaceId, parameters.sessionID, { limit: parameters.limit, })).items, - () => messagesOriginal(parameters, options), options, ); }; @@ -445,10 +414,9 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod return todoOriginal(parameters, options); } const url = `${ipolloworkMount.baseUrl}/workspace/${encodeURIComponent(ipolloworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}/snapshot`; - return wrapiPolloWorkReadWithFallback( + return wrapiPolloWorkRead( url, async () => (await ipolloworkSessionClient.getSessionSnapshot(ipolloworkMount.workspaceId, parameters.sessionID)).item.todos, - () => todoOriginal(parameters, options), options, ); }; diff --git a/apps/app/src/app/lib/work-context.ts b/apps/app/src/app/lib/work-context.ts index d68104ce0..f66430b98 100644 --- a/apps/app/src/app/lib/work-context.ts +++ b/apps/app/src/app/lib/work-context.ts @@ -14,7 +14,6 @@ import { } from "./enterprise-connections"; import { createiPolloWorkServerClient, - type iPolloWorkServerClient, } from "./ipollowork-server"; import { isDesktopRuntime } from "./runtime-env"; @@ -22,6 +21,7 @@ export const PERSONAL_WORK_CONTEXT_ID = "personal" as const; export type WorkContextId = typeof PERSONAL_WORK_CONTEXT_ID | `enterprise:${string}`; const ACTIVE_WORK_CONTEXT_KEY = "ipollowork.work-context.v1"; +const LAST_PROJECT_BY_CONTEXT_KEY = "ipollowork.work-context-projects.v1"; const LEGACY_ACTIVE_ENTERPRISE_KEY = "ipollowork.enterprise-active.v1"; const LEGACY_WORK_CONTEXT_STORAGE_KEYS = [ "ipollowork.work-context-workspaces.v1", @@ -124,70 +124,33 @@ export function filterWorkspacesForWorkContext workspaceBelongsToWorkContext(workspace, contextId)); } -function isLegacyWorkstationPath(workspacePath: string | null | undefined) { - return /(?:^|[/\\])\.ipollowork[/\\]workstations[/\\]/i.test(workspacePath?.trim() ?? ""); -} - -export function canonicalWorkspaceForWorkContext< - T extends Pick, ->( - workspaces: T[], - contextId: WorkContextId, - preferredIds: Array = [], -): T | null { - const candidates = filterWorkspacesForWorkContext(workspaces, contextId); - if (candidates.length === 0) return null; - - const contextCandidates = contextId === PERSONAL_WORK_CONTEXT_ID - ? candidates.some((workspace) => !isLegacyWorkstationPath(workspace.path)) - ? candidates.filter((workspace) => !isLegacyWorkstationPath(workspace.path)) - : candidates - : (() => { - const enterpriseId = contextId.slice("enterprise:".length); - const contextPathPattern = new RegExp(`(?:^|[/\\\\])\\.ipollowork[/\\\\]work-contexts[/\\\\]${enterpriseId}(?:[/\\\\]|$)`, "i"); - const dedicated = candidates.filter((workspace) => contextPathPattern.test(workspace.path?.trim() ?? "")); - return dedicated.length > 0 ? dedicated : candidates; - })(); - - for (const preferredId of preferredIds) { - const normalized = preferredId?.trim() ?? ""; - if (!normalized) continue; - const match = contextCandidates.find((workspace) => workspace.id === normalized); - if (match) return match; +function readLastProjectMap(): Record { + if (typeof window === "undefined") return {}; + try { + const parsed: unknown = JSON.parse(window.localStorage.getItem(LAST_PROJECT_BY_CONTEXT_KEY) ?? "{}"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const projects: Record = {}; + for (const [contextId, projectId] of Object.entries(parsed)) { + if (!normalizeWorkContextId(contextId) || typeof projectId !== "string" || !projectId.trim()) continue; + projects[contextId] = projectId.trim(); + } + return projects; + } catch { + return {}; } - - return contextCandidates.find((workspace) => workspace.workspaceType !== "remote") - ?? contextCandidates[0] - ?? null; } -export function canonicalWorkspacesForWorkContext< - T extends Pick, ->( - workspaces: T[], - contextId: WorkContextId, - preferredIds: Array = [], -): T[] { - const workspace = canonicalWorkspaceForWorkContext(workspaces, contextId, preferredIds); - return workspace ? [workspace] : []; +export function readLastProjectForWorkContext(contextId: WorkContextId): string | null { + return readLastProjectMap()[contextId] ?? null; } -export async function pruneServerWorkspacesForWorkContext( - client: iPolloWorkServerClient, - workspaces: Array>, - contextId: WorkContextId, - canonicalWorkspaceId: string, -): Promise { - const staleWorkspaceIds = filterWorkspacesForWorkContext(workspaces, contextId) - .map((workspace) => workspace.id) - .filter((workspaceId) => workspaceId !== canonicalWorkspaceId); - - const removed: string[] = []; - for (const workspaceId of staleWorkspaceIds) { - const result = await client.deleteWorkspace(workspaceId).catch(() => null); - if (result?.deleted) removed.push(workspaceId); - } - return removed; +export function rememberProjectForWorkContext(contextId: WorkContextId, projectId: string | null): void { + if (typeof window === "undefined") return; + const projects = readLastProjectMap(); + const normalized = projectId?.trim() ?? ""; + if (normalized) projects[contextId] = normalized; + else delete projects[contextId]; + window.localStorage.setItem(LAST_PROJECT_BY_CONTEXT_KEY, JSON.stringify(projects)); } function dispatchSwitch(phase: WorkContextSwitchDetail["phase"], contextId: WorkContextId) { @@ -242,15 +205,6 @@ async function activateWorkspaceEverywhere(workspace: WorkspaceInfo) { serverWorkspace = created.workspaces.find((entry) => entry.id === workspace.id || entry.path === workspace.path) ?? null; } if (!serverWorkspace) throw new Error("work_context_workspace_unavailable"); - const contextId = normalizeWorkContextId(workspace.workContextId) ?? PERSONAL_WORK_CONTEXT_ID; - const canonicalDisplayName = contextId === PERSONAL_WORK_CONTEXT_ID - ? "Personal" - : workspace.displayName?.trim() || workspace.name; - if ((serverWorkspace.displayName?.trim() || serverWorkspace.name) !== canonicalDisplayName) { - const renamed = await client.updateWorkspaceDisplayName(serverWorkspace.id, canonicalDisplayName); - serverWorkspace = renamed.workspaces.find((entry) => entry.id === serverWorkspace?.id) ?? serverWorkspace; - } - await pruneServerWorkspacesForWorkContext(client, serverWorkspaces, contextId, serverWorkspace.id); await client.activateWorkspace(serverWorkspace.id, { persist: true }); await workspaceSetSelected(workspace.id); await workspaceSetRuntimeActive(workspace.id); @@ -268,12 +222,16 @@ export async function activatePersonalWorkContext(): Promise { return null; } const state = await workspaceBootstrap(); - const workspace = canonicalWorkspaceForWorkContext(state.workspaces, contextId, [ - state.selectedId, - state.activeId, - ]); + const projects = filterWorkspacesForWorkContext(state.workspaces, contextId); + const rememberedProjectId = readLastProjectForWorkContext(contextId); + const workspace = projects.find((project) => project.id === rememberedProjectId) + ?? projects.find((project) => project.id === state.selectedId) + ?? projects.find((project) => project.id === state.activeId) + ?? projects[0] + ?? null; if (!workspace) throw new Error("personal_workspace_unavailable"); const workspaceId = await activateWorkspaceEverywhere(workspace); + rememberProjectForWorkContext(contextId, workspaceId); commitActiveContext(contextId); committed = true; return workspaceId; @@ -293,10 +251,13 @@ export async function activateEnterpriseWorkContext(connection: EnterpriseConnec return null; } const state = await workspaceBootstrap(); - let workspace = canonicalWorkspaceForWorkContext(state.workspaces, contextId, [ - state.selectedId, - state.activeId, - ]); + const rememberedProjectId = readLastProjectForWorkContext(contextId); + const projects = filterWorkspacesForWorkContext(state.workspaces, contextId); + let workspace: WorkspaceInfo | null = projects.find((project) => project.id === rememberedProjectId) + ?? projects.find((project) => project.id === state.selectedId) + ?? projects.find((project) => project.id === state.activeId) + ?? projects[0] + ?? null; if (!workspace) { const homeDir = await getDesktopHomeDir(); const folderPath = await joinDesktopPath(homeDir, ".ipollowork", "work-contexts", connection.id); @@ -312,6 +273,7 @@ export async function activateEnterpriseWorkContext(connection: EnterpriseConnec } if (!workspace) throw new Error("enterprise_workspace_unavailable"); const workspaceId = await activateWorkspaceEverywhere(workspace); + rememberProjectForWorkContext(contextId, workspaceId); commitActiveContext(contextId); committed = true; return workspaceId; diff --git a/apps/app/src/app/types.ts b/apps/app/src/app/types.ts index 70a2e45e6..5a0bc4c20 100644 --- a/apps/app/src/app/types.ts +++ b/apps/app/src/app/types.ts @@ -1,10 +1,6 @@ import type { Message, Part, - PermissionRequest as ApiPermissionRequest, - PermissionV2Request, - QuestionRequest, - ProviderListResponse, Session, } from "@opencode-ai/sdk/v2/client"; import type { createClient } from "./lib/opencode"; @@ -12,7 +8,32 @@ import type { OpencodeConfigFile, WorkspaceInfo } from "./lib/desktop-types"; export type Client = ReturnType; -export type ProviderListItem = ProviderListResponse["all"][number]; +export type ProviderModel = { + id: string; + name: string; + capabilities: { + attachment?: boolean; + reasoning?: boolean; + input?: { + image?: boolean; + }; + }; + variants?: Record>; +}; + +export type ProviderListItem = { + id: string; + name: string; + source: "env" | "config" | "custom" | "api"; + env: string[]; + models: Record; +}; + +export type ProviderListResponse = { + all: ProviderListItem[]; + connected: string[]; + default: Record; +}; export type SidebarSessionItem = { id: string; @@ -30,7 +51,7 @@ export type SidebarSessionItem = { directory?: string | null; }; -export type WorkspaceSessionGroup = { +export type ProjectSessionList = { workspace: WorkspaceInfo; sessions: SidebarSessionItem[]; status: "idle" | "loading" | "ready" | "error"; @@ -193,6 +214,7 @@ export const SETTINGS_TAB_VALUES = [ "updates", "recovery", "debug", + "remote-preview", ] as const; export type SettingsTab = (typeof SETTINGS_TAB_VALUES)[number]; @@ -381,17 +403,6 @@ export type ReloadTrigger = { path?: string; }; -export type PendingPermission = Omit & { - always: unknown; - receivedAt: number; - protocol: "legacy" | "v2"; - v2?: Pick; -}; - -export type PendingQuestion = QuestionRequest & { - receivedAt: number; -}; - export type TodoItem = { id: string; content: string; diff --git a/apps/app/src/app/utils/providers.ts b/apps/app/src/app/utils/providers.ts index 36712b8c0..2d340f2e8 100644 --- a/apps/app/src/app/utils/providers.ts +++ b/apps/app/src/app/utils/providers.ts @@ -1,4 +1,4 @@ -import type { ProviderListResponse } from "@opencode-ai/sdk/v2/client"; +import type { ProviderListResponse } from "../types"; const PINNED_PROVIDER_ORDER = ["opencode", "openai", "anthropic"] as const; diff --git a/apps/app/src/components/model-select.tsx b/apps/app/src/components/model-select.tsx index f4fd7b623..eb1bf512e 100644 --- a/apps/app/src/components/model-select.tsx +++ b/apps/app/src/components/model-select.tsx @@ -44,11 +44,12 @@ function getProviderDisplayName(providerId: string) { } function useModelOptions(open: boolean) { - const { client, opencodeBaseUrl, selectedWorkspaceRoot } = useWorkspace(); + const { client, engineId, opencodeBaseUrl, selectedWorkspaceRoot } = useWorkspace(); const checkDesktopRestriction = useCheckDesktopRestriction(); const { data, refetch } = useProviderListQuery({ client, + engineId, baseUrl: opencodeBaseUrl, directory: selectedWorkspaceRoot, enabled: Boolean(client), diff --git a/apps/app/src/i18n/locales/ca.ts b/apps/app/src/i18n/locales/ca.ts index 9aae7c791..1b8e588dc 100644 --- a/apps/app/src/i18n/locales/ca.ts +++ b/apps/app/src/i18n/locales/ca.ts @@ -114,7 +114,6 @@ export default { "composer.agent_label": "Agent", "composer.any_file_type_supported": "S'admet qualsevol tipus de fitxer.", "composer.attach_files": "Adjuntar fitxers", - "composer.attachments_unavailable": "Els fitxers adjunts no estan disponibles.", "composer.behavior_label": "Comportament", "composer.configure": "Configura", "composer.default_agent": "Agent per defecte", diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 65874f7ba..8d9cf2efc 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -1,8 +1,8 @@ -/** - * English translations - * Professional terms (Skills, Plugins, Commands, Sessions, OpenCode, OpenPackage, iPolloWork) are NOT translated - */ - +/** + * English translations + * Professional terms (Skills, Plugins, Commands, Sessions, OpenCode, OpenPackage, iPolloWork) are NOT translated + */ + export default { "action.remove": "Remove", "app.compact_command_desc": "Summarize this session to reduce context size.", @@ -752,6 +752,7 @@ export default { "find.no_matches": "No matches", "find.previous_match": "Previous match", "find.previous_match_hint": "Previous match (⇧↵)", + "git.title": "Git", "help.all_categories": "All categories", "help.back": "Back to Work", "help.clear_search": "Clear search", @@ -855,7 +856,7 @@ export default { "mcp.enable_app": "Enable", "mcp.env_modal_cancel": "Cancel", "mcp.env_modal_desc": "These credentials are stored in this device's encrypted env store and never uploaded. Connection continues automatically after saving.", -"mcp.env_modal_open_console": "Open platform console (create app)", + "mcp.env_modal_open_console": "Open platform console (create app)", "mcp.env_modal_placeholder": "Paste the value", "mcp.env_modal_save": "Save & connect", "mcp.env_modal_save_failed": "Save failed. Check the values and try again.", @@ -878,7 +879,7 @@ export default { "mcp.logout_modal_title": "Log out of this app?", "mcp.logout_success": "Logged out of {server}.", "mcp.logout_working": "Logging out...", -"mcp.missing_env": "Set these environment variables first: {vars}. Add them in Settings → Environment Variables, then retry.", + "mcp.missing_env": "Set these environment variables first: {vars}. Add them in Settings → Environment Variables, then retry.", "mcp.name_required": "Enter a server name.", "mcp.no_apps_hint": "Connect one above to get started.", "mcp.no_apps_yet": "No apps connected yet", @@ -1208,6 +1209,7 @@ export default { "notifications.view": "View", "onboarding.authorize_folder": "Authorize folder", "onboarding.installing_resources": "Installing required resources\u2026", + "ops.title": "Ops", "plugin_library.add": "Add", "plugin_library.category.ai-agents": "AI Agents & Automation", "plugin_library.category.business-operations": "Business & Operations", @@ -1334,6 +1336,23 @@ export default { "plugins.suggested": "Suggested plugins", "plugins.suggested_heading": "Suggested plugins", "plugins.title": "OpenCode plugins", + "projects.actions": "Project actions", + "projects.choose_folder": "Choose project folder", + "projects.create": "New project", + "projects.create_description": "Choose an independent folder for this project. Its conversations, files, and configuration stay isolated.", + "projects.create_failed": "Project creation failed.", + "projects.keep_one": "Personal and organization spaces must keep at least one project.", + "projects.name_and_folder_required": "Enter a project name and choose a project folder.", + "projects.name_placeholder": "Project name", + "projects.new_conversation": "New conversation in {project}", + "projects.remove": "Remove project", + "projects.remove_description": "The project will be removed from iPolloWork. Local files will not be deleted.", + "projects.remove_title": "Remove this project?", + "projects.removing": "Removing…", + "projects.rename": "Rename project", + "projects.server_unavailable": "The project service is unavailable. Reconnect and try again.", + "projects.show_in_folder": "Show in folder", + "projects.title": "Projects", "prompt_templates.error_save": "Could not save this prompt template.", "prompt_templates.save_task": "Save as template", "prompt_templates.toast_saved": "Saved template {title}", @@ -1375,6 +1394,7 @@ export default { "remote_workspace.optional_placeholder": "Optional", "remote_workspace.worker_url": "Worker URL", "remote_workspace.worker_url_hint": "Paste the iPolloWork worker URL you want to connect to.", + "scheduled_tasks.title": "Scheduled Tasks", "session.add_provider_hint": "Add a provider to run tasks.", "session.allow_for_session": "Always allow", "session.allow_once": "Allow once", @@ -2183,6 +2203,35 @@ export default { "settings.recovery_reset_config_unavailable": "Reset config is not yet available.", "settings.recovery_section_desc": "Roll back state without quitting iPolloWork.", "settings.recovery_section_title": "Reset & recovery", + "settings.remote_preview_address_desc": "Open this address in a phone or tablet browser on the same network.", + "settings.remote_preview_address_title": "Access address", + "settings.remote_preview_alert_desc": "Lets phones and tablets on your local network view the current workbench state through a browser. Read-only: devices can watch, not control.", + "settings.remote_preview_alert_title": "Remote preview (LAN read-only)", + "settings.remote_preview_code_desc": "Valid for 10 minutes, single use", + "settings.remote_preview_code_hint": "Enter this code on the device to pair it with this workbench.", + "settings.remote_preview_code_title": "Pair code", + "settings.remote_preview_copy": "Copy", + "settings.remote_preview_desktop_desc": "Remote preview is only available in the desktop app.", + "settings.remote_preview_desktop_title": "Desktop app required", + "settings.remote_preview_disconnect_all": "Disconnect all", + "settings.remote_preview_enable_desc": "Defaults to off. Turning it on exposes a read-only snapshot to your local network.", + "settings.remote_preview_enable_title": "Enable LAN preview", + "settings.remote_preview_error_title": "Could not start remote preview", + "settings.remote_preview_im_desc": "Send the current workbench status to a DingTalk/Feishu group via its MCP endpoint.", + "settings.remote_preview_im_endpoint_label": "DingTalk MCP endpoint URL", + "settings.remote_preview_im_endpoint_placeholder": "https://… DingTalk MCP endpoint (dws mcp url get )", + "settings.remote_preview_im_failed": "Could not send the preview summary.", + "settings.remote_preview_im_hint": "Get the endpoint from `dws mcp url get `. Only a redacted status summary is sent; no session tokens.", + "settings.remote_preview_im_need_endpoint": "Enter a DingTalk MCP endpoint first.", + "settings.remote_preview_im_push": "Push summary", + "settings.remote_preview_im_sent": "Preview summary sent.", + "settings.remote_preview_im_title": "Push preview to IM", + "settings.remote_preview_im_unavailable": "IM push is only available in the desktop app.", + "settings.remote_preview_no_address": "No LAN addresses found.", + "settings.remote_preview_regenerate": "Regenerate", + "settings.remote_preview_sessions_connected": "devices connected", + "settings.remote_preview_sessions_none": "No devices paired.", + "settings.remote_preview_sessions_title": "Paired devices", "settings.removing_containers": "Removing containers...", "settings.removing_local_state": "Removing local state...", "settings.repair_cache": "Repair cache", @@ -2223,6 +2272,32 @@ export default { "settings.restarting": "Restarting…", "settings.reveal_config": "Show file", "settings.reveal_config_requires_desktop": "Only available in the desktop app", + "settings.router_gateway.add_model": "Add escalation model", + "settings.router_gateway.api_key": "API key", + "settings.router_gateway.api_key_env": "API key env var name", + "settings.router_gateway.api_key_optional": "Optional; leave empty to use the env var", + "settings.router_gateway.base_url": "Base URL", + "settings.router_gateway.config_path": "Config file: {path}", + "settings.router_gateway.context_limit": "Context limit (tokens)", + "settings.router_gateway.debug": "Debug logging", + "settings.router_gateway.description": "Local smart routing gateway: requests go to the primary model by default, and switch to an escalation model after the context is repeatedly compacted past the limit. Saving takes effect immediately.", + "settings.router_gateway.desktop_only": "Smart routing gateway config is only available on desktop.", + "settings.router_gateway.escalation_desc": "After the session context is compacted (compaction) up to the limit, subsequent requests switch to the first escalation model in the list.", + "settings.router_gateway.escalation_label": "Enable auto escalation", + "settings.router_gateway.escalation_model": "Escalation model #{index}", + "settings.router_gateway.loading": "Loading gateway config...", + "settings.router_gateway.max_compactions": "Compaction limit", + "settings.router_gateway.merge_system": "Merge system messages to front", + "settings.router_gateway.model_name": "Model name", + "settings.router_gateway.not_running": "Gateway not running", + "settings.router_gateway.primary_label": "Primary model", + "settings.router_gateway.primary_model": "Primary model: {model}", + "settings.router_gateway.refresh": "Refresh", + "settings.router_gateway.remove_model": "Remove", + "settings.router_gateway.running": "Running", + "settings.router_gateway.saved": "Routing gateway config saved", + "settings.router_gateway.saving": "Saving...", + "settings.router_gateway.title": "Smart Routing Gateway", "settings.run_sandbox_probe": "Running probe...", "settings.running_probe": "Running probe...", "settings.runtime_config.agents_plugins": "Agents / plugins", @@ -2393,6 +2468,7 @@ export default { "settings.tab_description_pet": "An always-on desktop companion: progress reminders, pending decisions, and chat.", "settings.tab_description_preferences": "Default model, reasoning, and compaction", "settings.tab_description_recovery": "Fix common issues, reset workspace settings, or clean up leftover data.", + "settings.tab_description_remote_preview": "Allow LAN devices to view this workbench (read-only).", "settings.tab_description_shell": "Branding, visibility, and shell controls", "settings.tab_description_skills": "Browse, edit, and install skills without leaving settings.", "settings.tab_description_updates": "Keep the app current with quiet background checks and install controls.", @@ -2403,6 +2479,7 @@ export default { "settings.tab_pet": "Desktop Pet", "settings.tab_preferences": "Preferences", "settings.tab_recovery": "Recovery", + "settings.tab_remote_preview": "Remote Preview", "settings.tab_shell": "Customization", "settings.tab_skills": "Skills", "settings.tab_updates": "Updates", @@ -3074,4 +3151,4 @@ export default { "workspace_list.test_connection": "Test connection", "workspace_list.unavailable": "Unavailable", "workspace_list.workspace_fallback": "Workspace", -} as const; \ No newline at end of file +} as const; diff --git a/apps/app/src/i18n/locales/es.ts b/apps/app/src/i18n/locales/es.ts index f5ce4aeb3..b21e58080 100644 --- a/apps/app/src/i18n/locales/es.ts +++ b/apps/app/src/i18n/locales/es.ts @@ -114,7 +114,6 @@ export default { "composer.agent_label": "Agente", "composer.any_file_type_supported": "Se admite cualquier tipo de archivo.", "composer.attach_files": "Adjuntar archivos", - "composer.attachments_unavailable": "Los archivos adjuntos no están disponibles.", "composer.behavior_label": "Comportamiento", "composer.configure": "Configurar", "composer.default_agent": "Agente predeterminado", diff --git a/apps/app/src/i18n/locales/fr.ts b/apps/app/src/i18n/locales/fr.ts index dab5104d0..d8bb301e8 100644 --- a/apps/app/src/i18n/locales/fr.ts +++ b/apps/app/src/i18n/locales/fr.ts @@ -114,7 +114,6 @@ export default { "composer.agent_label": "Agent", "composer.any_file_type_supported": "Tous les types de fichiers sont pris en charge.", "composer.attach_files": "Joindre des fichiers", - "composer.attachments_unavailable": "Les pièces jointes ne sont pas disponibles.", "composer.behavior_label": "Comportement", "composer.configure": "Configurer", "composer.default_agent": "Agent par défaut", diff --git a/apps/app/src/i18n/locales/ja.ts b/apps/app/src/i18n/locales/ja.ts index b01033846..2cd6f63ae 100644 --- a/apps/app/src/i18n/locales/ja.ts +++ b/apps/app/src/i18n/locales/ja.ts @@ -113,7 +113,6 @@ export default { "composer.agent_label": "エージェント", "composer.any_file_type_supported": "すべてのファイル形式に対応しています。", "composer.attach_files": "ファイルを添付", - "composer.attachments_unavailable": "添付ファイルは利用できません。", "composer.behavior_label": "動作", "composer.configure": "設定", "composer.default_agent": "デフォルトエージェント", diff --git a/apps/app/src/i18n/locales/pt-BR.ts b/apps/app/src/i18n/locales/pt-BR.ts index 76bd193ae..c36ef60cd 100644 --- a/apps/app/src/i18n/locales/pt-BR.ts +++ b/apps/app/src/i18n/locales/pt-BR.ts @@ -114,7 +114,6 @@ export default { "composer.agent_label": "Agente", "composer.any_file_type_supported": "Qualquer tipo de arquivo é aceito.", "composer.attach_files": "Anexar arquivos", - "composer.attachments_unavailable": "Anexos não estão disponíveis.", "composer.behavior_label": "Comportamento", "composer.configure": "Configurar", "composer.default_agent": "Agente padrão", diff --git a/apps/app/src/i18n/locales/ru.ts b/apps/app/src/i18n/locales/ru.ts index a392074fb..6096b1a6c 100644 --- a/apps/app/src/i18n/locales/ru.ts +++ b/apps/app/src/i18n/locales/ru.ts @@ -115,7 +115,6 @@ export default { "composer.agent_label": "Агент", "composer.any_file_type_supported": "Поддерживаются файлы любого типа.", "composer.attach_files": "Прикрепить файлы", - "composer.attachments_unavailable": "Вложения недоступны.", "composer.behavior_label": "Поведение", "composer.configure": "Настроить", "composer.default_agent": "Агент по умолчанию", diff --git a/apps/app/src/i18n/locales/th.ts b/apps/app/src/i18n/locales/th.ts index 8e8bb8a49..4a83a2f5c 100644 --- a/apps/app/src/i18n/locales/th.ts +++ b/apps/app/src/i18n/locales/th.ts @@ -114,7 +114,6 @@ export default { "composer.agent_label": "Agent", "composer.any_file_type_supported": "รองรับไฟล์ทุกประเภท", "composer.attach_files": "แนบไฟล์", - "composer.attachments_unavailable": "ไฟล์แนบไม่พร้อมใช้งาน", "composer.behavior_label": "พฤติกรรม", "composer.configure": "ตั้งค่า", "composer.default_agent": "Agent เริ่มต้น", diff --git a/apps/app/src/i18n/locales/vi.ts b/apps/app/src/i18n/locales/vi.ts index 87f0f7764..202fe2d9a 100644 --- a/apps/app/src/i18n/locales/vi.ts +++ b/apps/app/src/i18n/locales/vi.ts @@ -114,7 +114,6 @@ export default { "composer.agent_label": "Agent", "composer.any_file_type_supported": "Hỗ trợ mọi loại tệp.", "composer.attach_files": "Đính kèm tệp", - "composer.attachments_unavailable": "Đính kèm không khả dụng.", "composer.behavior_label": "Hành vi", "composer.configure": "Cấu hình", "composer.default_agent": "Agent mặc định", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index 4e19bebb1..54f4c57ed 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -1,11 +1,11 @@ -/** - * 中文(简体)翻译 - * 产品名称保留英文:OpenCode、OpenPackage、iPolloWork - * Skills:标题用"Skills(技能)",正文用"skills" - * MCP:协议名称保留英文,不翻译为"应用" - * 翻译的术语:命令(Commands)、插件(Plugins)、会话(Sessions)、应用(Apps) - */ - +/** + * 中文(简体)翻译 + * 产品名称保留英文:OpenCode、OpenPackage、iPolloWork + * Skills:标题用"Skills(技能)",正文用"skills" + * MCP:协议名称保留英文,不翻译为"应用" + * 翻译的术语:命令(Commands)、插件(Plugins)、会话(Sessions)、应用(Apps) + */ + export default { "action.remove": "移除", "app.compact_command_desc": "压缩此会话以减少上下文大小。", @@ -766,6 +766,7 @@ export default { "find.no_matches": "没有匹配项", "find.previous_match": "上一个匹配项", "find.previous_match_hint": "上一个匹配项(⇧↵)", + "git.title": "Git", "help.all_categories": "全部分类", "help.back": "返回 Work", "help.clear_search": "清除搜索", @@ -869,7 +870,7 @@ export default { "mcp.enable_app": "启用", "mcp.env_modal_cancel": "取消", "mcp.env_modal_desc": "以下凭据将保存到本机的环境变量存储(加密保存在本设备,不会上传)。填好后自动完成连接。", -"mcp.env_modal_open_console": "打开开放平台(创建应用)", + "mcp.env_modal_open_console": "打开开放平台(创建应用)", "mcp.env_modal_placeholder": "粘贴对应的值", "mcp.env_modal_save": "保存并连接", "mcp.env_modal_save_failed": "保存失败,请检查值是否正确后重试。", @@ -892,7 +893,7 @@ export default { "mcp.logout_modal_title": "退出登录?", "mcp.logout_success": "{server}已退出登录。", "mcp.logout_working": "正在退出...", -"mcp.missing_env": "连接前请先配置环境变量:{vars}。到 设置 → 环境变量 里填好后重试。", + "mcp.missing_env": "连接前请先配置环境变量:{vars}。到 设置 → 环境变量 里填好后重试。", "mcp.name_required": "请输入服务器名称。", "mcp.no_apps_hint": "连接上方的应用开始使用。", "mcp.no_apps_yet": "暂无已连接的应用", @@ -1226,6 +1227,7 @@ export default { "notifications.view": "查看", "onboarding.authorize_folder": "授权文件夹", "onboarding.installing_resources": "正在安装相关资源中\u2026", + "ops.title": "运维", "plugin_library.add": "添加", "plugin_library.category.ai-agents": "AI Agent 与自动化", "plugin_library.category.business-operations": "商业与运营", @@ -1352,6 +1354,23 @@ export default { "plugins.suggested": "建议的插件", "plugins.suggested_heading": "建议的插件", "plugins.title": "OpenCode插件", + "projects.actions": "项目操作", + "projects.choose_folder": "选择项目文件夹", + "projects.create": "新建项目", + "projects.create_description": "选择一个独立文件夹作为项目空间。项目中的对话、文件和配置互相隔离。", + "projects.create_failed": "项目创建失败。", + "projects.keep_one": "个人或组织空间至少需要保留一个项目。", + "projects.name_and_folder_required": "请输入项目名称并选择项目文件夹。", + "projects.name_placeholder": "项目名称", + "projects.new_conversation": "在“{project}”中新建对话", + "projects.remove": "移除项目", + "projects.remove_description": "项目会从 iPolloWork 中移除,本地文件不会被删除。", + "projects.remove_title": "移除这个项目?", + "projects.removing": "正在移除…", + "projects.rename": "重命名项目", + "projects.server_unavailable": "项目服务暂不可用,请重新连接后再试。", + "projects.show_in_folder": "在文件夹中显示", + "projects.title": "项目", "prompt_templates.error_save": "保存提示模板失败。", "prompt_templates.save_task": "保存为模板", "prompt_templates.toast_saved": "已保存模板 {title}", @@ -1393,6 +1412,7 @@ export default { "remote_workspace.optional_placeholder": "可选", "remote_workspace.worker_url": "工作器 URL", "remote_workspace.worker_url_hint": "粘贴要连接的 iPolloWork 工作器 URL。", + "scheduled_tasks.title": "定时任务", "session.add_provider_hint": "添加提供商即可运行任务。", "session.allow_for_session": "始终允许", "session.allow_once": "允许一次", @@ -2201,6 +2221,35 @@ export default { "settings.recovery_reset_config_unavailable": "重置配置暂不可用。", "settings.recovery_section_desc": "无需退出 iPolloWork 即可回退状态。", "settings.recovery_section_title": "重置与恢复", + "settings.remote_preview_address_desc": "在手机或平板浏览器中打开该地址(需同一网络)。", + "settings.remote_preview_address_title": "访问地址", + "settings.remote_preview_alert_desc": "让同一局域网内的手机、平板通过浏览器查看当前工作台状态。只读:设备只能观看,不能操作。", + "settings.remote_preview_alert_title": "远程预览(局域网只读)", + "settings.remote_preview_code_desc": "10 分钟有效,单次使用", + "settings.remote_preview_code_hint": "在设备上输入此配对码即可与工作台配对。", + "settings.remote_preview_code_title": "配对码", + "settings.remote_preview_copy": "复制", + "settings.remote_preview_desktop_desc": "远程预览仅在桌面应用内可用。", + "settings.remote_preview_desktop_title": "需要桌面应用", + "settings.remote_preview_disconnect_all": "断开全部", + "settings.remote_preview_enable_desc": "默认关闭。开启后会向局域网暴露只读快照。", + "settings.remote_preview_enable_title": "启用局域网预览", + "settings.remote_preview_error_title": "远程预览启动失败", + "settings.remote_preview_im_desc": "通过 MCP 端点将当前工作台状态发送到钉钉/飞书群。", + "settings.remote_preview_im_endpoint_label": "钉钉 MCP 端点 URL", + "settings.remote_preview_im_endpoint_placeholder": "https://… 钉钉 MCP 端点(dws mcp url get )", + "settings.remote_preview_im_failed": "发送预览摘要失败。", + "settings.remote_preview_im_hint": "用 `dws mcp url get ` 获取端点。仅发送脱敏后的状态摘要,不包含任何会话令牌。", + "settings.remote_preview_im_need_endpoint": "请先输入钉钉 MCP 端点。", + "settings.remote_preview_im_push": "推送摘要", + "settings.remote_preview_im_sent": "预览摘要已发送。", + "settings.remote_preview_im_title": "推送预览到 IM", + "settings.remote_preview_im_unavailable": "IM 推送仅在桌面应用中可用。", + "settings.remote_preview_no_address": "未找到局域网地址。", + "settings.remote_preview_regenerate": "重新生成", + "settings.remote_preview_sessions_connected": "台设备已连接", + "settings.remote_preview_sessions_none": "暂无已配对设备。", + "settings.remote_preview_sessions_title": "已配对设备", "settings.removing_containers": "正在移除容器…", "settings.removing_local_state": "正在移除本地状态…", "settings.repair_cache": "修复缓存", @@ -2241,6 +2290,32 @@ export default { "settings.restarting": "正在重启…", "settings.reveal_config": "显示配置", "settings.reveal_config_requires_desktop": "显示配置需要桌面应用", + "settings.router_gateway.add_model": "添加升级模型", + "settings.router_gateway.api_key": "API 密钥", + "settings.router_gateway.api_key_env": "API 密钥环境变量名", + "settings.router_gateway.api_key_optional": "可选,留空则用环境变量", + "settings.router_gateway.base_url": "Base URL", + "settings.router_gateway.config_path": "配置文件:{path}", + "settings.router_gateway.context_limit": "上下文上限 (tokens)", + "settings.router_gateway.debug": "调试日志", + "settings.router_gateway.description": "本地智能路由网关:默认请求走主模型,上下文反复压缩超过上限后自动切换到升级模型。修改后保存即热生效。", + "settings.router_gateway.desktop_only": "智能路由网关配置仅在桌面端可用。", + "settings.router_gateway.escalation_desc": "当会话上下文反复压缩(compaction)达到上限次数后,后续请求自动切换到列表中的第一个升级模型。", + "settings.router_gateway.escalation_label": "启用自动升级", + "settings.router_gateway.escalation_model": "升级模型 #{index}", + "settings.router_gateway.loading": "正在读取网关配置…", + "settings.router_gateway.max_compactions": "压缩上限", + "settings.router_gateway.merge_system": "合并 system 到开头", + "settings.router_gateway.model_name": "模型名", + "settings.router_gateway.not_running": "网关未运行", + "settings.router_gateway.primary_label": "主模型", + "settings.router_gateway.primary_model": "主模型:{model}", + "settings.router_gateway.refresh": "刷新", + "settings.router_gateway.remove_model": "移除", + "settings.router_gateway.running": "运行中", + "settings.router_gateway.saved": "路由网关配置已保存", + "settings.router_gateway.saving": "保存中…", + "settings.router_gateway.title": "智能路由网关", "settings.run_sandbox_probe": "正在运行探测…", "settings.running_probe": "正在运行探测…", "settings.runtime_config.agents_plugins": "智能体 / 插件", @@ -2411,6 +2486,7 @@ export default { "settings.tab_description_pet": "常驻桌面的数字助手:任务提醒、待决策通知与陪伴对话。", "settings.tab_description_preferences": "默认模型、推理和上下文压缩。", "settings.tab_description_recovery": "修复迁移状态、重置工作区默认值和恢复本地设置。", + "settings.tab_description_remote_preview": "允许局域网设备查看此工作台(只读)。", "settings.tab_description_shell": "品牌、可见性和应用外壳控制项。", "settings.tab_description_skills": "在设置中浏览、编辑和安装skills。", "settings.tab_description_updates": "通过静默后台检查和安装控制保持应用为最新版本。", @@ -2421,6 +2497,7 @@ export default { "settings.tab_pet": "桌面助手", "settings.tab_preferences": "偏好设置", "settings.tab_recovery": "恢复", + "settings.tab_remote_preview": "远程预览", "settings.tab_shell": "自定义", "settings.tab_skills": "Skills(技能)", "settings.tab_updates": "更新", @@ -3093,4 +3170,4 @@ export default { "workspace_list.test_connection": "测试连接", "workspace_list.unavailable": "不可用", "workspace_list.workspace_fallback": "工作区", -} as const; \ No newline at end of file +} as const; diff --git a/apps/app/src/react-app/domains/connections/provider-auth/cloud-provider-config.ts b/apps/app/src/react-app/domains/connections/provider-auth/cloud-provider-config.ts index c579487ba..9ea627c7d 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/cloud-provider-config.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/cloud-provider-config.ts @@ -1,17 +1,10 @@ -import { applyEdits, modify } from "jsonc-parser"; -import type { ProviderConfig } from "@opencode-ai/sdk/v2/client"; - import type { DenOrgLlmProvider, DenOrgLlmProviderConnection, } from "../../../../app/lib/den"; import type { CloudImportedProvider } from "../../../../app/cloud/import-state"; -/** - * Pure helpers that build and reconcile the cloud-managed ("lpr_*") provider - * block inside a workspace `opencode.jsonc`. Extracted from the provider-auth - * store so the diff/update behaviour can be unit tested directly (#2346). - */ +/** Pure Work-layer helpers for cloud-managed provider identity and credentials. */ const getStringList = (value: unknown): string[] => Array.isArray(value) @@ -24,24 +17,12 @@ const getStringList = (value: unknown): string[] => const sameStringList = (a: string[], b: string[]) => a.length === b.length && a.every((value, index) => value === b[index]); -const escapeRegExp = (value: string) => - value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - -const removeCloudProviderComment = (raw: string, providerId: string) => - raw.replace( - new RegExp( - `(^[ \t]*)// iPolloWork Cloud import:.*\\n\\1(?="${escapeRegExp(providerId)}":)`, - "m", - ), - "$1", - ); - export const getCloudProviderEnv = (config: Record) => getStringList(config.env); /** - * Split a connect payload's credential into the opencode auth.json entry and - * the env vars to upsert. Multi-env providers (`apiKeys`) set every value as + * Split a connect payload's credential into the engine credential and the env + * vars to upsert. Multi-env providers (`apiKeys`) set every value as * an env var and use the first env-ordered value as the auth entry, following * the models.dev convention that `env[0]` is the primary credential. Legacy * single-credential payloads (`apiKey`) keep today's auth-only behaviour. @@ -71,8 +52,8 @@ export const getCloudManagedProviderId = ( ) => (provider.source === "ipollowork" ? "ipollowork" : provider.id.trim()); /** - * A provider key in `opencode.jsonc` that is owned by the cloud-import system: - * `lpr_*` keys (org-managed providers) and the `ipollowork` hosted provider. + * A provider key owned by the cloud-import system: `lpr_*` keys + * (org-managed providers) and the `ipollowork` hosted provider. * These keys are never hand-authored, so re-importing over an existing block * with one of these ids is a safe reconcile (recovers a lost import baseline) * rather than a clobber of a user's manual provider (#2346). @@ -105,115 +86,3 @@ export const isCloudProviderOutOfSync = ( // which otherwise made providers permanently out-of-sync. getProviderModelIds(provider), ); - -export const buildCloudProviderConfig = ( - provider: DenOrgLlmProviderConnection, -): ProviderConfig => { - const models = Object.fromEntries( - provider.models.map((model) => { - const next: NonNullable[string] = { - id: model.id, - name: model.name, - }; - const raw = model.config; - for (const key of [ - "family", - "release_date", - "attachment", - "reasoning", - "temperature", - "tool_call", - "interleaved", - "cost", - "limit", - "modalities", - "status", - "options", - "headers", - "provider", - "variants", - ] as const) { - const value = raw[key]; - if (value !== undefined) { - (next as Record)[key] = value; - } - } - return [model.id, next]; - }), - ); - - const next: ProviderConfig = { - id: provider.providerId, - name: provider.name, - env: getCloudProviderEnv(provider.providerConfig), - models, - }; - - if ( - typeof provider.providerConfig.npm === "string" && - provider.providerConfig.npm.trim() - ) { - next.npm = provider.providerConfig.npm; - } - if ( - typeof provider.providerConfig.api === "string" && - provider.providerConfig.api.trim() - ) { - next.api = provider.providerConfig.api; - } - if ( - provider.providerConfig.options && - typeof provider.providerConfig.options === "object" - ) { - next.options = provider.providerConfig.options as Record; - } - if (Array.isArray(provider.providerConfig.whitelist)) { - next.whitelist = getStringList(provider.providerConfig.whitelist); - } - if (Array.isArray(provider.providerConfig.blacklist)) { - next.blacklist = getStringList(provider.providerConfig.blacklist); - } - - return next; -}; - -/** - * Build the per-key runtime provider patch for a cloud import/reconcile. - * Sent to `PATCH /workspace/:id/config` where record values upsert and - * explicit `null` deletes (`mergeRuntimeProviderUpdate`) — no client-side - * read-modify-write of the user's `opencode.jsonc` at all. - */ -export const buildRuntimeProviderPatch = ( - provider: DenOrgLlmProviderConnection, - localProviderId: string, - previousProviderId?: string | null, -): Record => { - const patch: Record = {}; - if (previousProviderId && previousProviderId !== localProviderId) { - patch[previousProviderId] = null; - } - patch[localProviderId] = buildCloudProviderConfig(provider) as unknown as Record; - return patch; -}; - -export const formatConfigWithoutCloudProvider = ( - raw: string, - providerId: string, - disabledProviders: string[], -) => { - let updated = raw.trim() - ? raw - : '{\n "$schema": "https://opencode.ai/config.json"\n}\n'; - updated = removeCloudProviderComment(updated, providerId); - const providerEdits = modify(updated, ["provider", providerId], undefined, { - formattingOptions: { insertSpaces: true, tabSize: 2 }, - }); - updated = applyEdits(updated, providerEdits); - - const nextDisabled = disabledProviders.filter((id) => id !== providerId); - const disabledEdits = modify(updated, ["disabled_providers"], nextDisabled, { - formattingOptions: { insertSpaces: true, tabSize: 2 }, - }); - updated = applyEdits(updated, disabledEdits); - return updated.endsWith("\n") ? updated : `${updated}\n`; -}; diff --git a/apps/app/src/react-app/domains/connections/provider-auth/opencode-provider-engine-adapter.ts b/apps/app/src/react-app/domains/connections/provider-auth/opencode-provider-engine-adapter.ts new file mode 100644 index 000000000..7df42e49d --- /dev/null +++ b/apps/app/src/react-app/domains/connections/provider-auth/opencode-provider-engine-adapter.ts @@ -0,0 +1,317 @@ +import { applyEdits, modify, parse } from "jsonc-parser"; +import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; + +import { + readOpencodeConfig, + writeOpencodeConfig, +} from "../../../../app/lib/desktop"; +import type { DenOrgLlmProviderConnection } from "../../../../app/lib/den"; +import { unwrap, waitForHealthy } from "../../../../app/lib/opencode"; +import type { Client } from "../../../../app/types"; +import { isDesktopRuntime } from "../../../../app/utils"; +import { getCloudProviderEnv } from "./cloud-provider-config"; +import type { + ProviderEngineAdapter, + ProviderEngineConnection, +} from "./provider-engine-adapter"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasMethod(value: unknown, name: string) { + return isRecord(value) && typeof value[name] === "function"; +} + +function isOpenCodeClient(value: unknown): value is Client { + if (!isRecord(value)) return false; + return ( + hasMethod(value.provider, "list") && + hasMethod(value.provider, "auth") && + isRecord(value.provider) && + isRecord(value.provider.oauth) && + hasMethod(value.provider.oauth, "authorize") && + hasMethod(value.provider.oauth, "callback") && + hasMethod(value.auth, "set") && + hasMethod(value.auth, "remove") && + hasMethod(value.config, "get") && + hasMethod(value.config, "update") && + hasMethod(value.instance, "dispose") + ); +} + +function openCodeConnection(client: unknown): ProviderEngineConnection { + if (!isOpenCodeClient(client)) { + throw new Error("OpenCode provider client is unavailable"); + } + + return { + async listProviders(directory) { + return unwrap(await client.provider.list({ directory: directory?.trim() || undefined })); + }, + async listAuthMethods() { + const methods = unwrap(await client.provider.auth()); + return Object.fromEntries( + Object.entries(methods).map(([providerId, entries]) => [ + providerId, + entries.map(({ type, label }) => ({ type, label })), + ]), + ); + }, + async authorizeOAuth(providerId, methodIndex) { + return unwrap( + await client.provider.oauth.authorize({ + providerID: providerId, + method: methodIndex, + }), + ); + }, + async completeOAuth(providerId, methodIndex, code) { + unwrap( + await client.provider.oauth.callback({ + providerID: providerId, + method: methodIndex, + code: code?.trim() || undefined, + }), + ); + }, + async setApiKey(providerId, apiKey) { + unwrap( + await client.auth.set({ + providerID: providerId, + auth: { type: "api", key: apiKey }, + }), + ); + }, + async removeCredentials(providerId) { + unwrap(await client.auth.remove({ providerID: providerId })); + }, + async readDisabledProviders() { + const config = unwrap(await client.config.get()); + return Array.isArray(config.disabled_providers) ? config.disabled_providers : []; + }, + async writeDisabledProviders(providerIds) { + const config = unwrap(await client.config.get()); + const next = { ...config }; + if (providerIds.length) { + next.disabled_providers = providerIds; + } else { + delete next.disabled_providers; + } + unwrap(await client.config.update({ config: next })); + }, + async dispose() { + unwrap(await client.instance.dispose()); + }, + async waitUntilHealthy() { + await waitForHealthy(client, { timeoutMs: 8000, pollMs: 250 }); + }, + }; +} + +function normalizeDisabledProviders(value: unknown) { + return Array.isArray(value) + ? [ + ...new Set( + value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean), + ), + ] + : []; +} + +function projectProviderIds(raw: string) { + const config: unknown = parse(raw); + if (!isRecord(config) || !isRecord(config.provider)) return []; + return Object.keys(config.provider); +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function formatOpenCodeConfigWithoutProvider( + raw: string, + providerId: string, + disabledProviders: string[], +) { + const commentPattern = new RegExp( + `(^[ \\t]*)// iPolloWork Cloud import:.*\\n\\1(?="${escapeRegExp(providerId)}":)`, + "m", + ); + let updated = raw.replace(commentPattern, "$1"); + updated = applyEdits( + updated, + modify(updated, ["provider", providerId], undefined, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }), + ); + updated = applyEdits( + updated, + modify( + updated, + ["disabled_providers"], + disabledProviders.filter((id) => id !== providerId), + { formattingOptions: { insertSpaces: true, tabSize: 2 } }, + ), + ); + return updated.endsWith("\n") ? updated : `${updated}\n`; +} + +function buildOpenCodeCloudProviderPatch( + provider: DenOrgLlmProviderConnection, + localProviderId: string, + previousProviderId?: string | null, +) { + const modelFields = [ + "family", + "release_date", + "attachment", + "reasoning", + "temperature", + "tool_call", + "interleaved", + "cost", + "limit", + "modalities", + "status", + "options", + "headers", + "provider", + "variants", + ] as const; + const models = Object.fromEntries( + provider.models.map((model) => [ + model.id, + { + id: model.id, + name: model.name, + ...Object.fromEntries( + modelFields.flatMap((key) => + model.config[key] === undefined ? [] : [[key, model.config[key]]], + ), + ), + }, + ]), + ); + const config: Record = { + id: provider.providerId, + name: provider.name, + env: getCloudProviderEnv(provider.providerConfig), + models, + }; + const npm = provider.providerConfig.npm; + const api = provider.providerConfig.api; + if (typeof npm === "string" && npm.trim()) config.npm = npm; + if (typeof api === "string" && api.trim()) config.api = api; + if (isRecord(provider.providerConfig.options)) config.options = provider.providerConfig.options; + if (Array.isArray(provider.providerConfig.whitelist)) { + config.whitelist = provider.providerConfig.whitelist.filter( + (entry): entry is string => typeof entry === "string" && Boolean(entry.trim()), + ); + } + if (Array.isArray(provider.providerConfig.blacklist)) { + config.blacklist = provider.providerConfig.blacklist.filter( + (entry): entry is string => typeof entry === "string" && Boolean(entry.trim()), + ); + } + return { + ...(previousProviderId && previousProviderId !== localProviderId + ? { [previousProviderId]: null } + : {}), + [localProviderId]: config, + }; +} + +export const openCodeProviderEngineAdapter: ProviderEngineAdapter = { + id: DEFAULT_ENGINE_ID, + configFileName: "opencode.json", + connect: openCodeConnection, + emptyProjectConfig: () => '{\n "$schema": "https://opencode.ai/config.json"\n}\n', + async readProjectConfig(target) { + if (target.canUseiPolloWorkServer && target.ipolloworkClient && target.workspaceId) { + return target.ipolloworkClient.readOpencodeConfigFile(target.workspaceId, "project"); + } + if (target.hasiPolloWorkTarget) { + throw new Error("iPolloWork server config API is unavailable for this workspace."); + } + if (target.isLocalWorkspace && isDesktopRuntime() && target.root) { + return readOpencodeConfig("project", target.root); + } + return null; + }, + async writeProjectConfig(target, content) { + if (target.canUseiPolloWorkServer && target.ipolloworkClient && target.workspaceId) { + const result = await target.ipolloworkClient.writeOpencodeConfigFile( + target.workspaceId, + "project", + content, + ); + if (!result.ok) { + throw new Error(result.stderr || result.stdout || "Failed to write opencode.jsonc"); + } + return true; + } + if (target.hasiPolloWorkTarget) { + throw new Error("iPolloWork server config API is unavailable for this workspace."); + } + if (target.isLocalWorkspace && isDesktopRuntime() && target.root) { + const result = await writeOpencodeConfig("project", target.root, content); + if (!result.ok) { + throw new Error(result.stderr || result.stdout || "Failed to write opencode.jsonc"); + } + return true; + } + return false; + }, + async patchRuntimeProviders(target, update) { + if (!target.canUseiPolloWorkServer || !target.ipolloworkClient || !target.workspaceId) { + throw new Error("iPolloWork server unavailable. Connect to manage cloud providers."); + } + await target.ipolloworkClient.patchConfig(target.workspaceId, { + opencode: { provider: update }, + }); + }, + async runtimeProviderIds(target) { + if (!target.canUseiPolloWorkServer || !target.ipolloworkClient || !target.workspaceId) return []; + const config = await target.ipolloworkClient.getConfig(target.workspaceId); + return isRecord(config.opencode) && isRecord(config.opencode.provider) + ? Object.keys(config.opencode.provider) + : []; + }, + projectProviderIds, + formatProjectProviderDisabledState(raw, providerId, disabled) { + const resolvedProviderId = providerId.trim(); + const config: unknown = parse(raw); + const currentDisabled = normalizeDisabledProviders( + isRecord(config) ? config.disabled_providers : undefined, + ); + const nextDisabled = disabled + ? [...currentDisabled.filter((entry) => entry !== resolvedProviderId), resolvedProviderId] + : currentDisabled.filter((entry) => entry !== resolvedProviderId); + const edits = modify(raw, ["disabled_providers"], nextDisabled.length ? nextDisabled : undefined, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + const updated = applyEdits(raw, edits); + return updated.endsWith("\n") ? updated : `${updated}\n`; + }, + formatProjectWithoutProvider: formatOpenCodeConfigWithoutProvider, + buildCloudProviderPatch: buildOpenCodeCloudProviderPatch, + buildCompatibleProviderPatch(profile) { + const api = profile.api?.trim(); + const baseURL = profile.baseURL?.trim(); + if (!api && !baseURL) { + throw new Error("Compatible provider API URL is required"); + } + return { + [profile.id]: { + npm: profile.npm ?? "@ai-sdk/openai-compatible", + name: profile.name, + ...(api ? { api } : { options: { baseURL } }), + models: profile.models, + }, + }; + }, +}; diff --git a/apps/app/src/react-app/domains/connections/provider-auth/provider-engine-adapter.ts b/apps/app/src/react-app/domains/connections/provider-auth/provider-engine-adapter.ts new file mode 100644 index 000000000..9740d7f38 --- /dev/null +++ b/apps/app/src/react-app/domains/connections/provider-auth/provider-engine-adapter.ts @@ -0,0 +1,102 @@ +import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; + +import type { iPolloWorkServerClient } from "../../../../app/lib/ipollowork-server"; +import type { DenOrgLlmProviderConnection } from "../../../../app/lib/den"; +import type { ProviderListResponse } from "../../../../app/types"; +import { openCodeProviderEngineAdapter } from "./opencode-provider-engine-adapter"; + +export { openCodeProviderEngineAdapter } from "./opencode-provider-engine-adapter"; + +export type ProviderEngineAuthMethod = { + type: "oauth" | "api"; + label: string; +}; + +export type ProviderEngineAuthAuthorization = { + url: string; + method: "auto" | "code"; + instructions: string; +}; + +export type CompatibleProviderProfile = { + id: string; + name: string; + npm?: string; + api?: string; + baseURL?: string; + models: Record>; +}; + +export type ProviderEngineConfigTarget = { + ipolloworkClient: iPolloWorkServerClient | null; + workspaceId: string | null; + hasiPolloWorkTarget: boolean; + canUseiPolloWorkServer: boolean; + isLocalWorkspace: boolean; + root: string; +}; + +export type ProviderEngineConnection = { + listProviders(directory?: string): Promise; + listAuthMethods(): Promise>; + authorizeOAuth(providerId: string, methodIndex: number): Promise; + completeOAuth(providerId: string, methodIndex: number, code?: string): Promise; + setApiKey(providerId: string, apiKey: string): Promise; + removeCredentials(providerId: string): Promise; + readDisabledProviders(): Promise; + writeDisabledProviders(providerIds: string[]): Promise; + dispose(): Promise; + waitUntilHealthy(): Promise; +}; + +export interface ProviderEngineAdapter { + readonly id: string; + readonly configFileName: string; + connect(client: unknown): ProviderEngineConnection; + emptyProjectConfig(): string; + readProjectConfig(target: ProviderEngineConfigTarget): Promise<{ content?: string | null } | null>; + writeProjectConfig(target: ProviderEngineConfigTarget, content: string): Promise; + patchRuntimeProviders(target: ProviderEngineConfigTarget, update: Record): Promise; + runtimeProviderIds(target: ProviderEngineConfigTarget): Promise; + projectProviderIds(raw: string): string[]; + formatProjectProviderDisabledState(raw: string, providerId: string, disabled: boolean): string; + formatProjectWithoutProvider(raw: string, providerId: string, disabledProviders: string[]): string; + buildCloudProviderPatch( + provider: DenOrgLlmProviderConnection, + localProviderId: string, + previousProviderId?: string | null, + ): Record; + buildCompatibleProviderPatch(profile: CompatibleProviderProfile): Record; +} + +export class ProviderEngineAdapterRegistry { + readonly #adapters: ReadonlyMap; + + constructor(adapters: readonly ProviderEngineAdapter[]) { + const entries = new Map(); + for (const adapter of adapters) { + const id = adapter.id.trim(); + if (!id) throw new Error("Provider engine adapter ID is required"); + if (entries.has(id)) throw new Error(`Duplicate provider engine adapter: ${id}`); + entries.set(id, adapter); + } + this.#adapters = entries; + } + + get(id?: string | null): ProviderEngineAdapter { + const resolved = id?.trim() || DEFAULT_ENGINE_ID; + const adapter = this.#adapters.get(resolved); + if (!adapter) { + throw new Error(`Provider engine is not registered: ${resolved}`); + } + return adapter; + } + + ids(): string[] { + return [...this.#adapters.keys()]; + } +} + +export const providerEngineAdapters = new ProviderEngineAdapterRegistry([ + openCodeProviderEngineAdapter, +]); diff --git a/apps/app/src/react-app/domains/connections/provider-auth/store.ts b/apps/app/src/react-app/domains/connections/provider-auth/store.ts index 44957a52a..4909d202f 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/store.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/store.ts @@ -1,10 +1,6 @@ import { useSyncExternalStore } from "react"; -import { applyEdits, modify, parse } from "jsonc-parser"; -import type { - ProviderAuthAuthorization, - ProviderListResponse, -} from "@opencode-ai/sdk/v2/client"; +import { parse } from "jsonc-parser"; import { t } from "../../../../i18n"; import { @@ -13,18 +9,15 @@ import { type DenOrgLlmProvider, type DenOrgLlmProviderConnection, } from "../../../../app/lib/den"; -import { unwrap, waitForHealthy } from "../../../../app/lib/opencode"; import { - readOpencodeConfig, - writeOpencodeConfig, engineRestart, workspaceiPolloWorkRead, workspaceiPolloWorkWrite, } from "../../../../app/lib/desktop"; import { iPolloWorkServerError } from "../../../../app/lib/ipollowork-server"; import type { - Client, ProviderListItem, + ProviderListResponse, WorkspaceDisplay, } from "../../../../app/types"; import { isDesktopRuntime, safeStringify } from "../../../../app/utils"; @@ -59,8 +52,6 @@ import { type CloudImportedProvider, } from "../../../../app/cloud/import-state"; import { - buildRuntimeProviderPatch, - formatConfigWithoutCloudProvider, getCloudManagedProviderId, getCloudProviderEnv, getProviderModelIds, @@ -75,6 +66,12 @@ import { type DesktopAppRestrictionChecker, } from "../../../../app/cloud/desktop-app-restrictions"; import { TOKENSTAR_PROVIDER, tokenStarRuntimeModels } from "./tokenstar-provider"; +import { + providerEngineAdapters, + type ProviderEngineAuthAuthorization, + type ProviderEngineAuthMethod, + type ProviderEngineConfigTarget, +} from "./provider-engine-adapter"; type ProviderReturnFocusTarget = "none" | "composer"; type CloudProviderSyncReason = "sign_in" | "app_launch" | "interval" | "settings_cloud_opened"; @@ -128,7 +125,7 @@ export type ProviderAuthProvider = { export type ProviderOAuthStartResult = { methodIndex: number; - authorization: ProviderAuthAuthorization; + authorization: ProviderEngineAuthAuthorization; }; export type ProviderAuthStoreSnapshot = { @@ -144,7 +141,7 @@ export type ProviderAuthStoreSnapshot = { }; type CreateProviderAuthStoreOptions = { - client: () => Client | null; + client: () => unknown | null; providers: () => ProviderListItem[]; providerDefaults: () => Record; providerConnectedIds: () => string[]; @@ -160,7 +157,7 @@ type CreateProviderAuthStoreOptions = { setProviderDefaults: (value: Record) => void; setProviderConnectedIds: (value: string[]) => void; setDisabledProviders: (value: string[]) => void; - markOpencodeConfigReloadRequired: () => void; + markEngineConfigReloadRequired: (configFileName: string) => void; focusPromptSoon?: () => void; }; @@ -219,6 +216,21 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const getProviderAuthWorkerType = (): "local" | "remote" => options.selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local"; + const getProviderEngineAdapter = () => + providerEngineAdapters.get(options.selectedWorkspaceDisplay().engineId); + + const getProviderEngineConnection = () => { + const client = options.client(); + if (!client) { + throw new Error(t("providers.not_connected")); + } + return getProviderEngineAdapter().connect(client); + }; + + const markProviderEngineConfigReloadRequired = () => { + options.markEngineConfigReloadRequired(getProviderEngineAdapter().configFileName); + }; + const getProviderAuthProviders = (): ProviderAuthProvider[] => { const merged = new Map(); @@ -296,6 +308,18 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) }; }; + const resolveProviderEngineConfigTarget = async ( + mode: "read" | "write", + ): Promise => { + const target = await resolveiPolloWorkConfigTarget(mode); + return { + ...target, + workspaceId: target.ipolloworkWorkspaceId, + isLocalWorkspace: options.selectedWorkspaceDisplay().workspaceType === "local", + root: options.selectedWorkspaceRoot().trim(), + }; + }; + const refreshSnapshot = () => { snapshot = { providerAuthModalOpen: state.providerAuthModalOpen, @@ -477,82 +501,28 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) }; const readProjectConfigFile = async () => { - const root = options.selectedWorkspaceRoot().trim(); - const isLocalWorkspace = - options.selectedWorkspaceDisplay().workspaceType === "local"; - const { ipolloworkClient, ipolloworkWorkspaceId, hasiPolloWorkTarget, canUseiPolloWorkServer } = - await resolveiPolloWorkConfigTarget("read"); - - if (canUseiPolloWorkServer && ipolloworkClient && ipolloworkWorkspaceId) { - return await ipolloworkClient.readOpencodeConfigFile(ipolloworkWorkspaceId, "project"); - } - - if (hasiPolloWorkTarget) { - throw new Error("iPolloWork server config API is unavailable for this workspace."); - } - - if (isLocalWorkspace && isDesktopRuntime() && root) { - return await readOpencodeConfig("project", root); - } - - return null; + const target = await resolveProviderEngineConfigTarget("read"); + return getProviderEngineAdapter().readProjectConfig(target); }; const writeProjectConfigFile = async (content: string) => { - const root = options.selectedWorkspaceRoot().trim(); - const isLocalWorkspace = - options.selectedWorkspaceDisplay().workspaceType === "local"; - const { ipolloworkClient, ipolloworkWorkspaceId, hasiPolloWorkTarget, canUseiPolloWorkServer } = - await resolveiPolloWorkConfigTarget("write"); - - if (canUseiPolloWorkServer && ipolloworkClient && ipolloworkWorkspaceId) { - const result = await ipolloworkClient.writeOpencodeConfigFile( - ipolloworkWorkspaceId, - "project", - content, - ) as { ok: boolean; stderr?: string; stdout?: string }; - if (!result.ok) { - throw new Error(result.stderr || result.stdout || "Failed to write opencode.jsonc"); - } - return true; - } - - if (hasiPolloWorkTarget) { - throw new Error("iPolloWork server config API is unavailable for this workspace."); - } - - if (isLocalWorkspace && isDesktopRuntime() && root) { - const result = await writeOpencodeConfig("project", root, content) as { ok: boolean; stderr?: string; stdout?: string }; - if (!result.ok) { - throw new Error(result.stderr || result.stdout || "Failed to write opencode.jsonc"); - } - return true; - } - - return false; + const target = await resolveProviderEngineConfigTarget("write"); + return getProviderEngineAdapter().writeProjectConfig(target, content); }; /** * Upsert/delete cloud-managed provider entries in the workspace's runtime - * opencode config (server-side SQLite merged into OPENCODE_CONFIG). Record - * values upsert, explicit `null` deletes — per-key on the server, so there - * is no read-modify-write race and no edit of the user's opencode.jsonc. + * engine config. Record values upsert and explicit `null` deletes per key, + * so there is no read-modify-write race or edit of user-owned config. */ const patchRuntimeProviders = async (update: Record) => { - const { ipolloworkClient, ipolloworkWorkspaceId, canUseiPolloWorkServer } = - await resolveiPolloWorkConfigTarget("write"); - if (!canUseiPolloWorkServer || !ipolloworkClient || !ipolloworkWorkspaceId) { - throw new Error("iPolloWork server unavailable. Connect to manage cloud providers."); - } - await ipolloworkClient.patchConfig(ipolloworkWorkspaceId, { - opencode: { provider: update }, - }); + const target = await resolveProviderEngineConfigTarget("write"); + await getProviderEngineAdapter().patchRuntimeProviders(target, update); }; /** - * Best-effort migration: pre-runtime builds wrote cloud provider blocks - * into the project opencode.jsonc. Strip them so the runtime entry is the - * single owner (and stale blocks from older builds stop shadowing state). + * Best-effort migration: pre-runtime builds wrote cloud provider blocks into + * project config. Strip them so the runtime entry remains the single owner. */ const stripLegacyCloudProviderBlocks = async (providerIds: Array) => { const ids = [...new Set(providerIds.flatMap((id) => (id?.trim() ? [id.trim()] : [])))]; @@ -561,7 +531,11 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) await updateProjectConfigFile((raw) => { let next = raw; for (const id of ids) { - next = formatConfigWithoutCloudProvider(next, id, options.disabledProviders()); + next = getProviderEngineAdapter().formatProjectWithoutProvider( + next, + id, + options.disabledProviders(), + ); } return next; }); @@ -572,13 +546,12 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const updateProjectConfigFile = async ( updater: (raw: string) => string, - fallbackUpdate?: (config: Record) => Record, ) => { const configFile = await readProjectConfigFile() as { content?: string } | null; if (configFile) { const raw = configFile.content?.trim() ? configFile.content - : '{\n "$schema": "https://opencode.ai/config.json"\n}\n'; + : getProviderEngineAdapter().emptyProjectConfig(); const next = updater(raw); if (configsAreSemanticallyEqual(raw, next)) { return false; @@ -586,56 +559,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) await writeProjectConfigFile(next); return true; } - - if (!fallbackUpdate) { - return false; - } - - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } - const config = unwrap(await c.config.get()); - const next = fallbackUpdate(config); - await c.config.update({ config: next }); - return true; - }; - - const normalizeDisabledProviders = (value: unknown) => - Array.isArray(value) - ? [ - ...new Set( - value - .filter((entry): entry is string => typeof entry === "string") - .map((entry) => entry.trim()) - .filter(Boolean), - ), - ] - : []; - - const formatConfigWithProviderDisabledState = ( - raw: string, - providerId: string, - disabled: boolean, - ) => { - const resolvedProviderId = providerId.trim(); - let updated = raw.trim() - ? raw - : '{\n "$schema": "https://opencode.ai/config.json"\n}\n'; - const parsed = parse(updated) as Record | undefined; - const currentDisabled = normalizeDisabledProviders(parsed?.disabled_providers); - const nextDisabled = disabled - ? [...currentDisabled.filter((entry) => entry !== resolvedProviderId), resolvedProviderId] - : currentDisabled.filter((entry) => entry !== resolvedProviderId); - - const disabledEdits = modify( - updated, - ["disabled_providers"], - nextDisabled.length ? nextDisabled : undefined, - { formattingOptions: { insertSpaces: true, tabSize: 2 } }, - ); - updated = applyEdits(updated, disabledEdits); - return updated.endsWith("\n") ? updated : `${updated}\n`; + return false; }; const ensureProjectProviderDisabledState = async ( @@ -647,7 +571,9 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) throw new Error(t("providers.provider_id_required")); } - const currentDisabled = normalizeDisabledProviders(options.disabledProviders()); + const currentDisabled = [ + ...new Set(options.disabledProviders().map((entry) => entry.trim()).filter(Boolean)), + ]; const nextDisabled = disabled ? [...currentDisabled.filter((entry) => entry !== resolvedProviderId), resolvedProviderId] : currentDisabled.filter((entry) => entry !== resolvedProviderId); @@ -659,25 +585,20 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) return false; } - const updatedConfig = await updateProjectConfigFile( - (raw) => formatConfigWithProviderDisabledState(raw, resolvedProviderId, disabled), - (config) => { - const nextConfig = { ...config }; - if (nextDisabled.length) { - nextConfig.disabled_providers = nextDisabled; - } else { - delete nextConfig.disabled_providers; - } - return nextConfig; - }, + const updatedProjectConfig = await updateProjectConfigFile((raw) => + getProviderEngineAdapter().formatProjectProviderDisabledState( + raw, + resolvedProviderId, + disabled, + ), ); - if (!updatedConfig) { - throw new Error("Could not update opencode.jsonc for this workspace."); + if (!updatedProjectConfig) { + await getProviderEngineConnection().writeDisabledProviders(nextDisabled); } options.setDisabledProviders(nextDisabled); - options.markOpencodeConfigReloadRequired(); + markProviderEngineConfigReloadRequired(); refreshSnapshot(); emitChange(); return true; @@ -695,7 +616,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) }; // Sweep all cloud-managed provider entries (keys matching /^lpr_/) from - // both the runtime config and opencode.jsonc, regardless of + // both runtime and project engine config, regardless of // importedCloudProviders state. Returns the list of provider IDs that were // removed so callers can also clear their auth credentials. const sweepOrphanCloudProvidersFromConfig = async (): Promise => { @@ -703,42 +624,34 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) // Runtime-managed orphans (`lpr_*` keys in the workspace runtime config). try { - const { ipolloworkClient, ipolloworkWorkspaceId, canUseiPolloWorkServer } = - await resolveiPolloWorkConfigTarget("write"); - if (canUseiPolloWorkServer && ipolloworkClient && ipolloworkWorkspaceId) { - const merged = await ipolloworkClient.getConfig(ipolloworkWorkspaceId); - const runtimeProvider = isRecord(merged.opencode) ? merged.opencode.provider : null; - const runtimeOrphans = isRecord(runtimeProvider) - ? Object.keys(runtimeProvider).filter((key) => /^lpr_/i.test(key)) - : []; - if (runtimeOrphans.length > 0) { - await patchRuntimeProviders( - Object.fromEntries(runtimeOrphans.map((id) => [id, null])), - ); - for (const id of runtimeOrphans) orphanIds.add(id); - } + const target = await resolveProviderEngineConfigTarget("write"); + const runtimeOrphans = (await getProviderEngineAdapter().runtimeProviderIds(target)) + .filter((key) => /^lpr_/i.test(key)); + if (runtimeOrphans.length > 0) { + await patchRuntimeProviders( + Object.fromEntries(runtimeOrphans.map((id) => [id, null])), + ); + for (const id of runtimeOrphans) orphanIds.add(id); } } catch { // Best-effort; the legacy file sweep below still runs. } - // Legacy `opencode.jsonc` blocks written by pre-runtime builds. + // Legacy project-config blocks written by pre-runtime builds. const configFile = await readProjectConfigFile().catch(() => null) as { content?: string } | null; if (configFile?.content?.trim()) { - const parsed = parse(configFile.content); - const providerSection = - parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record).provider - : null; - const fileOrphans = - providerSection && typeof providerSection === "object" && !Array.isArray(providerSection) - ? Object.keys(providerSection as Record).filter((key) => /^lpr_/i.test(key)) - : []; + const fileOrphans = getProviderEngineAdapter() + .projectProviderIds(configFile.content) + .filter((key) => /^lpr_/i.test(key)); if (fileOrphans.length > 0) { await updateProjectConfigFile((raw) => { let next = raw; for (const id of fileOrphans) { - next = formatConfigWithoutCloudProvider(next, id, options.disabledProviders()); + next = getProviderEngineAdapter().formatProjectWithoutProvider( + next, + id, + options.disabledProviders(), + ); } return next; }); @@ -787,19 +700,9 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) return; } - const parsed = parse(configFile.content); - const providerSection = - parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record).provider - : null; - if ( - providerSection && - typeof providerSection === "object" && - !Array.isArray(providerSection) && - localProviderId in (providerSection as Record) - ) { + if (getProviderEngineAdapter().projectProviderIds(configFile.content).includes(localProviderId)) { throw new Error( - `${localProviderId} already has a provider block in opencode.jsonc. Remove it before importing the cloud-managed version.`, + `${localProviderId} already has a provider block in ${getProviderEngineAdapter().configFileName}. Remove it before importing the cloud-managed version.`, ); } }; @@ -923,43 +826,8 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) emitChange(); }; - const assertNoClientError = (result: unknown) => { - const maybe = result as { error?: unknown } | null | undefined; - if (!maybe || maybe.error === undefined) return; - throw new Error(describeProviderError(maybe.error, t("providers.request_failed"))); - }; - const removeProviderAuthCredentials = async (providerId: string) => { - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } - - const authClient = c.auth as unknown as { - remove?: (options: { providerID: string }) => Promise; - set?: (options: { providerID: string; auth: unknown }) => Promise; - }; - if (typeof authClient.remove === "function") { - const result = await authClient.remove({ providerID: providerId }); - assertNoClientError(result); - return; - } - - const rawClient = (c as unknown as { - client?: { delete?: (options: { url: string }) => Promise }; - }).client; - if (rawClient?.delete) { - await rawClient.delete({ url: `/auth/${encodeURIComponent(providerId)}` }); - return; - } - - if (typeof authClient.set === "function") { - const result = await authClient.set({ providerID: providerId, auth: null }); - assertNoClientError(result); - return; - } - - throw new Error(t("providers.removal_unsupported")); + await getProviderEngineConnection().removeCredentials(providerId); }; const describeProviderError = (error: unknown, fallback: string) => { @@ -1041,7 +909,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) }; const buildProviderAuthMethods = ( - methods: Record, + methods: Record, availableProviders: ProviderAuthProvider[], workerType: "local" | "remote", cloudProviders: DenOrgLlmProvider[], @@ -1139,16 +1007,12 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) }; const loadProviderAuthMethods = async (workerType: "local" | "remote") => { - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } - const methods = unwrap(await c.provider.auth()); + const methods = await getProviderEngineConnection().listAuthMethods(); const cloudProviders = await refreshCloudOrgProviders().catch( () => [] as DenOrgLlmProvider[], ); return buildProviderAuthMethods( - methods as Record, + methods, getProviderAuthProviders(), workerType, cloudProviders, @@ -1160,10 +1024,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) methodIndex?: number, ): Promise { setStateField("providerAuthError", null); - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } + const connection = getProviderEngineConnection(); try { const cachedMethods = state.providerAuthMethods; const authMethods = Object.keys(cachedMethods).length @@ -1200,9 +1061,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) throw new Error(`${t("providers.not_oauth_flow_prefix")} ${resolved}.`); } - const auth = unwrap( - await c.provider.oauth.authorize({ providerID: resolved, method: oauthIndex }), - ); + const auth = await connection.authorizeOAuth(resolved, oauthIndex); return { methodIndex: oauthIndex, authorization: auth }; } catch (error) { const message = describeProviderError(error, t("providers.connect_failed")); @@ -1212,8 +1071,9 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } async function refreshProviders(optionsArg?: { dispose?: boolean }) { - const c = options.client(); - if (!c) return null; + const client = options.client(); + if (!client) return null; + const connection = getProviderEngineAdapter().connect(client); if (optionsArg?.dispose) { // Prefer the iPolloWork server engine reload: it disposes the engine AND @@ -1249,26 +1109,25 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) if (!reloaded) { try { - unwrap(await c.instance.dispose()); + await connection.dispose(); } catch { // ignore dispose failures and try reading current state anyway } } try { - await waitForHealthy(options.client() ?? c, { timeoutMs: 8000, pollMs: 250 }); + const activeClient = options.client() ?? client; + await getProviderEngineAdapter().connect(activeClient).waitUntilHealthy(); } catch { // ignore health wait failures and still attempt provider reads } } - const activeClient = options.client() ?? c; + const activeClient = options.client() ?? client; + const activeConnection = getProviderEngineAdapter().connect(activeClient); let disabledProviders = options.disabledProviders() ?? []; try { - const config = unwrap(await activeClient.config.get()); - disabledProviders = Array.isArray(config.disabled_providers) - ? config.disabled_providers - : []; + disabledProviders = await activeConnection.readDisabledProviders(); options.setDisabledProviders(disabledProviders); refreshSnapshot(); emitChange(); @@ -1280,6 +1139,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const updated = filterProviderList( await ensureProviderListQuery(getReactQueryClient(), { client: activeClient, + engineId: getProviderEngineAdapter().id, directory: options.selectedWorkspaceRoot(), force: Boolean(optionsArg?.dispose), }), @@ -1298,10 +1158,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) code?: string, ) { setStateField("providerAuthError", null); - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } + const connection = getProviderEngineConnection(); const resolved = providerId?.trim(); if (!resolved) { @@ -1337,12 +1194,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) try { const trimmedCode = code?.trim(); - const result = await c.provider.oauth.callback({ - providerID: resolved, - method: methodIndex, - code: trimmedCode || undefined, - }); - assertNoClientError(result); + await connection.completeOAuth(resolved, methodIndex, trimmedCode || undefined); const updated = await refreshProviders({ dispose: true }); const connectedNow = Array.isArray(updated?.connected) && updated.connected.includes(resolved); if (connectedNow) { @@ -1377,12 +1229,6 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) providerAuthBusy: true, providerAuthError: null, })); - const c = options.client(); - if (!c) { - setStateField("providerAuthBusy", false); - throw new Error(t("providers.not_connected")); - } - const trimmed = apiKey.trim(); if (!trimmed) { setStateField("providerAuthBusy", false); @@ -1394,22 +1240,23 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const isTokenStar = resolvedProviderId === TOKENSTAR_PROVIDER.providerId; try { + const connection = getProviderEngineConnection(); if (isQwen3Coder) { - await patchRuntimeProviders({ - [QWEN3_CODER_PROVIDER.providerId]: { - npm: "@ai-sdk/openai-compatible", + await patchRuntimeProviders( + getProviderEngineAdapter().buildCompatibleProviderPatch({ + id: QWEN3_CODER_PROVIDER.providerId, name: QWEN3_CODER_PROVIDER.name, - options: { baseURL: QWEN3_CODER_PROVIDER.baseURL }, + baseURL: QWEN3_CODER_PROVIDER.baseURL, models: { [QWEN3_CODER_PROVIDER.modelId]: { name: QWEN3_CODER_PROVIDER.modelName, }, }, - }, - }); + }), + ); try { await updateProjectConfigFile((raw) => - formatConfigWithoutCloudProvider( + getProviderEngineAdapter().formatProjectWithoutProvider( raw, QWEN3_CODER_PROVIDER.providerId, options.disabledProviders(), @@ -1427,17 +1274,17 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) .filter(Boolean), ), ]; - await patchRuntimeProviders({ - [TOKENSTAR_PROVIDER.providerId]: { - npm: "@ai-sdk/openai-compatible", + await patchRuntimeProviders( + getProviderEngineAdapter().buildCompatibleProviderPatch({ + id: TOKENSTAR_PROVIDER.providerId, name: TOKENSTAR_PROVIDER.name, - options: { baseURL: TOKENSTAR_PROVIDER.baseURL }, + baseURL: TOKENSTAR_PROVIDER.baseURL, models: tokenStarRuntimeModels(selectedModelIds), - }, - }); + }), + ); try { await updateProjectConfigFile((raw) => - formatConfigWithoutCloudProvider( + getProviderEngineAdapter().formatProjectWithoutProvider( raw, TOKENSTAR_PROVIDER.providerId, options.disabledProviders(), @@ -1447,7 +1294,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) // Runtime config owns this provider; legacy file cleanup is best-effort. } } - await c.auth.set({ providerID: providerId, auth: { type: "api", key: trimmed } }); + await connection.setApiKey(providerId, trimmed); if (isQwen3Coder || isTokenStar) { const syntheticProviderId = isTokenStar ? TOKENSTAR_PROVIDER.providerId @@ -1480,10 +1327,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) if (!optionsArg?.silent) { setStateField("providerAuthError", null); } - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } + const connection = getProviderEngineConnection(); const settings = readDenSettings(); const token = settings.authToken?.trim() ?? ""; @@ -1521,10 +1365,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) await ipolloworkClient.upsertUserEnv(envEntries); } if (primaryApiKey) { - await c.auth.set({ - providerID: localProviderId, - auth: { type: "api", key: primaryApiKey }, - }); + await connection.setApiKey(localProviderId, primaryApiKey); await mirroriPolloWorkModelsVoiceEnv(provider, primaryApiKey); } if (existingImported?.providerId && existingImported.providerId !== localProviderId) { @@ -1539,9 +1380,13 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } // Cloud providers are runtime-managed: upsert (and delete a renamed // predecessor) via the server's per-key provider merge instead of - // editing the user's opencode.jsonc. + // editing the user's engine config file. await patchRuntimeProviders( - buildRuntimeProviderPatch(provider, localProviderId, existingImported?.providerId ?? null), + getProviderEngineAdapter().buildCloudProviderPatch( + provider, + localProviderId, + existingImported?.providerId ?? null, + ), ); await stripLegacyCloudProviderBlocks([localProviderId, existingImported?.providerId]); @@ -1567,7 +1412,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) .disabledProviders() .filter((id) => id !== localProviderId && id !== existingImported?.providerId); options.setDisabledProviders(nextDisabledProviders); - options.markOpencodeConfigReloadRequired(); + markProviderEngineConfigReloadRequired(); await refreshProviders({ dispose: true }); refreshSnapshot(); emitChange(); @@ -1607,7 +1452,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } } // Runtime-managed: delete the provider entry via the server's per-key - // merge (`null` deletes), then strip any legacy opencode.jsonc block + // merge (`null` deletes), then strip any legacy project-config block // left by pre-runtime builds. Both are idempotent. await patchRuntimeProviders({ [imported.providerId]: null }); await stripLegacyCloudProviderBlocks([imported.providerId]); @@ -1619,7 +1464,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) options.setDisabledProviders( options.disabledProviders().filter((id) => id !== imported.providerId), ); - options.markOpencodeConfigReloadRequired(); + markProviderEngineConfigReloadRequired(); refreshSnapshot(); emitChange(); return `${t("providers.disconnected_prefix")} ${imported.name}`; @@ -1800,10 +1645,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) async function disconnectProvider(providerId: string) { setStateField("providerAuthError", null); - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } + getProviderEngineConnection(); const resolved = providerId.trim(); if (!resolved) { @@ -1822,7 +1664,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) await patchRuntimeProviders({ [TOKENSTAR_PROVIDER.providerId]: null }); try { await updateProjectConfigFile((raw) => - formatConfigWithoutCloudProvider( + getProviderEngineAdapter().formatProjectWithoutProvider( raw, TOKENSTAR_PROVIDER.providerId, options.disabledProviders(), @@ -1955,7 +1797,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const importedProviders = { ...state.importedCloudProviders }; const importedIds = Object.keys(importedProviders); - // Best-effort cleanup: remove each cloud provider from opencode.jsonc + // Best-effort cleanup: remove each cloud provider from project config // BEFORE clearing state so removeCloudProviderInternal can find the records void (async () => { for (const cloudId of importedIds) { @@ -1966,7 +1808,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } } // Final sweep: remove any orphan `lpr_*` provider keys that remain - // in opencode.jsonc but weren't tracked in importedCloudProviders + // in project config but weren't tracked in importedCloudProviders // (e.g. from a previous failed cleanup or external edit). try { const orphans = await sweepOrphanCloudProvidersFromConfig(); @@ -1978,7 +1820,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } } if (orphans.length > 0) { - options.markOpencodeConfigReloadRequired(); + markProviderEngineConfigReloadRequired(); } } catch { // Ignore sweep failures during sign-out cleanup @@ -2020,7 +1862,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } catch {} } } - // Then: sweep any `lpr_*` keys that remain in opencode.jsonc + // Then: sweep any `lpr_*` keys that remain in project config try { const orphans = await sweepOrphanCloudProvidersFromConfig(); for (const providerId of orphans) { @@ -2029,7 +1871,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } catch {} } if (orphans.length > 0) { - options.markOpencodeConfigReloadRequired(); + markProviderEngineConfigReloadRequired(); } } catch {} mutateState((current) => ({ diff --git a/apps/app/src/react-app/domains/connections/provider-auth/use-session-provider-auth.ts b/apps/app/src/react-app/domains/connections/provider-auth/use-session-provider-auth.ts index ae42b6670..eda27e7a7 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/use-session-provider-auth.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/use-session-provider-auth.ts @@ -115,10 +115,10 @@ export function useSessionProviderAuth(input: UseSessionProviderAuthInput) { setProviderDefaults, setProviderConnectedIds, setDisabledProviders: setDisabledProviderIds, - markOpencodeConfigReloadRequired: () => { + markEngineConfigReloadRequired: (configFileName) => { markReloadRequired("config", { type: "config", - name: "opencode.json", + name: configFileName, action: "updated", }); }, @@ -175,7 +175,7 @@ export function useSessionProviderAuth(input: UseSessionProviderAuthInput) { }, [selectedWorkspaceEndpoint, store]); // Session is where forced sign-in lands. Keep org-managed cloud providers in - // sync here so sign-in applies opencode.json changes before Settings opens. + // sync here so sign-in applies engine-provider changes before Settings opens. useCloudProviderAutoSync(store.runCloudProviderSync); const snapshot = useProviderAuthStoreSnapshot(store); diff --git a/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx b/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx index 319e9f25c..1d26fee89 100644 --- a/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx +++ b/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx @@ -19,7 +19,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { t } from "@/i18n"; -import type { PendingPermission } from "@/app/types"; +import type { ConversationPermission } from "../engine/conversation-engine"; import { cn } from "@/lib/utils"; type PermissionPresentation = { @@ -40,7 +40,7 @@ type PermissionDetail = { }; type PermissionApprovalModalProps = { - permission: PendingPermission; + permission: ConversationPermission; busy?: boolean; respondPermission?: (requestID: string, reply: "once" | "always" | "reject") => void; safeStringify?: (value: unknown) => string; @@ -214,9 +214,9 @@ function isFocusableElement(element: HTMLElement) { return style.display !== "none" && style.visibility !== "hidden"; } -function describePermissionRequest(permission: PendingPermission): PermissionPresentation { - const patterns = permission.patterns.filter((pattern) => pattern.trim().length > 0); - if (permission.permission === "doom_loop") { +function describePermissionRequest(permission: ConversationPermission): PermissionPresentation { + const patterns = permission.resources.filter((pattern) => pattern.trim().length > 0); + if (permission.kind === "doom_loop") { const tool = permission.metadata && typeof permission.metadata === "object" && typeof permission.metadata.tool === "string" ? permission.metadata.tool @@ -234,12 +234,12 @@ function describePermissionRequest(permission: PendingPermission): PermissionPre }; } - const copy = permissionCopy(permission.permission); - const isExternalDirectory = permission.permission === "external_directory"; + const copy = permissionCopy(permission.kind); + const isExternalDirectory = permission.kind === "external_directory"; return { title: copy.title, message: copy.message, - permissionLabel: readablePermissionLabel(permission.permission), + permissionLabel: readablePermissionLabel(permission.kind), scopeLabel: isExternalDirectory ? t("session.permission_detail_path") : t("session.scope_label"), scopeValue: patterns.join(", ") || t("session.permission_scope_empty"), isDoomLoop: false, diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 394479d4f..5cf5fe867 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -3,7 +3,7 @@ import type { CSSProperties } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { UIMessage } from "ai"; import { useNavigate } from "react-router-dom"; -import { Code2, Ellipsis, Eye, FileText, Film, Globe, Image, LoaderCircle, Mic2, Palette, PanelRightClose, PanelRightOpen, Pencil, Presentation, Search, Settings2, Trash2, Upload, X, Zap } from "lucide-react"; +import { Code2, Ellipsis, Eye, FileText, Film, FolderOpen, Globe, Image, LoaderCircle, Mic2, Palette, PanelRightClose, PanelRightOpen, Pencil, Presentation, Search, Settings2, Trash2, Upload, X, Zap } from "lucide-react"; import { MAX_TEMPLATE_PACKAGE_BYTES, TEMPLATE_PACKAGE_FILE_ACCEPT, isPptxCompatibleTemplate, type PptxCompatibility, type TemplateCatalogItem, type TemplateCategory, type TemplateManifestV1, type TemplateSessionSnapshot, type TemplateSessionState, type TemplateValidationReport } from "@ipollowork/types/templates"; import { currentLocale, t } from "../../../../i18n"; @@ -23,16 +23,15 @@ import { } from "@/app/lib/enterprise-connections"; import { getDisplaySessionTitle } from "../../../../app/lib/session-title"; import type { BootPhase } from "../../../../app/lib/startup-boot"; -import { openDesktopPath, revealDesktopItemInDir, saveFile, type WorkspaceInfo } from "../../../../app/lib/desktop"; +import { openDesktopPath, pickDirectory, revealDesktopItemInDir, saveFile, type WorkspaceInfo } from "../../../../app/lib/desktop"; import type { - ComposerDraft, - PendingPermission, - PendingQuestion, - ProviderListItem, + ComposerDraft, + ProviderListItem, TodoItem, - WorkspaceConnectionState, - WorkspaceSessionGroup, -} from "../../../../app/types"; + WorkspaceConnectionState, + ProjectSessionList, +} from "../../../../app/types"; +import type { ConversationPermission, ConversationQuestion } from "../engine/conversation-engine"; import { ConversationOutputPanel, ConversationOutputTrigger } from "@/components/chat/artifact"; import { buildSessionMarkdown, sessionMarkdownFilename } from "@/components/chat/utils"; import { @@ -64,7 +63,6 @@ import { RenameSessionModal } from "../modals/rename-session-modal"; import { AppSidebar } from "../sidebar/app-sidebar"; import type { iPolloWorkSessionType, iPolloWorkTemplateId } from "../sidebar/app-sidebar-provider"; import { readSessionType, sessionTypeForTemplate, setSessionType } from "../sidebar/session-type"; -import { useSessionManagementStore, useActiveWorkspaceGroupId } from "../sidebar/session-management-store"; import { SessionSurface, type SessionSurfaceProps } from "../surface/session-surface"; import { replaceDesignSelectionToken } from "../surface/composer/composer-draft"; import { getComposerDraft, useComposerStateStore } from "../surface/composer-state-store"; @@ -122,6 +120,9 @@ import { shouldRefreshTemplateCatalogOnOpen } from "../templates/template-market import { savePromptTemplate } from "@/react-app/domains/session/templates/prompt-template-store"; import { SidePanel, type SidePanelLauncherItem } from "../panel/side-panel"; import { TerminalDock } from "../terminal/terminal-dock"; +import { OpsPanel } from "../ops/ops-panel"; +import { GitPanel } from "../git/git-panel"; +import { ScheduledTasksPanel } from "../scheduled-tasks/scheduled-tasks-panel"; import { useActivePanelTab, usePanelTabStore, useSessionPanelState } from "../panel/panel-tab-store"; import { useWorkspaceShellLayout } from "../../../shell/workspace-shell-layout"; import { useControlAction, type iPolloWorkControlAction } from "../../../shell/control/control-provider"; @@ -163,9 +164,9 @@ export type SessionPageHistoryControls = { onRedo: () => void | Promise; }; -export type SessionPageSidebarProps = { - workspaceSessionGroups: WorkspaceSessionGroup[]; - selectedWorkspaceId: string; +export type SessionPageSidebarProps = { + projectSessionLists: ProjectSessionList[]; + selectedWorkspaceId: string; selectedSessionId: string | null; developerMode: boolean; sessionStatusById: Record; @@ -174,20 +175,23 @@ export type SessionPageSidebarProps = { newTaskDisabled: boolean; sidebarHydratedFromCache: boolean; startupPhase: BootPhase; - onOpenSession: (workspaceId: string, sessionId: string) => void; + onOpenSession: (workspaceId: string, sessionId: string) => void; + onSelectProject: (workspaceId: string) => Promise | boolean | void; + onCreateProject: (input: { name: string; folderPath: string }) => Promise | void; + onRenameProject: (workspaceId: string, name: string) => Promise | void; + onRevealProject: (workspaceId: string) => Promise | void; + onDeleteProject: (workspaceId: string) => Promise | void; onPrefetchSession?: (workspaceId: string, sessionId: string) => void; onCreateTaskInWorkspace: ( workspaceId: string, type?: iPolloWorkSessionType, templateId?: iPolloWorkTemplateId, templateScope?: WorkContextId, - groupId?: string | null, ) => Promise | string | null | void; onCreateTaskWithPrompt?: (workspaceId: string, prompt: string) => void; onCreateTemplateAuthoring: ( workspaceId: string, input: { category: TemplateCategory; pptxCompatibility?: PptxCompatibility }, - groupId?: string | null, ) => Promise | string | null | void; onRecoverWorkspace: (workspaceId: string) => Promise | boolean | void; onTestWorkspaceConnection: (workspaceId: string) => Promise | boolean | void; @@ -241,11 +245,11 @@ export type SessionPageProps = { todos: TodoItem[]; sessionLoadingById: (sessionId: string | null) => boolean; providerAuthModal?: ProviderAuthModalProps | null; - activePermission?: PendingPermission | null; + activePermission?: ConversationPermission | null; permissionReplyBusy?: boolean; respondPermission?: (requestID: string, reply: "once" | "always" | "reject") => void; safeStringify?: (value: unknown) => string; - activeQuestion?: PendingQuestion | null; + activeQuestion?: ConversationQuestion | null; questionReplyBusy?: boolean; respondQuestion?: (requestID: string, answers: string[][]) => void; notFoundMessage?: string | null; @@ -260,9 +264,9 @@ export type SessionPageProps = { onTerminalOpenChange?: (open: boolean) => void; }; -function getSidebarInitialLoading(props: SessionPageSidebarProps) { - if (props.workspaceSessionGroups.some((group) => group.sessions.length > 0)) { - return false; +function getSidebarInitialLoading(props: SessionPageSidebarProps) { + if (props.projectSessionLists.some((project) => project.sessions.length > 0)) { + return false; } if (props.sidebarHydratedFromCache) return false; if ( @@ -271,15 +275,15 @@ function getSidebarInitialLoading(props: SessionPageSidebarProps) { props.startupPhase !== "ready" ) { return true; - } - return props.workspaceSessionGroups.some( - (group) => group.status === "loading" || group.status === "idle", + } + return props.projectSessionLists.some( + (project) => project.status === "loading" || project.status === "idle", ); } - -function sessionTitleForId(groups: WorkspaceSessionGroup[], id: string | null | undefined) { - if (!id) return ""; - const sessionsById = new Map(groups.flatMap((group) => group.sessions.map((session) => [session.id, session] as const))); + +function sessionTitleForId(projects: ProjectSessionList[], id: string | null | undefined) { + if (!id) return ""; + const sessionsById = new Map(projects.flatMap((project) => project.sessions.map((session) => [session.id, session] as const))); const match = sessionsById.get(id); return match ? getDisplaySessionTitle(match.title) : ""; } @@ -590,8 +594,8 @@ export function SessionPage(props: SessionPageProps) { const hasArtifactTargets = artifactTargetCount > 0; const activeSidePanel = voiceSidePanelOpen ? "voice" : sessionSidePanel; const selectedSessionTitle = useMemo( - () => sessionTitleForId(props.sidebar.workspaceSessionGroups, props.selectedSessionId), - [props.selectedSessionId, props.sidebar.workspaceSessionGroups], + () => sessionTitleForId(props.sidebar.projectSessionLists, props.selectedSessionId), + [props.selectedSessionId, props.sidebar.projectSessionLists], ); const [templateSessionRevision, setTemplateSessionRevision] = useState(0); const [templateCatalog, setTemplateCatalog] = useState([]); @@ -631,7 +635,6 @@ export function SessionPage(props: SessionPageProps) { ? readSessionType(props.selectedSessionId) : null ), [props.selectedSessionId, sessionTypeRevision]); - const activeSessionGroupId = useActiveWorkspaceGroupId(props.selectedWorkspaceId); const isDesignSession = selectedSessionType === "design"; const isVideoSession = selectedSessionType === "video"; const currentVideoEntryPath = props.selectedSessionId && isVideoSession @@ -1226,16 +1229,17 @@ export function SessionPage(props: SessionPageProps) { const [deleteOpen, setDeleteOpen] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false); const [sessionActionId, setSessionActionId] = useState(null); - const [createGroupOpen, setCreateGroupOpen] = useState(false); - const [createGroupLabel, setCreateGroupLabel] = useState(""); - const [createGroupWorkspaceId, setCreateGroupWorkspaceId] = useState(null); - const [renameGroupOpen, setRenameGroupOpen] = useState(false); - const [renameGroupLabel, setRenameGroupLabel] = useState(""); - const [renameGroupOriginalLabel, setRenameGroupOriginalLabel] = useState(""); - const [renameGroupTarget, setRenameGroupTarget] = useState<{ workspaceId: string; groupId: string } | null>(null); - const [removeGroupOpen, setRemoveGroupOpen] = useState(false); - const [removeGroupTarget, setRemoveGroupTarget] = useState<{ workspaceId: string; groupId: string; label: string } | null>(null); - const [mainWorkspaceView, setMainWorkspaceView] = useState<"extensions" | null>(null); + const [createProjectOpen, setCreateProjectOpen] = useState(false); + const [createProjectName, setCreateProjectName] = useState(""); + const [createProjectFolder, setCreateProjectFolder] = useState(""); + const [createProjectBusy, setCreateProjectBusy] = useState(false); + const [createProjectError, setCreateProjectError] = useState(null); + const [renameProjectId, setRenameProjectId] = useState(null); + const [renameProjectName, setRenameProjectName] = useState(""); + const [renameProjectBusy, setRenameProjectBusy] = useState(false); + const [deleteProjectId, setDeleteProjectId] = useState(null); + const [deleteProjectBusy, setDeleteProjectBusy] = useState(false); + const [mainWorkspaceView, setMainWorkspaceView] = useState<"extensions" | "ops" | "git" | "scheduled-tasks" | null>(null); const preserveSidePanelOnPanelOpenRef = useRef(false); const setCurrentSidePanel = useCallback((panel: SidePanelItem | null) => { @@ -2080,6 +2084,18 @@ export function SessionPage(props: SessionPageProps) { setCurrentSidePanel(null); setMainWorkspaceView("extensions"); }, [setCurrentSidePanel]); + const openOpsRailPane = useCallback(() => { + setCurrentSidePanel(null); + setMainWorkspaceView("ops"); + }, [setCurrentSidePanel]); + const openGitRailPane = useCallback(() => { + setCurrentSidePanel(null); + setMainWorkspaceView("git"); + }, [setCurrentSidePanel]); + const openScheduledTasksRailPane = useCallback(() => { + setCurrentSidePanel(null); + setMainWorkspaceView("scheduled-tasks"); + }, [setCurrentSidePanel]); const openVoiceRailPane = useCallback(() => { toggleCurrentSidePanel("voice"); }, [toggleCurrentSidePanel]); @@ -2229,10 +2245,10 @@ export function SessionPage(props: SessionPageProps) { toast.error(error instanceof Error ? error.message : t("prompt_templates.error_save")); } }, [canSavePromptTemplate, conversationMessages, props.selectedSessionId, selectedSessionTitle]); - const sessionActionTitle = useMemo( - () => sessionTitleForId(props.sidebar.workspaceSessionGroups, sessionActionId), - [props.sidebar.workspaceSessionGroups, sessionActionId], - ); + const sessionActionTitle = useMemo( + () => sessionTitleForId(props.sidebar.projectSessionLists, sessionActionId), + [props.sidebar.projectSessionLists, sessionActionId], + ); const showWorkspaceSetupEmptyState = props.workspaces.length === 0 && !props.selectedSessionId; const showNewConversationChrome = !props.selectedSessionId && !showWorkspaceSetupEmptyState; const showStartupSkeleton = @@ -2247,23 +2263,23 @@ export function SessionPage(props: SessionPageProps) { // Derive the main-pane error from the same data the sidebar uses so the two // panes can never disagree. We check (in priority order): // 1. selectedWorkspaceError (errorsByWorkspaceId[selectedWorkspaceId]) - // 2. workspaceConnectionStateById[selectedWorkspaceId].message (covers test/recover paths) - // 3. group.error from workspaceSessionGroups (the same source the sidebar reads) - const selectedWorkspaceConnectionMessage = (() => { + // 2. workspaceConnectionStateById[selectedWorkspaceId].message (covers test/recover paths) + // 3. Project load errors from the same source the sidebar reads. + const selectedWorkspaceConnectionMessage = (() => { const state = props.sidebar.workspaceConnectionStateById[props.selectedWorkspaceId]; if (state?.status === "error") return state.message?.trim() ?? ""; return ""; })(); - const selectedWorkspaceGroupError = (() => { - const group = props.sidebar.workspaceSessionGroups.find( - (item) => item.workspace.id === props.selectedWorkspaceId, - ); - return group?.error?.trim() ?? ""; + const selectedWorkspaceProjectError = (() => { + const project = props.sidebar.projectSessionLists.find( + (item) => item.workspace.id === props.selectedWorkspaceId, + ); + return project?.error?.trim() ?? ""; })(); const selectedWorkspaceErrorMessage = props.selectedWorkspaceError?.trim() || selectedWorkspaceConnectionMessage || - selectedWorkspaceGroupError || + selectedWorkspaceProjectError || ""; const showSelectedWorkspaceError = Boolean(selectedWorkspaceErrorMessage); const selectedWorkspaceErrorTitle = @@ -2305,7 +2321,7 @@ export function SessionPage(props: SessionPageProps) { (showWorkspaceSetupEmptyState || (props.selectedSessionId && !selectedSessionIsDefaultTitle)), ); const showMainHeaderMenu = showHeaderMenu && showMainHeaderTitle; - const mainHeaderHidden = mainWorkspaceView === "extensions" || (showNewConversationChrome && !sidebarVisuallyCollapsed); + const mainHeaderHidden = mainWorkspaceView === "extensions" || mainWorkspaceView === "ops" || mainWorkspaceView === "git" || mainWorkspaceView === "scheduled-tasks" || (showNewConversationChrome && !sidebarVisuallyCollapsed); const visibleWorkspaceWidth = viewportWidth - (shellConfig.sidebar && sidebarOpen ? effectiveLeftSidebarWidth : 0); const floatingRightPanelToggleOffset = sidePanelOpen ? Math.min(effectiveBrowserPanelWidth, Math.max(0, visibleWorkspaceWidth - 40)) + 8 @@ -2333,9 +2349,9 @@ export function SessionPage(props: SessionPageProps) { const openRenameModal = (sessionId: string) => { if (!props.onRenameSession) return; - setSessionActionId(sessionId); - setRenameTitle(sessionTitleForId(props.sidebar.workspaceSessionGroups, sessionId)); - setRenameOpen(true); + setSessionActionId(sessionId); + setRenameTitle(sessionTitleForId(props.sidebar.projectSessionLists, sessionId)); + setRenameOpen(true); }; const submitRename = async () => { @@ -2351,7 +2367,7 @@ export function SessionPage(props: SessionPageProps) { } }; - const confirmDelete = async () => { + const confirmDelete = async () => { const sessionId = sessionActionId; if (!sessionId || !props.onDeleteSession) return; setDeleteBusy(true); @@ -2361,7 +2377,66 @@ export function SessionPage(props: SessionPageProps) { } finally { setDeleteBusy(false); } - }; + }; + + const openRenameProject = (workspaceId: string) => { + const project = props.workspaces.find((workspace) => workspace.id === workspaceId); + if (!project) return; + setRenameProjectId(workspaceId); + setRenameProjectName(project.displayName?.trim() || project.name?.trim() || ""); + }; + + const pickProjectFolder = async () => { + const selected = await pickDirectory({ title: t("projects.choose_folder") }); + if (typeof selected !== "string" || !selected.trim()) return; + const folderPath = selected.trim(); + setCreateProjectFolder(folderPath); + if (!createProjectName.trim()) { + const folderName = folderPath.replaceAll("\\", "/").split("/").filter(Boolean).pop() ?? ""; + setCreateProjectName(folderName); + } + }; + + const submitCreateProject = async () => { + const name = createProjectName.trim(); + const folderPath = createProjectFolder.trim(); + if (!name || !folderPath) return; + setCreateProjectBusy(true); + setCreateProjectError(null); + try { + await props.sidebar.onCreateProject({ name, folderPath }); + setCreateProjectOpen(false); + } catch (error) { + setCreateProjectError(error instanceof Error ? error.message : t("app.unknown_error")); + } finally { + setCreateProjectBusy(false); + } + }; + + const submitRenameProject = async () => { + const workspaceId = renameProjectId; + const name = renameProjectName.trim(); + if (!workspaceId || !name) return; + setRenameProjectBusy(true); + try { + await props.sidebar.onRenameProject(workspaceId, name); + setRenameProjectId(null); + } finally { + setRenameProjectBusy(false); + } + }; + + const confirmDeleteProject = async () => { + const workspaceId = deleteProjectId; + if (!workspaceId) return; + setDeleteProjectBusy(true); + try { + await props.sidebar.onDeleteProject(workspaceId); + setDeleteProjectId(null); + } finally { + setDeleteProjectBusy(false); + } + }; return (
@@ -2376,9 +2451,9 @@ export function SessionPage(props: SessionPageProps) { )} style={sidebarProviderStyle} > - { + setCreateProjectName(""); + setCreateProjectFolder(""); + setCreateProjectError(null); + setCreateProjectOpen(true); + } : undefined} + onOpenRenameProject={openRenameProject} + onRevealProject={(workspaceId) => void props.sidebar.onRevealProject(workspaceId)} + onOpenDeleteProject={setDeleteProjectId} onPrefetchSession={props.sidebar.onPrefetchSession} onCreateTaskInWorkspace={props.sidebar.onCreateTaskInWorkspace} onOpenRenameSession={props.onRenameSession ? openRenameModal : undefined} @@ -2398,21 +2483,6 @@ export function SessionPage(props: SessionPageProps) { onArchiveSession={props.onArchiveSession ? (sessionId, archived) => { void props.onArchiveSession?.(sessionId, archived); } : undefined} - onOpenCreateGroupModal={(workspaceId) => { - setCreateGroupWorkspaceId(workspaceId); - setCreateGroupLabel(""); - setCreateGroupOpen(true); - }} - onOpenRenameGroupModal={(workspaceId, groupId, label) => { - setRenameGroupTarget({ workspaceId, groupId }); - setRenameGroupLabel(label); - setRenameGroupOriginalLabel(label); - setRenameGroupOpen(true); - }} - onOpenRemoveGroupModal={(workspaceId, groupId, label) => { - setRemoveGroupTarget({ workspaceId, groupId, label }); - setRemoveGroupOpen(true); - }} onRecoverWorkspace={props.sidebar.onRecoverWorkspace} onTestWorkspaceConnection={props.sidebar.onTestWorkspaceConnection} onEditWorkspaceConnection={props.sidebar.onEditWorkspaceConnection} @@ -2422,12 +2492,15 @@ export function SessionPage(props: SessionPageProps) { name: denAuth.user?.name ?? null, email: denAuth.user?.email ?? null, }} - activePrimaryItem={templateMarketOpen ? "template-market" : mainWorkspaceView === "extensions" ? "extensions" : null} + activePrimaryItem={templateMarketOpen ? "template-market" : mainWorkspaceView === "extensions" ? "extensions" : mainWorkspaceView === "ops" ? "ops" : mainWorkspaceView === "git" ? "git" : mainWorkspaceView === "scheduled-tasks" ? "scheduled-tasks" : null} onOpenAccount={openCloudAccount} onOpenSettings={props.onOpenSettings} onOpenHelp={props.onOpenHelp} onOpenTemplateMarket={() => setTemplateMarketOpen(true)} onOpenExtensions={openExtensionsRailPane} + onOpenOps={openOpsRailPane} + onOpenGit={openGitRailPane} + onOpenScheduledTasks={openScheduledTasksRailPane} onSignIn={openCloudSignIn} onOpenSessionSearch={props.sidebar.onOpenSessionSearch ? handleSidebarOpenSessionSearch : undefined} onStartResize={startLeftSidebarResize} @@ -2620,6 +2693,18 @@ export function SessionPage(props: SessionPageProps) {
{props.settingsSlot}
+ ) : mainWorkspaceView === "ops" ? ( +
+ setMainWorkspaceView(null)} /> +
+ ) : mainWorkspaceView === "git" ? ( +
+ setMainWorkspaceView(null)} /> +
+ ) : mainWorkspaceView === "scheduled-tasks" ? ( +
+ setMainWorkspaceView(null)} /> +
) : showStartupSkeleton ? (
@@ -2720,7 +2805,6 @@ export function SessionPage(props: SessionPageProps) { type, templateId, PERSONAL_WORK_CONTEXT_ID, - activeSessionGroupId, )} onMaterializeTemplate={async (templateId, surface) => { if (!props.ipolloworkServerClient || !props.runtimeWorkspaceId || !props.selectedSessionId) return; @@ -2982,7 +3066,7 @@ export function SessionPage(props: SessionPageProps) { onExport={(template) => void exportPersonalTemplate(template)} onImport={importDesignTemplate} canCreate={props.selectedWorkspaceDisplay.workspaceType === "local"} - onCreate={(input) => props.sidebar.onCreateTemplateAuthoring(props.selectedWorkspaceId, input, activeSessionGroupId)} + onCreate={(input) => props.sidebar.onCreateTemplateAuthoring(props.selectedWorkspaceId, input)} onUse={(template) => { if (template.manifest.surface === "video" && props.selectedWorkspaceDisplay.workspaceType === "remote") { toast.error(t("templates.video_local_only")); @@ -2994,7 +3078,6 @@ export function SessionPage(props: SessionPageProps) { sessionTypeForTemplate(template.manifest), template.manifest.id, templateResourceScope, - activeSessionGroupId, ); }} /> : null} @@ -3015,7 +3098,7 @@ export function SessionPage(props: SessionPageProps) { /> ) : null} - {props.onDeleteSession ? ( + {props.onDeleteSession ? ( - ) : null} - - { if (!open) setCreateGroupOpen(false); }}> - - - {t("session_management.new_group")} - - setCreateGroupLabel(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && createGroupLabel.trim()) { - if (createGroupWorkspaceId) useSessionManagementStore.getState().createGroup(createGroupWorkspaceId, createGroupLabel.trim()); - setCreateGroupOpen(false); - } - }} - placeholder={t("session_management.new_group_prompt")} - /> - - }>{t("common.cancel")} - - - - - - { if (!open) setRenameGroupOpen(false); }}> - - - {t("session_management.rename_group")} - - setRenameGroupLabel(event.currentTarget.value)} - onKeyDown={(event) => { - const label = renameGroupLabel.trim(); - if (event.key !== "Enter" || !label || label === renameGroupOriginalLabel.trim() || !renameGroupTarget) return; - useSessionManagementStore.getState().renameGroup(renameGroupTarget.workspaceId, renameGroupTarget.groupId, label); - setRenameGroupOpen(false); - }} - placeholder={t("session_management.new_group_prompt")} - /> - - }>{t("common.cancel")} - - - - - - { - if (removeGroupTarget) { - useSessionManagementStore.getState().removeGroup(removeGroupTarget.workspaceId, removeGroupTarget.groupId); - } - setRemoveGroupOpen(false); - }} - onCancel={() => setRemoveGroupOpen(false)} - /> - - { if (!open && !createProjectBusy) setCreateProjectOpen(false); }}> + + + {t("projects.create")} + {t("projects.create_description")} + +
+ setCreateProjectName(event.currentTarget.value)} + placeholder={t("projects.name_placeholder")} + disabled={createProjectBusy} + /> + + {createProjectError ?

{createProjectError}

: null} +
+ + }>{t("common.cancel")} + + +
+ + + { if (!open && !renameProjectBusy) setRenameProjectId(null); }}> + + + {t("projects.rename")} + + setRenameProjectName(event.currentTarget.value)} + onKeyDown={(event) => { if (event.key === "Enter") void submitRenameProject(); }} + placeholder={t("projects.name_placeholder")} + disabled={renameProjectBusy} + /> + + }>{t("common.cancel")} + + + + + + void confirmDeleteProject()} + onCancel={() => { if (!deleteProjectBusy) setDeleteProjectId(null); }} + /> + + diff --git a/apps/app/src/react-app/domains/session/control/session-control-actions.ts b/apps/app/src/react-app/domains/session/control/session-control-actions.ts index 35ea56bca..5c57104c9 100644 --- a/apps/app/src/react-app/domains/session/control/session-control-actions.ts +++ b/apps/app/src/react-app/domains/session/control/session-control-actions.ts @@ -1,19 +1,18 @@ /** @jsxImportSource react */ -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; -import type { createClient } from "../../../../app/lib/opencode"; import type { iPolloWorkServerClient, iPolloWorkWorkspaceInfo } from "../../../../app/lib/ipollowork-server"; -import { setSessionArchived } from "../../../../app/lib/opencode-session"; import { getDisplaySessionTitle } from "../../../../app/lib/session-title"; import { useControlAction, type iPolloWorkControlAction } from "../../../shell/control/control-provider"; -import { useSessionManagementStore } from "../sidebar/session-management-store"; +import { useSessionPinStore } from "../sidebar/session-pin-store"; +import type { ConversationEngineConnection } from "../engine/conversation-engine"; type SessionLike = { id?: string; - title?: string; + title?: string | null; time?: { - updated?: number; - created?: number; + updated?: number | null; + created?: number | null; }; }; @@ -29,7 +28,7 @@ type UseSessionControlActionsInput = { selectedSessionId: string | null; canCreateTask: boolean; ipolloworkClient: iPolloWorkServerClient | null; - opencodeClient: ReturnType | null; + conversation: ConversationEngineConnection | null; navigateToSession: (sessionId: string) => void; navigateToSessionRoot: () => void; createTaskInWorkspace: (workspaceId: string) => Promise | unknown; @@ -72,7 +71,7 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) { navigateToSessionRoot, openModelPicker, ipolloworkClient, - opencodeClient, + conversation, refreshRouteState, selectedSessionId, selectedWorkspaceId, @@ -144,24 +143,20 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) { { name: "sessionId", type: "string", required: true, description: "Session ID from session.list_sessions." }, { name: "title", type: "string", required: true, description: "New session title." }, ], - disabled: !opencodeClient, + disabled: !conversation, execute: async (args) => { const sessionId = stringArg(args, "sessionId"); const title = stringArg(args, "title"); if (!sessionId) return { ok: false, error: "sessionId is required" }; if (!title) return { ok: false, error: "title is required" }; - if (!opencodeClient) return { ok: false, error: "OpenCode client is not connected" }; + if (!conversation) return { ok: false, error: "Conversation engine is not connected" }; const targetWorkspace = findSessionWorkspace(workspaces, sessionsByWorkspaceId, sessionId); - await opencodeClient.session.update({ - sessionID: sessionId, - title, - directory: targetWorkspace?.path || selectedWorkspaceRoot || undefined, - }); + await conversation.rename(sessionId, title, targetWorkspace?.path || selectedWorkspaceRoot || undefined); await refreshRouteState(); return { ok: true, sessionId, title }; }, - }), [opencodeClient, refreshRouteState, selectedWorkspaceRoot, sessionsByWorkspaceId, workspaces]); + }), [conversation, refreshRouteState, selectedWorkspaceRoot, sessionsByWorkspaceId, workspaces]); useControlAction(renameSessionControlAction); const deleteSessionControlAction = useMemo(() => ({ @@ -206,27 +201,10 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) { useControlAction(modelPickerControlAction); // --------------------------------------------------------------------------- - // Session management control actions (pin, archive, groups) + // Session management control actions (pin and archive) // --------------------------------------------------------------------------- - const store = useSessionManagementStore; - - /** Resolve a workspace ID from user input. Falls back to selectedWorkspaceId - * if the input is empty or doesn't match any known workspace (e.g. if the - * caller passes a display name instead of the actual ID). */ - const resolveWorkspaceId = useCallback((input: string | undefined): string | undefined => { - if (!input) return selectedWorkspaceId || undefined; - // Exact match on ID. - if (workspaces.some((ws) => ws.id === input)) return input; - // Fuzzy match on display name / path — return the first matching workspace ID. - const byName = workspaces.find( - (ws) => - (ws.displayName?.trim() || ws.name?.trim() || ws.path?.trim() || "").toLowerCase() === input.toLowerCase(), - ); - if (byName) return byName.id; - // Unknown — fall back to selected. - return selectedWorkspaceId || undefined; - }, [selectedWorkspaceId, workspaces]); + const store = useSessionPinStore; const pinControlAction = useMemo(() => ({ id: "session.pin", @@ -255,111 +233,18 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) { { name: "sessionId", type: "string", required: true, description: "Session ID." }, { name: "archived", type: "boolean", required: true, description: "true to archive, false to unarchive." }, ], - disabled: !opencodeClient, + disabled: !conversation, execute: async (args) => { const sessionId = stringArg(args, "sessionId"); const archived = booleanArg(args, "archived"); if (!sessionId) return { ok: false, error: "sessionId is required" }; - if (!opencodeClient) return { ok: false, error: "OpenCode client is not connected" }; + if (!conversation) return { ok: false, error: "Conversation engine is not connected" }; const targetWorkspace = findSessionWorkspace(workspaces, sessionsByWorkspaceId, sessionId); - await setSessionArchived(opencodeClient, sessionId, archived, targetWorkspace?.path || selectedWorkspaceRoot || undefined); + await conversation.setArchived(sessionId, archived, targetWorkspace?.path || selectedWorkspaceRoot || undefined); await refreshRouteState(); return { ok: true, sessionId, archived }; }, - }), [opencodeClient, refreshRouteState, selectedWorkspaceRoot, sessionsByWorkspaceId, workspaces]); + }), [conversation, refreshRouteState, selectedWorkspaceRoot, sessionsByWorkspaceId, workspaces]); useControlAction(archiveControlAction); - const groupCreateControlAction = useMemo(() => ({ - id: "session.group.create", - label: "Create a session group", - description: "Create a new group (folder/separator) in the current workspace sidebar. Sessions can then be moved into it.", - sideEffect: "mutation", - requiresArgs: true, - args: [ - { name: "label", type: "string", required: true, description: "Group name (e.g. 'Done', 'In progress', 'Backlog')." }, - { name: "workspaceId", type: "string", required: false, description: "Workspace ID. Defaults to the selected workspace." }, - ], - disabled: !selectedWorkspaceId, - execute: (args) => { - const label = stringArg(args, "label"); - const wsId = resolveWorkspaceId(stringArg(args, "workspaceId")); - if (!label) return { ok: false, error: "label is required" }; - if (!wsId) return { ok: false, error: "No workspace selected" }; - store.getState().createGroup(wsId, label); - const created = store.getState().groupsByWorkspace[wsId]; - const newGroup = created?.groups[created.groups.length - 1]; - return { ok: true, workspaceId: wsId, label, groupId: newGroup?.id ?? null }; - }, - }), [resolveWorkspaceId]); - useControlAction(groupCreateControlAction); - - const groupMoveControlAction = useMemo(() => ({ - id: "session.group.move", - label: "Move a session to a group", - description: "Assign a session to a group (folder). Pass groupId=null or omit to remove from current group. Use session.group.list to see available groups.", - sideEffect: "mutation", - requiresArgs: true, - args: [ - { name: "sessionId", type: "string", required: true, description: "Session ID." }, - { name: "groupId", type: "string", required: false, description: "Group ID to move into. Omit or null to ungrouped." }, - { name: "workspaceId", type: "string", required: false, description: "Workspace ID. Defaults to session's workspace." }, - ], - execute: (args) => { - const sessionId = stringArg(args, "sessionId"); - const groupId = stringArg(args, "groupId") || null; - if (!sessionId) return { ok: false, error: "sessionId is required" }; - const targetWorkspace = findSessionWorkspace(workspaces, sessionsByWorkspaceId, sessionId); - const wsId = resolveWorkspaceId(stringArg(args, "workspaceId")) || targetWorkspace?.id; - if (!wsId) return { ok: false, error: "Could not determine workspace" }; - store.getState().assignGroup(wsId, sessionId, groupId); - return { ok: true, sessionId, groupId, workspaceId: wsId }; - }, - }), [resolveWorkspaceId, sessionsByWorkspaceId, workspaces]); - useControlAction(groupMoveControlAction); - - const groupRemoveControlAction = useMemo(() => ({ - id: "session.group.remove", - label: "Remove a session group", - description: "Remove a group from the workspace. Sessions in the group become ungrouped (not deleted).", - sideEffect: "mutation", - requiresConfirmation: true, - requiresArgs: true, - args: [ - { name: "groupId", type: "string", required: true, description: "Group ID to remove." }, - { name: "workspaceId", type: "string", required: false, description: "Workspace ID. Defaults to selected." }, - { name: "confirmed", type: "boolean", required: true, description: "Must be true." }, - ], - disabled: !selectedWorkspaceId, - execute: (args) => { - const groupId = stringArg(args, "groupId"); - const confirmed = booleanArg(args, "confirmed"); - const wsId = resolveWorkspaceId(stringArg(args, "workspaceId")); - if (!groupId) return { ok: false, error: "groupId is required" }; - if (!confirmed) return { ok: false, error: "Requires confirmed: true" }; - if (!wsId) return { ok: false, error: "No workspace selected" }; - store.getState().removeGroup(wsId, groupId); - return { ok: true, groupId, workspaceId: wsId }; - }, - }), [resolveWorkspaceId]); - useControlAction(groupRemoveControlAction); - - const groupListControlAction = useMemo(() => ({ - id: "session.group.list", - label: "List session groups", - description: "List all groups in a workspace with their IDs and labels.", - sideEffect: "none", - args: [{ name: "workspaceId", type: "string", required: false, description: "Workspace ID. Defaults to selected." }], - execute: (args) => { - const wsId = resolveWorkspaceId(stringArg(args, "workspaceId")); - if (!wsId) return { ok: false, error: "No workspace selected" }; - const state = store.getState().groupsByWorkspace[wsId]; - return { - ok: true, - workspaceId: wsId, - groups: (state?.groups ?? []).map((g) => ({ id: g.id, label: g.label })), - assignments: state?.assignments ?? {}, - }; - }, - }), [resolveWorkspaceId]); - useControlAction(groupListControlAction); } diff --git a/apps/app/src/react-app/domains/session/engine/conversation-engine.ts b/apps/app/src/react-app/domains/session/engine/conversation-engine.ts new file mode 100644 index 000000000..b3ec9a91b --- /dev/null +++ b/apps/app/src/react-app/domains/session/engine/conversation-engine.ts @@ -0,0 +1,200 @@ +import type { UIMessage, UIMessageChunk } from "ai"; + +import type { ModelRef, SlashCommandOption, TodoItem } from "@/app/types"; + +export type ConversationStatus = + | { type: "idle" } + | { type: "busy" } + | { type: "retry"; attempt: number; message: string; next: number }; + +export type ConversationSession = { + [key: string]: unknown; + id: string; + title: string; + slug?: string | null; + parentID?: string | null; + directory?: string | null; + time?: { + created?: number | null; + updated?: number | null; + archived?: number | null; + }; + revertMessageId?: string | null; +}; + +export type ConversationSnapshot = { + session: ConversationSession; + messages: UIMessage[]; + todos: TodoItem[]; + status: ConversationStatus; +}; + +export type ConversationPermission = { + id: string; + sessionId: string; + kind: string; + resources: string[]; + remember: string[]; + metadata: Record; + receivedAt: number; + native: unknown; +}; + +export type ConversationQuestionOption = { + label: string; + description?: string; +}; + +export type ConversationQuestionInfo = { + header?: string; + question: string; + options: ConversationQuestionOption[]; + multiple?: boolean; + custom?: boolean; +}; + +export type ConversationQuestion = { + id: string; + sessionId: string; + questions: ConversationQuestionInfo[]; + receivedAt: number; + native: unknown; +}; + +export type ConversationAgent = { + name: string; + description?: string; + hidden?: boolean; + mode?: string; +}; + +export type ConversationPromptPart = + | { type: "text"; text: string; synthetic?: boolean } + | { type: "file"; mime: string; url: string; filename?: string } + | { type: "agent"; name: string }; + +export type ConversationMessageChunk = Extract< + UIMessageChunk, + { type: "text-delta" | "reasoning-delta" } +>; + +export type ConversationEvent = + | { type: "session.updated"; sessionId: string; info: ConversationSession } + | { type: "session.deleted"; sessionId: string } + | { type: "session.error"; sessionId: string; errorText: string } + | { type: "session.compaction"; sessionId: string; running: boolean } + | { type: "session.status"; sessionId: string; status: ConversationStatus } + | { type: "session.idle"; sessionId: string } + | { type: "todo.updated"; sessionId: string; todos: TodoItem[] } + | { type: "permission.asked"; permission: ConversationPermission } + | { type: "permission.replied"; sessionId: string; requestId: string } + | { type: "question.asked"; question: ConversationQuestion } + | { type: "question.replied"; sessionId: string; requestId: string } + | { type: "message.upsert"; sessionId: string; message: UIMessage } + | { type: "message.removed"; sessionId: string; messageId: string } + | { + type: "message.parts"; + sessionId: string; + messageId: string; + partId: string; + parts: UIMessage["parts"]; + visibleAssistantOutput: boolean; + } + | { + type: "message.chunk"; + sessionId: string; + messageId: string; + chunk: ConversationMessageChunk; + }; + +export type ConversationSubscribeInput = { + signal: AbortSignal; + onEvent: (event: ConversationEvent) => void; +}; + +export type ConversationPromptInput = { + sessionId: string; + parts: ConversationPromptPart[]; + model?: ModelRef; + agent?: string; + variant?: string; + reasoningEffort?: string; + system?: string; +}; + +export interface ConversationEngineConnection { + mapSnapshot(snapshot: unknown): ConversationSnapshot; + subscribe(input: ConversationSubscribeInput): Promise; + listPermissions(input: { sessionId: string; directory?: string }): Promise; + replyPermission(input: { + permission: ConversationPermission; + reply: "once" | "always" | "reject"; + directory?: string; + }): Promise; + listQuestions(input: { sessionId: string; directory?: string }): Promise; + replyQuestion(input: { + question: ConversationQuestion; + answers: string[][]; + directory?: string; + }): Promise; + create(directory?: string): Promise; + abort(sessionId: string, directory?: string): Promise; + revert(sessionId: string, messageId: string): Promise; + fork(input: { + sessionId: string; + messageId: string | null; + messages: UIMessage[]; + }): Promise; + rename(sessionId: string, title: string, directory?: string): Promise; + setArchived(sessionId: string, archived: boolean, directory?: string): Promise; + shell(sessionId: string, command: string): Promise; + runCommand(input: { + sessionId: string; + command: string; + arguments: string; + model?: ModelRef; + directory?: string; + reasoningEffort?: string; + }): Promise; + sendPrompt(input: ConversationPromptInput): Promise; + listCommands(directory?: string): Promise; + listAgents(): Promise; + searchFiles(query: string, directory?: string): Promise; +} + +export interface ConversationEngineAdapter { + readonly id: string; + connect(input: { + baseUrl: string; + token?: string; + directory?: string; + }): ConversationEngineConnection; +} + +export class ConversationEngineAdapterRegistry { + readonly #adapters: ReadonlyMap; + readonly #defaultEngineId: string; + + constructor(defaultEngineId: string, adapters: readonly ConversationEngineAdapter[]) { + this.#defaultEngineId = defaultEngineId; + const entries = new Map(); + for (const adapter of adapters) { + const id = adapter.id.trim(); + if (!id) throw new Error("Conversation engine adapter ID is required"); + if (entries.has(id)) throw new Error(`Duplicate conversation engine adapter: ${id}`); + entries.set(id, adapter); + } + this.#adapters = entries; + } + + get(id?: string | null): ConversationEngineAdapter { + const resolved = id?.trim() || this.#defaultEngineId; + const adapter = this.#adapters.get(resolved); + if (!adapter) throw new Error(`Conversation engine is not registered: ${resolved}`); + return adapter; + } + + ids(): string[] { + return [...this.#adapters.keys()]; + } +} diff --git a/apps/app/src/react-app/domains/session/engine/opencode-conversation-engine.ts b/apps/app/src/react-app/domains/session/engine/opencode-conversation-engine.ts new file mode 100644 index 000000000..cadcfe50e --- /dev/null +++ b/apps/app/src/react-app/domains/session/engine/opencode-conversation-engine.ts @@ -0,0 +1,199 @@ +import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; + +import { createClient, unwrap } from "@/app/lib/opencode"; +import type { Client } from "@/app/types"; +import { + ConversationEngineAdapterRegistry, + type ConversationEngineAdapter, + type ConversationEngineConnection, + type ConversationPermission, +} from "./conversation-engine"; +import { + isOpenCodeV2Permission as isV2Permission, + mapOpenCodeConversationEvent as mapEvent, + mapOpenCodeConversationSnapshot as mapSnapshot, + mapOpenCodeLegacyPermission as mapLegacyPermission, + mapOpenCodeQuestion as mapQuestion, + mapOpenCodeSession as mapSession, + mapOpenCodeV2Permission as mapV2Permission, + resolveOpenCodeForkBoundaryId, +} from "./opencode-conversation-mapper"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasMethod(value: unknown, name: string) { + return isRecord(value) && typeof value[name] === "function"; +} + +function isOpenCodeClient(value: unknown): value is Client { + if (!isRecord(value)) return false; + return ( + isRecord(value.session) && + hasMethod(value.session, "abort") && + hasMethod(value.session, "promptAsync") && + isRecord(value.event) && + hasMethod(value.event, "subscribe") + ); +} + +function openCodeConnection(input: { baseUrl: string; token?: string; directory?: string }): ConversationEngineConnection { + const client = createClient(input.baseUrl, input.directory, { + token: input.token, + mode: "ipollowork", + }); + if (!isOpenCodeClient(client)) throw new Error("OpenCode conversation client is unavailable"); + + return { + mapSnapshot, + async subscribe(input) { + const subscription = await client.event.subscribe(undefined, { signal: input.signal }); + for await (const raw of subscription.stream) { + if (input.signal.aborted) return; + const event = mapEvent(raw); + if (event) input.onEvent(event); + } + }, + async listPermissions(input) { + const receivedAt = Date.now(); + const permissions: ConversationPermission[] = []; + let readSucceeded = false; + try { + permissions.push( + ...unwrap(await client.permission.list({ directory: input.directory })) + .map((permission) => mapLegacyPermission(permission, receivedAt)), + ); + readSucceeded = true; + } catch {} + try { + permissions.push( + ...unwrap(await client.v2.session.permission.list({ sessionID: input.sessionId })).data + .map((permission) => mapV2Permission(permission, receivedAt)), + ); + readSucceeded = true; + } catch {} + if (!readSucceeded) throw new Error("Could not read pending permissions"); + return permissions.filter((permission) => permission.sessionId === input.sessionId); + }, + async replyPermission(input) { + if (isV2Permission(input.permission.native)) { + const result = await client.v2.session.permission.reply({ + sessionID: input.permission.sessionId, + requestID: input.permission.id, + reply: input.reply, + }); + if (result.error !== undefined) unwrap(result); + return; + } + unwrap(await client.permission.reply({ + requestID: input.permission.id, + reply: input.reply, + directory: input.directory, + })); + }, + async listQuestions(input) { + const receivedAt = Date.now(); + return unwrap(await client.question.list({ directory: input.directory })) + .filter((question) => question.sessionID === input.sessionId) + .map((question) => mapQuestion(question, receivedAt)); + }, + async replyQuestion(input) { + unwrap(await client.question.reply({ + requestID: input.question.id, + answers: input.answers, + directory: input.directory, + })); + }, + async create(directory) { + return mapSession(unwrap(await client.session.create({ directory }))); + }, + async abort(sessionId, directory) { + return unwrap(await client.session.abort({ sessionID: sessionId, directory })) === true; + }, + async revert(sessionId, messageId) { + return mapSession(unwrap(await client.session.revert({ sessionID: sessionId, messageID: messageId }))); + }, + async fork(input) { + return mapSession(unwrap(await client.session.fork({ + sessionID: input.sessionId, + messageID: resolveOpenCodeForkBoundaryId(input.messages, input.messageId) ?? undefined, + }))); + }, + async rename(sessionId, title, directory) { + unwrap(await client.session.update({ sessionID: sessionId, title, directory })); + }, + async setArchived(sessionId, archived, directory) { + unwrap(await client.session.update({ + sessionID: sessionId, + directory, + time: { archived: archived ? Date.now() : 0 }, + })); + }, + async shell(sessionId, command) { + const result = await client.session.shell({ sessionID: sessionId, command }); + if (result.error !== undefined) unwrap(result); + }, + async runCommand(input) { + const result = await client.session.command({ + sessionID: input.sessionId, + command: input.command, + arguments: input.arguments, + model: input.model ? `${input.model.providerID}/${input.model.modelID}` : undefined, + directory: input.directory, + ...(input.reasoningEffort ? { reasoning_effort: input.reasoningEffort } : {}), + }); + if (result.error !== undefined) unwrap(result); + }, + async sendPrompt(input) { + const result = await client.session.promptAsync({ + sessionID: input.sessionId, + parts: input.parts, + model: input.model, + agent: input.agent, + ...(input.reasoningEffort + ? { reasoning_effort: input.reasoningEffort } + : input.variant + ? { variant: input.variant } + : {}), + ...(input.system ? { system: input.system } : {}), + }); + if (result.error !== undefined) unwrap(result); + }, + async listCommands(directory) { + try { + const list = (await client.command.list({ directory }))?.data ?? []; + if (!Array.isArray(list)) return []; + return list.map((command) => ({ + id: `cmd:${command.name}`, + name: String(command.name ?? ""), + description: command.description ? String(command.description) : undefined, + source: command.source, + })); + } catch { + return []; + } + }, + async listAgents() { + return unwrap(await client.app.agents()).map((agent) => ({ + name: agent.name, + description: agent.description, + hidden: agent.hidden, + mode: agent.mode, + })); + }, + async searchFiles(query, directory) { + return unwrap(await client.find.files({ query, dirs: "true", limit: 50, directory })); + }, + }; +} + +export const openCodeConversationEngineAdapter: ConversationEngineAdapter = { + id: DEFAULT_ENGINE_ID, + connect: openCodeConnection, +}; + +export const conversationEngineAdapters = new ConversationEngineAdapterRegistry( + DEFAULT_ENGINE_ID, + [openCodeConversationEngineAdapter], +); diff --git a/apps/app/src/react-app/domains/session/engine/opencode-conversation-mapper.ts b/apps/app/src/react-app/domains/session/engine/opencode-conversation-mapper.ts new file mode 100644 index 000000000..69a96125d --- /dev/null +++ b/apps/app/src/react-app/domains/session/engine/opencode-conversation-mapper.ts @@ -0,0 +1,351 @@ +import type { UIMessage } from "ai"; +import type { + Part, + PermissionRequest, + PermissionV2Request, + QuestionRequest, + Session, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client"; + +import type { iPolloWorkSessionSnapshot } from "@/app/lib/ipollowork-server"; +import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX, type TodoItem } from "@/app/types"; +import { normalizeEvent } from "@/app/utils"; +import type { + ConversationEvent, + ConversationPermission, + ConversationQuestion, + ConversationSession, + ConversationSnapshot, + ConversationStatus, +} from "./conversation-engine"; +import { + describeOpencodeSessionError, + mapOpencodePartToUIParts, + opencodePartHasVisibleAssistantOutput, + snapshotToUIMessages, +} from "./opencode-message-adapter"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function mapOpenCodeSession(session: Session): ConversationSession { + return { + ...session, + id: session.id, + title: session.title ?? "", + slug: session.slug, + parentID: session.parentID, + directory: session.directory, + time: session.time, + revertMessageId: session.revert?.messageID ?? null, + }; +} + +function mapStatus(status: SessionStatus): ConversationStatus { + if (status.type === "retry") { + return { + type: "retry", + attempt: status.attempt, + message: status.message, + next: status.next, + }; + } + return { type: status.type }; +} + +function mapTodos(sessionId: string, todos: Todo[]): TodoItem[] { + return todos.map((todo, index) => ({ + id: `${sessionId}:${index}:${todo.content}`, + content: todo.content, + status: todo.status, + priority: todo.priority, + })); +} + +function v2PermissionKind(action: string): string { + if (action === "external_directory" || action.endsWith(".external_directory")) return "external_directory"; + if (action === "file.read") return "read"; + if (action === "file.edit" || action === "file.write") return "edit"; + return action; +} + +export function mapOpenCodeLegacyPermission( + permission: PermissionRequest, + receivedAt: number, +): ConversationPermission { + return { + id: permission.id, + sessionId: permission.sessionID, + kind: permission.permission, + resources: permission.patterns, + remember: Array.isArray(permission.always) + ? permission.always.filter((value): value is string => typeof value === "string") + : [], + metadata: permission.metadata ?? {}, + receivedAt, + native: permission, + }; +} + +export function mapOpenCodeV2Permission( + permission: PermissionV2Request, + receivedAt: number, +): ConversationPermission { + const metadata: Record = { + ...(permission.metadata ?? {}), + action: permission.action, + }; + if (permission.save?.length) metadata.save = permission.save.join(", "); + if (permission.source) metadata.tool = { + messageID: permission.source.messageID, + callID: permission.source.callID, + }; + return { + id: permission.id, + sessionId: permission.sessionID, + kind: v2PermissionKind(permission.action), + resources: permission.resources, + remember: permission.save ?? [], + metadata, + receivedAt, + native: permission, + }; +} + +export function isOpenCodeV2Permission(value: unknown): value is PermissionV2Request { + return isRecord(value) && typeof value.action === "string" && Array.isArray(value.resources); +} + +export function mapOpenCodeQuestion( + question: QuestionRequest, + receivedAt: number, +): ConversationQuestion { + return { + id: question.id, + sessionId: question.sessionID, + questions: question.questions.map((item) => ({ + header: item.header, + question: item.question, + options: item.options.map((option) => ({ + label: option.label, + description: option.description, + })), + multiple: item.multiple, + custom: item.custom, + })), + receivedAt, + native: question, + }; +} + +function messageFromInfo(info: { + id: string; + role: UIMessage["role"]; + time?: { created?: number; completed?: number }; +}): UIMessage { + const created = info.time?.created; + const completed = info.time?.completed; + return { + id: info.id, + role: info.role, + ...(typeof created === "number" || typeof completed === "number" + ? { metadata: { ipollowork: { + ...(typeof created === "number" ? { created } : {}), + ...(typeof completed === "number" ? { completed } : {}), + } } } + : {}), + parts: [], + }; +} + +function sessionIdFromProperties(properties: unknown) { + if (!isRecord(properties)) return ""; + return typeof properties.sessionID === "string" ? properties.sessionID : ""; +} + +export function mapOpenCodeConversationEvent(raw: unknown): ConversationEvent | null { + const event = normalizeEvent(raw); + if (!event) return null; + const properties = event.properties; + + if (event.type === "session.updated") { + if (!isRecord(properties) || !isRecord(properties.info)) return null; + const info = properties.info as unknown as Session; + const sessionId = typeof properties.sessionID === "string" ? properties.sessionID : info.id; + return sessionId ? { type: "session.updated", sessionId, info: mapOpenCodeSession(info) } : null; + } + + if (event.type === "session.deleted") { + if (!isRecord(properties)) return null; + const info = isRecord(properties.info) ? properties.info : null; + const sessionId = typeof properties.sessionID === "string" + ? properties.sessionID + : typeof info?.id === "string" + ? info.id + : ""; + return sessionId ? { type: "session.deleted", sessionId } : null; + } + + if (event.type === "session.error") { + const sessionId = sessionIdFromProperties(properties); + if (!sessionId) return null; + return { + type: "session.error", + sessionId, + errorText: describeOpencodeSessionError(isRecord(properties) ? properties.error : undefined), + }; + } + + if (event.type === "session.next.compaction.started") { + const sessionId = sessionIdFromProperties(properties); + return sessionId ? { type: "session.compaction", sessionId, running: true } : null; + } + + if (event.type === "session.next.compaction.ended" || event.type === "session.compacted") { + const sessionId = sessionIdFromProperties(properties); + return sessionId ? { type: "session.compaction", sessionId, running: false } : null; + } + + if (event.type === "session.status") { + if (!isRecord(properties) || typeof properties.sessionID !== "string" || !isRecord(properties.status)) return null; + return { + type: "session.status", + sessionId: properties.sessionID, + status: mapStatus(properties.status as SessionStatus), + }; + } + + if (event.type === "session.idle") { + const sessionId = sessionIdFromProperties(properties); + return sessionId ? { type: "session.idle", sessionId } : null; + } + + if (event.type === "todo.updated") { + if (!isRecord(properties) || typeof properties.sessionID !== "string" || !Array.isArray(properties.todos)) return null; + return { + type: "todo.updated", + sessionId: properties.sessionID, + todos: mapTodos(properties.sessionID, properties.todos as Todo[]), + }; + } + + if (event.type === "permission.asked") { + const permission = properties as PermissionRequest; + if (!permission?.id || !permission.sessionID) return null; + return { type: "permission.asked", permission: mapOpenCodeLegacyPermission(permission, Date.now()) }; + } + + if (event.type === "permission.v2.asked") { + const permission = properties as PermissionV2Request; + if (!permission?.id || !permission.sessionID) return null; + return { type: "permission.asked", permission: mapOpenCodeV2Permission(permission, Date.now()) }; + } + + if (event.type === "permission.replied" || event.type === "permission.v2.replied") { + if (!isRecord(properties) || typeof properties.sessionID !== "string" || typeof properties.requestID !== "string") return null; + return { type: "permission.replied", sessionId: properties.sessionID, requestId: properties.requestID }; + } + + if (event.type === "question.asked") { + const question = properties as QuestionRequest; + if (!question?.id || !question.sessionID) return null; + return { type: "question.asked", question: mapOpenCodeQuestion(question, Date.now()) }; + } + + if (event.type === "question.replied" || event.type === "question.rejected") { + if (!isRecord(properties) || typeof properties.sessionID !== "string" || typeof properties.requestID !== "string") return null; + return { type: "question.replied", sessionId: properties.sessionID, requestId: properties.requestID }; + } + + if (event.type === "message.updated") { + if (!isRecord(properties) || !isRecord(properties.info)) return null; + const info = properties.info; + if ( + typeof info.id !== "string" || + typeof info.sessionID !== "string" || + (info.role !== "user" && info.role !== "assistant" && info.role !== "system") + ) return null; + return { + type: "message.upsert", + sessionId: info.sessionID, + message: messageFromInfo({ + id: info.id, + role: info.role, + time: isRecord(info.time) + ? { + created: typeof info.time.created === "number" ? info.time.created : undefined, + completed: typeof info.time.completed === "number" ? info.time.completed : undefined, + } + : undefined, + }), + }; + } + + if (event.type === "message.removed") { + if (!isRecord(properties) || typeof properties.sessionID !== "string" || typeof properties.messageID !== "string") return null; + return { type: "message.removed", sessionId: properties.sessionID, messageId: properties.messageID }; + } + + if (event.type === "message.part.updated") { + if (!isRecord(properties) || !isRecord(properties.part)) return null; + const part = properties.part as unknown as Part; + if (!part.id || !part.sessionID || !part.messageID) return null; + return { + type: "message.parts", + sessionId: part.sessionID, + messageId: part.messageID, + partId: part.id, + parts: mapOpencodePartToUIParts(part), + visibleAssistantOutput: opencodePartHasVisibleAssistantOutput(part), + }; + } + + if (event.type === "message.part.delta") { + if ( + !isRecord(properties) || + typeof properties.sessionID !== "string" || + typeof properties.messageID !== "string" || + typeof properties.partID !== "string" || + typeof properties.delta !== "string" || + !properties.delta + ) return null; + return { + type: "message.chunk", + sessionId: properties.sessionID, + messageId: properties.messageID, + chunk: { type: "text-delta", id: properties.partID, delta: properties.delta }, + }; + } + + return null; +} + +export function mapOpenCodeConversationSnapshot(snapshot: unknown): ConversationSnapshot { + const source = snapshot as iPolloWorkSessionSnapshot; + if (!source?.session?.id || !Array.isArray(source.messages) || !Array.isArray(source.todos)) { + throw new Error("OpenCode returned an invalid session snapshot"); + } + return { + session: mapOpenCodeSession(source.session), + messages: snapshotToUIMessages(source), + todos: mapTodos(source.session.id, source.todos), + status: mapStatus(source.status), + }; +} + +export function resolveOpenCodeForkBoundaryId( + messages: UIMessage[], + messageId: string | null, +): string | null { + if (!messageId) return null; + const index = messages.findIndex((message) => message.id === messageId); + if (index < 0) return null; + for (let candidateIndex = index + 1; candidateIndex < messages.length; candidateIndex += 1) { + const candidate = messages[candidateIndex]; + if (candidate && !candidate.id.startsWith(SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX)) return candidate.id; + } + return null; +} diff --git a/apps/app/src/react-app/domains/session/sync/usechat-adapter.ts b/apps/app/src/react-app/domains/session/engine/opencode-message-adapter.ts similarity index 75% rename from apps/app/src/react-app/domains/session/sync/usechat-adapter.ts rename to apps/app/src/react-app/domains/session/engine/opencode-message-adapter.ts index 5757acf1a..32465f101 100644 --- a/apps/app/src/react-app/domains/session/sync/usechat-adapter.ts +++ b/apps/app/src/react-app/domains/session/engine/opencode-message-adapter.ts @@ -13,7 +13,7 @@ import { parseDynamicToolUIPart, parseStructuredOutputUIPart, STRUCTURED_OUTPUT_TOOL, -} from "./parse-tool-parts"; +} from "./opencode-tool-parts"; function recordValue(value: unknown, key: string) { if (!value || typeof value !== "object") return undefined; @@ -107,21 +107,21 @@ export function createSessionErrorUIMessage(turnKey: string, text: string, optio return { id, role: "assistant", - ...(typeof created === "number" ? { metadata: { opencode: { created } } } : {}), + ...(typeof created === "number" ? { metadata: { ipollowork: { created } } } : {}), parts: [{ type: "text", text, state: "done", - providerMetadata: { opencode: { partId: `${id}:text` } }, + providerMetadata: { ipollowork: { partId: `${id}:text` } }, }], }; } function fileProviderMetadata(part: FilePart) { if (part.source) { - return { opencode: { partId: part.id, source: part.source } }; + return { ipollowork: { partId: part.id, source: part.source } }; } - return { opencode: { partId: part.id } }; + return { ipollowork: { partId: part.id } }; } function getTextPartValue(part: Part) { @@ -149,7 +149,7 @@ function mapFileSourcePart(part: FilePart): UIMessage["parts"][number] | null { if (!source) return null; const sourceId = `${part.id}:source`; - const providerMetadata = { opencode: { partId: sourceId, sourcePartId: part.id, source } }; + const providerMetadata = { ipollowork: { partId: sourceId, sourcePartId: part.id, source } }; if (source.type === "resource") { if (source.uri.startsWith("http://")) { @@ -174,6 +174,72 @@ function mapFileParts(part: FilePart): UIMessage["parts"] { return [mapFilePart(part)]; } +export function opencodePartHasVisibleAssistantOutput(part: Part) { + if (part.type === "text" && part.synthetic) return false; + if (part.type === "text" && part.ignored) return false; + const partType = String(part.type); + if ("text" in part && typeof part.text === "string" && part.text.trim().length > 0) return true; + return partType === "tool" || partType === "file" || partType === "agent"; +} + +export function mapOpencodePartToUIParts(part: Part): UIMessage["parts"] { + if (part.type === "text") { + if (part.synthetic) { + const metadataParts: UIMessage["parts"] = []; + const selection = parseDesignAiSelectionDisplayMetadata(part.text); + if (selection) metadataParts.push({ + type: "data-design-selection" as const, + data: { ...selection, partId: `${part.id}:design-selection` }, + }); + const animations = parseHyperframesAnimationDisplayMetadata(part.text); + if (animations) metadataParts.push({ + type: "data-animation-references" as const, + data: { items: animations, partId: `${part.id}:animation-references` }, + }); + const voice = parseVideoVoiceDisplayMetadata(part.text); + if (voice) metadataParts.push({ + type: "data-voice-reference" as const, + data: { ...voice, partId: `${part.id}:voice-reference` }, + }); + const illustration = parseVideoIllustrationDisplayMetadata(part.text); + if (illustration) metadataParts.push({ + type: "data-illustration-reference" as const, + data: { ...illustration, partId: `${part.id}:illustration-reference` }, + }); + return metadataParts; + } + if (part.ignored) return []; + return [{ + type: "text", + text: getTextPartValue(part), + state: "done", + providerMetadata: { ipollowork: { partId: part.id } }, + }]; + } + if (part.type === "reasoning") { + return [{ + type: "reasoning", + text: getTextPartValue(part), + state: "done", + providerMetadata: { ipollowork: { partId: part.id } }, + }]; + } + if (part.type === "file") return mapFileParts(part); + if (part.type === "tool") return mapSnapshotToolParts(part); + if (part.type === "agent") { + return [{ + type: "text", + text: part.name ? `@${part.name}` : "@agent", + state: "done", + providerMetadata: { ipollowork: { partId: part.id } }, + }]; + } + if (part.type === "step-start") { + return [{ type: "step-start" }]; + } + return []; +} + function mapSnapshotToolParts(part: ToolPart): UIMessage["parts"] { if (part.tool === STRUCTURED_OUTPUT_TOOL) { const mapped = parseStructuredOutputUIPart(part); @@ -200,72 +266,12 @@ export function snapshotToUIMessages(snapshot: iPolloWorkSessionSnapshot): UIMes id: message.info.id, role: message.info.role, ...(typeof created === "number" || typeof completed === "number" - ? { metadata: { opencode: { + ? { metadata: { ipollowork: { ...(typeof created === "number" ? { created } : {}), ...(typeof completed === "number" ? { completed } : {}), } } } : {}), - parts: message.parts.flatMap((part) => { - if (part.type === "text") { - if (part.synthetic) { - const metadataParts: UIMessage["parts"] = []; - const selection = parseDesignAiSelectionDisplayMetadata(part.text); - if (selection) metadataParts.push({ - type: "data-design-selection" as const, - data: { ...selection, partId: `${part.id}:design-selection` }, - }); - const animations = parseHyperframesAnimationDisplayMetadata(part.text); - if (animations) metadataParts.push({ - type: "data-animation-references" as const, - data: { items: animations, partId: `${part.id}:animation-references` }, - }); - const voice = parseVideoVoiceDisplayMetadata(part.text); - if (voice) metadataParts.push({ - type: "data-voice-reference" as const, - data: { ...voice, partId: `${part.id}:voice-reference` }, - }); - const illustration = parseVideoIllustrationDisplayMetadata(part.text); - if (illustration) metadataParts.push({ - type: "data-illustration-reference" as const, - data: { ...illustration, partId: `${part.id}:illustration-reference` }, - }); - return metadataParts; - } - if (part.ignored) return []; - return [{ - type: "text", - text: getTextPartValue(part), - state: "done" as const, - providerMetadata: { opencode: { partId: part.id } }, - }]; - } - if (part.type === "reasoning") { - return [{ - type: "reasoning", - text: getTextPartValue(part), - state: "done" as const, - providerMetadata: { opencode: { partId: part.id } }, - }]; - } - if (part.type === "file") { - return mapFileParts(part); - } - if (part.type === "tool") { - return mapSnapshotToolParts(part); - } - if (part.type === "agent") { - return [{ - type: "text", - text: part.name ? `@${part.name}` : "@agent", - state: "done", - providerMetadata: { opencode: { partId: part.id } }, - }]; - } - if (part.type === "step-start") { - return [{ type: "step-start", providerMetadata: { opencode: { partId: part.id } } }]; - } - return []; - }), + parts: message.parts.flatMap(mapOpencodePartToUIParts), }; // Surface a failed turn as its own synthetic error message keyed by the diff --git a/apps/app/src/react-app/domains/session/sync/parse-tool-parts.ts b/apps/app/src/react-app/domains/session/engine/opencode-tool-parts.ts similarity index 85% rename from apps/app/src/react-app/domains/session/sync/parse-tool-parts.ts rename to apps/app/src/react-app/domains/session/engine/opencode-tool-parts.ts index 5e7662a1b..5f9ac9353 100644 --- a/apps/app/src/react-app/domains/session/sync/parse-tool-parts.ts +++ b/apps/app/src/react-app/domains/session/engine/opencode-tool-parts.ts @@ -1,5 +1,5 @@ -import type { DynamicToolUIPart, TextUIPart } from "ai"; import type { ToolPart } from "@opencode-ai/sdk/v2/client"; +import type { DynamicToolUIPart, TextUIPart } from "ai"; import { safeStringify } from "@/app/utils"; @@ -28,7 +28,7 @@ export function parseStructuredOutputUIPart(part: ToolPart): TextUIPart | null { type: "text", text, state: part.state.status === "completed" ? "done" : "streaming", - providerMetadata: { opencode: { partId: `structured-output-${part.callID}`, toolPartId: part.id } }, + providerMetadata: { ipollowork: { partId: `structured-output-${part.callID}`, toolPartId: part.id } }, }; } @@ -45,7 +45,7 @@ export function parseDynamicToolUIPart(part: ToolPart): DynamicToolUIPart | null state: "output-error", input: part.state.input, errorText: part.state.error, - callProviderMetadata: { opencode: { partId: part.id } }, + callProviderMetadata: { ipollowork: { partId: part.id } }, }; } @@ -57,7 +57,7 @@ export function parseDynamicToolUIPart(part: ToolPart): DynamicToolUIPart | null state: "output-available", input: part.state.input, output: part.state.output, - callProviderMetadata: { opencode: { partId: part.id } }, + callProviderMetadata: { ipollowork: { partId: part.id } }, }; } @@ -73,6 +73,6 @@ export function parseDynamicToolUIPart(part: ToolPart): DynamicToolUIPart | null toolCallId: part.callID, state: "input-streaming", input: part.state.input, - callProviderMetadata: { opencode: { partId: part.id } }, + callProviderMetadata: { ipollowork: { partId: part.id } }, }; } diff --git a/apps/app/src/react-app/domains/session/git/git-panel.tsx b/apps/app/src/react-app/domains/session/git/git-panel.tsx new file mode 100644 index 000000000..c57aa9e03 --- /dev/null +++ b/apps/app/src/react-app/domains/session/git/git-panel.tsx @@ -0,0 +1,277 @@ +/** @jsxImportSource react */ +import { useEffect, useMemo, useState } from "react"; +import { GitBranch, GitCommitHorizontal, Loader2, RefreshCw, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { isElectronRuntime } from "../../../../app/utils"; +import { + layoutGraph, + shortSha, + shortRef, + type GraphCommit, + type GraphRef, + type LayoutCommit, +} from "./graph-layout"; + +const LANE_WIDTH = 26; +const ROW_HEIGHT = 26; +const RADIUS = 7; +const MAX_COMMITS = 1200; + +type GraphData = { + ok: boolean; + isRepo: boolean; + error?: string; + count?: number; + totalCount?: number | null; + truncated?: boolean; + commits?: GraphCommit[]; + refs?: GraphRef[]; + headShas?: string[]; +}; + +function commitMessage(sha: string): string { + return shortSha(sha); +} + +type GitPanelProps = { + workspaceRoot: string; + onClose?: () => void; +}; + +export function GitPanel({ workspaceRoot, onClose }: GitPanelProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedSha, setSelectedSha] = useState(null); + + const fetchGraph = () => { + if (!isElectronRuntime()) { + setData({ ok: false, isRepo: false, error: "Git panel is available in the desktop app." }); + setLoading(false); + return; + } + const graph = window.__IPOLLOWORK_ELECTRON__?.git?.graph; + if (!graph) { + setData({ ok: false, isRepo: false, error: "Git bridge is unavailable." }); + setLoading(false); + return; + } + setLoading(true); + void graph({ cwd: workspaceRoot, maxCommits: MAX_COMMITS }) + .then((result) => { + setData(result as GraphData); + setSelectedSha(null); + }) + .catch((error) => { + setData({ ok: false, isRepo: false, error: error instanceof Error ? error.message : "Could not read git graph." }); + }) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + fetchGraph(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceRoot]); + + const { rows, laneCount } = useMemo(() => { + if (!data?.ok || !data.commits) return { rows: [] as LayoutCommit[], laneCount: 0 }; + return layoutGraph(data.commits, data.refs ?? []); + }, [data]); + + const width = Math.max(320, laneCount * LANE_WIDTH + 24); + + const edgeLines = useMemo(() => { + if (!rows.length) return []; + const lines: { key: string; d: string; stub?: boolean }[] = []; + const rowBySha = new Map(rows.map((r) => [r.sha, r.row])); + const laneBySha = new Map(rows.map((r) => [r.sha, r.lane])); + for (const commit of rows) { + for (const parent of commit.parents) { + const childX = commit.lane * LANE_WIDTH + LANE_WIDTH / 2; + const childY = commit.row * ROW_HEIGHT + ROW_HEIGHT / 2; + const parentRow = rowBySha.get(parent); + if (parentRow === undefined) { + // Parent lies outside the fetched window (truncated history). + // Draw a short dangling stub downward to signal the cut. + const stubEnd = childY + 12; + lines.push({ + key: `${commit.sha}-${parent}`, + d: `M ${childX} ${childY} L ${childX} ${stubEnd}`, + stub: true, + }); + continue; + } + const parentLane = laneBySha.get(parent); + if (parentLane === undefined) continue; + const parentX = parentLane * LANE_WIDTH + LANE_WIDTH / 2; + const parentY = parentRow * ROW_HEIGHT + ROW_HEIGHT / 2; + if (commit.lane === parentLane) { + lines.push({ key: `${commit.sha}-${parent}`, d: `M ${childX} ${childY} L ${parentX} ${parentY}` }); + } else { + const midY = childY + (parentY - childY) / 2; + lines.push({ + key: `${commit.sha}-${parent}`, + d: `M ${childX} ${childY} V ${midY} H ${parentX} V ${parentY}`, + }); + } + } + } + return lines; + }, [rows]); + + const selected = selectedSha ? rows.find((r) => r.sha === selectedSha) : null; + + return ( +
+
+
+ + Git 图谱 + 泳道 +
+
+ + {onClose ? ( + + ) : null} +
+
+ + {data?.ok && data.truncated && data.totalCount !== undefined && data.totalCount !== null ? ( +
+ + + 历史已截断:显示前 {data.count} 条,仓库共有 {data.totalCount} 条提交。虚线表示截断边界。 + +
+ ) : null} + +
+
+ {loading ? ( +
+ + 读取 Git 图谱… +
+ ) : !data?.ok ? ( +
+

+ {data?.isRepo === false ? "当前目录不是 Git 仓库。" : data?.error ?? "无法读取 Git 图谱。"} +

+ {data?.error ?

{data.error}

: null} +
+ ) : rows.length === 0 ? ( +

没有可显示的提交。

+ ) : ( +
+ + {edgeLines.map((line) => ( + + ))} + {rows.map((commit) => { + const x = commit.lane * LANE_WIDTH + LANE_WIDTH / 2; + const y = commit.row * ROW_HEIGHT + ROW_HEIGHT / 2; + const isHead = data.headShas?.includes(commit.sha) ?? false; + const isSelected = selectedSha === commit.sha; + const fill = isHead ? "var(--color-ring)" : isSelected ? "var(--color-primary)" : "var(--color-muted-foreground)"; + return ( + setSelectedSha(isSelected ? null : commit.sha)} + style={{ cursor: "pointer" }} + > + + + + ); + })} + +
+ {rows.map((commit) => { + const x = commit.lane * LANE_WIDTH + LANE_WIDTH / 2; + const y = commit.row * ROW_HEIGHT + ROW_HEIGHT / 2; + const isHead = data.headShas?.includes(commit.sha) ?? false; + const isSelected = selectedSha === commit.sha; + return ( + + ); + })} +
+
+ )} +
+ + +
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/git/graph-layout.ts b/apps/app/src/react-app/domains/session/git/graph-layout.ts new file mode 100644 index 000000000..1fbae38b0 --- /dev/null +++ b/apps/app/src/react-app/domains/session/git/graph-layout.ts @@ -0,0 +1,144 @@ +/** + * Pure git-graph swimlane layout. + * + * Input is the commit DAG produced by the Electron main process + * (`git rev-list --parents --all`) plus ref → commit mapping. This module is + * deliberately framework-free so the layout algorithm can be unit-tested + * without React/DOM. + */ + +export type GraphCommit = { + sha: string; + parents: string[]; +}; + +export type GraphRef = { + sha: string; + refname: string; + head: boolean; +}; + +export type LayoutCommit = GraphCommit & { + row: number; + lane: number; + refs: GraphRef[]; +}; + +export type GraphLayout = { + rows: LayoutCommit[]; + laneCount: number; +}; + +/** + * Greedy lane assignment based on parent inheritance: + * - a commit inherits its FIRST parent's lane (linear history stays in one + * column); + * - the second+ parent of a merge opens a new lane; + * - a lane whose owner has already been placed can be reused for a fresh + * branch. + * + * Invariants: + * - every row index equals its position in the input order (0..n-1); + * - every lane index is a non-negative integer in [0, laneCount); + * - a commit's lane never changes once assigned; + * - every commit with at least one parent inherits that parent's lane unless + * the parent's lane is still owned by an unplaced commit and a second + * parent forces a fork. + */ +export function layoutGraph(commits: GraphCommit[], refs: GraphRef[] = []): GraphLayout { + const refsBySha = new Map(); + for (const ref of refs) { + const list = refsBySha.get(ref.sha) ?? []; + list.push(ref); + refsBySha.set(ref.sha, list); + } + + const laneOf = new Map(); + const laneOwner: (string | undefined)[] = []; + const placed = new Set(); + let laneCount = 0; + + const freeLaneOrNew = () => { + for (let i = 0; i < laneCount; i++) { + const owner = laneOwner[i]; + if (owner === undefined || placed.has(owner)) return i; + } + const lane = laneCount++; + laneOwner.push(undefined); + return lane; + }; + + const claimLane = (sha: string) => { + const existing = laneOf.get(sha); + if (existing !== undefined) return existing; + const lane = freeLaneOrNew(); + laneOf.set(sha, lane); + laneOwner[lane] = sha; + return lane; + }; + + // A fresh lane that never reuses a sibling's lane; used only when a commit + // forks away from a parent whose lane is already occupied by a sibling. + const claimFreshLane = () => { + const lane = laneCount++; + laneOwner.push(undefined); + return lane; + }; + + const rows: LayoutCommit[] = []; + commits.forEach((commit, row) => { + const firstParent = commit.parents[0]; + let lane: number; + const alreadyAssigned = laneOf.get(commit.sha); + if (alreadyAssigned !== undefined) { + // The commit was reserved as a parent earlier; keep its lane. + lane = alreadyAssigned; + } else if ( + firstParent !== undefined + && laneOf.has(firstParent) + && laneOwner[laneOf.get(firstParent)!] === firstParent + ) { + // Inherit the first parent's lane (linear continuation) — but only if + // the parent still occupies that lane. A sibling branch that already + // inherited it forces this branch into its own fresh lane. + lane = laneOf.get(firstParent)!; + } else if (firstParent !== undefined && laneOf.has(firstParent)) { + // Parent's lane is held by a sibling branch; fork to a fresh lane so + // the two branches remain visually distinct. + lane = claimFreshLane(); + } else { + lane = freeLaneOrNew(); + } + placed.add(commit.sha); + laneOf.set(commit.sha, lane); + laneOwner[lane] = commit.sha; + rows.push({ + ...commit, + row, + lane, + refs: refsBySha.get(commit.sha) ?? [], + }); + // Reserve lanes for parents not yet assigned. The first parent shares + // this lane (unless it already has one); second+ parents fork to new + // lanes so merges become visible. + commit.parents.forEach((parent, index) => { + if (laneOf.has(parent)) return; + if (index === 0) { + laneOf.set(parent, lane); + laneOwner[lane] = parent; + } else { + claimLane(parent); + } + }); + }); + + return { rows, laneCount }; +} + +export function shortSha(sha: string): string { + return sha.slice(0, 7); +} + +export function shortRef(refname: string): string { + return refname.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\//, ""); +} diff --git a/apps/app/src/react-app/domains/session/modals/question-modal.tsx b/apps/app/src/react-app/domains/session/modals/question-modal.tsx index b0dcfc395..9c7fc616f 100644 --- a/apps/app/src/react-app/domains/session/modals/question-modal.tsx +++ b/apps/app/src/react-app/domains/session/modals/question-modal.tsx @@ -1,13 +1,13 @@ /** @jsxImportSource react */ import { useEffect, useReducer } from "react"; -import type { QuestionInfo } from "@opencode-ai/sdk/v2/client"; import { Check, ChevronRight, HelpCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { t } from "@/i18n"; +import type { ConversationQuestionInfo } from "../engine/conversation-engine"; export type QuestionPanelProps = { - questions: QuestionInfo[]; + questions: ConversationQuestionInfo[]; busy: boolean; onReply: (answers: string[][]) => void; }; diff --git a/apps/app/src/react-app/domains/session/modals/use-model-picker.ts b/apps/app/src/react-app/domains/session/modals/use-model-picker.ts index 053828013..0609ca0ad 100644 --- a/apps/app/src/react-app/domains/session/modals/use-model-picker.ts +++ b/apps/app/src/react-app/domains/session/modals/use-model-picker.ts @@ -22,6 +22,7 @@ import { t } from "@/i18n"; export type UseModelPickerInput = { client: Client | null; + engineId?: string | null; baseUrl: string; workspaceRoot: string; /** Optional: surface option-load failures (settings shows a toast; the session route stays silent). */ @@ -29,7 +30,7 @@ export type UseModelPickerInput = { }; export function useModelPicker(input: UseModelPickerInput) { - const { client, baseUrl, workspaceRoot, onLoadError } = input; + const { client, engineId, baseUrl, workspaceRoot, onLoadError } = input; const checkDesktopRestriction = useCheckDesktopRestriction(); const [open, setOpen] = useState(false); @@ -83,6 +84,7 @@ export function useModelPicker(input: UseModelPickerInput) { try { const data = await ensureProviderListQuery(getReactQueryClient(), { client, + engineId, baseUrl, directory: workspaceRoot || undefined, }); @@ -131,7 +133,7 @@ export function useModelPicker(input: UseModelPickerInput) { return () => { cancelled = true; }; - }, [open, baseUrl, client, recentProviderIds, workspaceRoot]); + }, [open, baseUrl, client, engineId, recentProviderIds, workspaceRoot]); // Apply org-level restrictions (dev #1505) on top of the raw model list // so the picker never surfaces blocked options: diff --git a/apps/app/src/react-app/domains/session/ops/ops-panel.tsx b/apps/app/src/react-app/domains/session/ops/ops-panel.tsx new file mode 100644 index 000000000..3f73967c1 --- /dev/null +++ b/apps/app/src/react-app/domains/session/ops/ops-panel.tsx @@ -0,0 +1,383 @@ +/** @jsxImportSource react */ +import { useEffect, useRef, useState } from "react"; +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import { Loader2, Plus, RefreshCw, Server, TerminalSquare, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { isElectronRuntime } from "../../../../app/utils"; +import { normalizeSshTarget } from "./ops-utils"; + +type OpsSession = { + id: string; + label: string; + target: string; + terminalId: string | null; + exited: boolean; +}; + +type OpsTerminalProps = { + session: OpsSession; + onStatus: (sessionId: string, status: string) => void; + onExit: (sessionId: string) => void; + onRequestFocus: () => void; +}; + +function OpsTerminal({ session, onStatus, onExit, onRequestFocus }: OpsTerminalProps) { + const containerRef = useRef(null); + const terminalIdRef = useRef(null); + const terminalRef = useRef(null); + const fitRef = useRef(null); + const startedRef = useRef(false); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + if (!isElectronRuntime()) { + onStatus(session.id, "Terminal is available in the desktop app."); + return; + } + if (startedRef.current) return; + startedRef.current = true; + + const bridge = window.__IPOLLOWORK_ELECTRON__?.terminal; + if (!bridge?.create || !bridge.write || !bridge.resize || !bridge.kill || !bridge.onData || !bridge.onExit) { + onStatus(session.id, "Terminal bridge is unavailable."); + return; + } + const createTerminal = bridge.create; + const writeTerminal = bridge.write; + const resizeTerminal = bridge.resize; + const killTerminal = bridge.kill; + const onTerminalData = bridge.onData; + const onTerminalExit = bridge.onExit; + + let disposed = false; + const fitAddon = new FitAddon(); + const terminal = new Terminal({ + cursorBlink: true, + convertEol: true, + fontFamily: "'SFMono-Regular', 'Cascadia Code', 'Liberation Mono', Menlo, monospace", + fontSize: 12, + theme: { + background: "#0b0d12", + foreground: "#d7dde8", + cursor: "#ffffff", + selectionBackground: "#334155", + }, + }); + terminal.loadAddon(fitAddon); + terminal.open(container); + terminal.focus(); + fitAddon.fit(); + terminalRef.current = terminal; + fitRef.current = fitAddon; + + const removeDataListener = onTerminalData(({ terminalId, data }) => { + if (terminalIdRef.current !== terminalId) return; + terminal.write(data); + }); + const removeExitListener = onTerminalExit(({ terminalId }) => { + if (terminalIdRef.current !== terminalId) return; + terminalIdRef.current = null; + onExit(session.id); + }); + const inputDisposable = terminal.onData((data) => { + const terminalId = terminalIdRef.current; + if (!terminalId) return; + void writeTerminal(terminalId, data); + }); + + const fitAndResize = () => { + fitAddon.fit(); + const terminalId = terminalIdRef.current; + if (!terminalId) return; + void resizeTerminal(terminalId, terminal.cols, terminal.rows); + }; + const resizeObserver = new ResizeObserver(fitAndResize); + resizeObserver.observe(container); + + onStatus(session.id, `Connecting to ${session.target}…`); + void createTerminal({ + cwd: "/", + cols: terminal.cols, + rows: terminal.rows, + command: ["ssh", "-t", session.target], + }) + .then(({ terminalId }) => { + if (disposed) { + void killTerminal(terminalId); + return; + } + terminalIdRef.current = terminalId; + onStatus(session.id, session.target); + fitAndResize(); + }) + .catch((error) => { + onStatus(session.id, error instanceof Error ? error.message : "Could not start SSH session."); + }); + + return () => { + disposed = true; + resizeObserver.disconnect(); + inputDisposable.dispose(); + removeDataListener(); + removeExitListener(); + const terminalId = terminalIdRef.current; + terminalIdRef.current = null; + if (terminalId) void killTerminal(terminalId); + terminal.dispose(); + terminalRef.current = null; + fitRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session.id]); + + return ( +
+
+
+ ); +} + +type OpsPanelProps = { + onClose?: () => void; +}; + +export function OpsPanel({ onClose }: OpsPanelProps) { + const [sessions, setSessions] = useState([]); + const [statusBySession, setStatusBySession] = useState>({}); + const [activeSessionId, setActiveSessionId] = useState(null); + const [hosts, setHosts] = useState([]); + const [hostsLoading, setHostsLoading] = useState(true); + const [configPath, setConfigPath] = useState("~/.ssh/config"); + const [quickTarget, setQuickTarget] = useState(""); + const [connecting, setConnecting] = useState(false); + const [loadError, setLoadError] = useState(null); + + const refreshHosts = () => { + if (!isElectronRuntime()) { + setLoadError("Ops panel is available in the desktop app."); + setHostsLoading(false); + return; + } + const listHosts = window.__IPOLLOWORK_ELECTRON__?.ssh?.listHosts; + if (!listHosts) { + setLoadError("SSH bridge is unavailable."); + setHostsLoading(false); + return; + } + setHostsLoading(true); + void listHosts() + .then(({ hosts: hostList, configPath: config }) => { + setHosts(hostList ?? []); + setConfigPath(config ?? "~/.ssh/config"); + setLoadError(null); + }) + .catch((error) => { + setLoadError(error instanceof Error ? error.message : "Could not read SSH config."); + }) + .finally(() => setHostsLoading(false)); + }; + + useEffect(() => { + refreshHosts(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const addSession = (target: string) => { + const clean = normalizeSshTarget(target); + if (!clean) return; + const id = `ops_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; + setSessions((prev) => [ + ...prev, + { id, label: clean, target: clean, terminalId: null, exited: false }, + ]); + setActiveSessionId(id); + setQuickTarget(""); + }; + + const connectQuick = () => { + const target = quickTarget.trim(); + if (!target || connecting) return; + setConnecting(true); + // Defer so the terminal pane mounts before the SSH connection starts. + window.setTimeout(() => { + addSession(target); + setConnecting(false); + }, 0); + }; + + const closeSession = (sessionId: string) => { + setSessions((prev) => prev.filter((session) => session.id !== sessionId)); + setStatusBySession((prev) => { + const next = { ...prev }; + delete next[sessionId]; + return next; + }); + setActiveSessionId((current) => { + if (current !== sessionId) return current; + const remaining = sessions.filter((session) => session.id !== sessionId); + return remaining.length ? remaining[remaining.length - 1].id : null; + }); + }; + + const handleStatus = (sessionId: string, status: string) => { + setStatusBySession((prev) => ({ ...prev, [sessionId]: status })); + }; + + const handleExit = (sessionId: string) => { + setStatusBySession((prev) => ({ ...prev, [sessionId]: "Disconnected" })); + }; + + const activeSession = sessions.find((session) => session.id === activeSessionId) ?? null; + + return ( +
+
+
+ + 运维面板 + SSH +
+
+ + {onClose ? ( + + ) : null} +
+
+ +
+ + +
+
+ {sessions.length === 0 ? ( + 没有活动的 SSH 会话 + ) : ( + sessions.map((session) => ( +
+ + +
+ )) + )} +
+ +
+ {activeSession ? ( + <> +
+ +
+ {activeSession ? ( + setActiveSessionId(activeSession.id)} + /> + ) : ( +
+

+ 在左侧选择一台主机,或输入 user@host 开始 SSH 会话。 +

+
+ )} +
+
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/ops/ops-utils.ts b/apps/app/src/react-app/domains/session/ops/ops-utils.ts new file mode 100644 index 000000000..8163d1ee4 --- /dev/null +++ b/apps/app/src/react-app/domains/session/ops/ops-utils.ts @@ -0,0 +1,21 @@ +/** + * Pure helpers for the SSH ops panel. + * + * Framework-free so they can be unit-tested without React/DOM. + */ + +/** Normalize a raw SSH connect target: strip a leading `ssh`/`-t` and whitespace. */ +export function normalizeSshTarget(raw: string): string { + const trimmed = String(raw ?? "").trim(); + if (!trimmed) return ""; + let cleaned = trimmed; + const prefix = /^ssh\s+/; + if (prefix.test(cleaned)) { + cleaned = cleaned.replace(prefix, ""); + } + const flag = /^-t\s+/; + if (flag.test(cleaned)) { + cleaned = cleaned.replace(flag, ""); + } + return cleaned.trim(); +} diff --git a/apps/app/src/react-app/domains/session/scheduled-tasks/cron.ts b/apps/app/src/react-app/domains/session/scheduled-tasks/cron.ts new file mode 100644 index 000000000..a096e44e2 --- /dev/null +++ b/apps/app/src/react-app/domains/session/scheduled-tasks/cron.ts @@ -0,0 +1,170 @@ +// Standard 5-field cron matcher + next-run calculator. Kept dependency-free +// so both the renderer (live preview) and the Electron scheduler can rely on +// the same field semantics. Mirrors the model used by opencode-scheduler and +// opencode-tasks: +// +// ┌───────────── minute (0-59) +// │ ┌───────────── hour (0-23) +// │ │ ┌───────────── day of month (1-31) +// │ │ │ ┌───────────── month (1-12) +// │ │ │ │ ┌───────────── day of week (0-6, Sunday=0; 7 = Sunday) +// │ │ │ │ │ +// * * * * * + +const MAX_SCAN_MINUTES = 366 * 24 * 60 * 4; // 4 years covers the leap-year cycle + +export const CRON_FIELDS = ["minute", "hour", "day", "month", "weekday"] as const; +export type CronFieldName = (typeof CRON_FIELDS)[number]; + +export type CronField = { + min: number; + max: number; + values: Set; +}; + +type CronParse = { + fields: Record; + raw: Record; +}; + +function parseFieldPart(part: string, min: number, max: number, allowSeven: boolean): Set | null { + const values = new Set(); + const add = (n: number) => { + if (n < min || n > max) return false; + if (!allowSeven && n === 7) return false; + values.add(n); + return true; + }; + + for (const raw of part.split(",")) { + const trimmed = raw.trim(); + if (!trimmed) return null; + if (!/^(?:\*|\d+)(?:-\d+)?(?:\/\d+)?$/.test(trimmed)) return null; + + if (trimmed === "*") { + for (let i = min; i <= max; i++) values.add(i); + continue; + } + + const stepMatch = /^(\*|\d+-\d+|\d+)\/(\d+)$/.exec(trimmed); + if (stepMatch) { + const base = stepMatch[1]; + const step = Number.parseInt(stepMatch[2], 10); + if (!Number.isInteger(step) || step <= 0) return null; + + let rangeStart = min; + let rangeEnd = max; + if (base !== "*") { + if (base.includes("-")) { + const [a, b] = base.split("-").map((n) => Number.parseInt(n, 10)); + if (!Number.isInteger(a) || !Number.isInteger(b)) return null; + rangeStart = a; + rangeEnd = b; + } else { + const single = Number.parseInt(base, 10); + if (!Number.isInteger(single)) return null; + rangeStart = single; + rangeEnd = max; + } + } + for (let i = rangeStart; i <= rangeEnd; i += step) { + if (!add(i)) return null; + } + continue; + } + + if (trimmed.includes("-")) { + const [a, b] = trimmed.split("-").map((n) => Number.parseInt(n, 10)); + if (!Number.isInteger(a) || !Number.isInteger(b) || a > b) return null; + for (let i = a; i <= b; i++) { + if (!add(i)) return null; + } + continue; + } + + const single = Number.parseInt(trimmed, 10); + if (!Number.isInteger(single)) return null; + if (!add(single)) return null; + } + + return values; +} + +function buildField(name: CronFieldName, raw: string): CronField | null { + const min = name === "month" || name === "day" ? 1 : 0; + const max = name === "month" ? 12 : name === "day" ? 31 : name === "weekday" ? 7 : name === "hour" ? 23 : 59; + const allowSeven = name === "weekday"; + const values = parseFieldPart(raw, min, max, allowSeven); + if (!values || values.size === 0) return null; + return { min, max, values }; +} + +export function parseCron(expression: string): CronParse | null { + const trimmed = expression.trim(); + const parts = trimmed.split(/\s+/); + if (parts.length !== 5) return null; + + const minute = buildField("minute", parts[0]); + const hour = buildField("hour", parts[1]); + const day = buildField("day", parts[2]); + const month = buildField("month", parts[3]); + const weekday = buildField("weekday", parts[4]); + if (!minute || !hour || !day || !month || !weekday) return null; + + return { + fields: { minute, hour, day, month, weekday }, + raw: { minute: parts[0], hour: parts[1], day: parts[2], month: parts[3], weekday: parts[4] }, + }; +} + +function normalizeWeekday(weekday: number): number { + return weekday === 7 ? 0 : weekday; +} + +function weekdayMatches(field: CronField, date: Date): boolean { + const dow = date.getDay(); + if (field.values.has(normalizeWeekday(dow))) return true; + return field.values.has(7) && dow === 0; +} + +function matchesParsed(parsed: CronParse, date: Date): boolean { + const { fields, raw } = parsed; + + const minute = fields.minute.values.has(date.getMinutes()); + const hour = fields.hour.values.has(date.getHours()); + const month = fields.month.values.has(date.getMonth() + 1); + const day = fields.day.values.has(date.getDate()); + const dow = weekdayMatches(fields.weekday, date); + + const dayRestricted = !raw.day.includes("*"); + const dowRestricted = !raw.weekday.includes("*"); + + const dayMatch = dayRestricted && dowRestricted ? day || dow : day && dow; + return minute && hour && month && dayMatch; +} + +export function cronMatches(expression: string, date: Date): boolean { + const parsed = parseCron(expression); + if (!parsed) return false; + return matchesParsed(parsed, date); +} + +export function isValidCron(expression: string): boolean { + return parseCron(expression) !== null; +} + +/** Next run strictly after `after` (inclusive minute boundary handled by caller). */ +export function nextRunAfter(expression: string, after: Date): Date | null { + const parsed = parseCron(expression); + if (!parsed) return null; + + const cursor = new Date(after.getTime()); + cursor.setSeconds(0, 0); + cursor.setMinutes(cursor.getMinutes() + 1); + + for (let i = 0; i < MAX_SCAN_MINUTES; i++) { + if (matchesParsed(parsed, cursor)) return new Date(cursor.getTime()); + cursor.setMinutes(cursor.getMinutes() + 1); + } + return null; +} diff --git a/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task-templates.ts b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task-templates.ts new file mode 100644 index 000000000..2b90e84d6 --- /dev/null +++ b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task-templates.ts @@ -0,0 +1,63 @@ +export type ScheduledTaskTemplate = { + id: string; + title: string; + description: string; + cron: string; + prompt: string; +}; + +export const SCHEDULED_TASK_TEMPLATES: ScheduledTaskTemplate[] = [ + { + id: "daily-standup", + title: "每日站会摘要", + description: "每个工作日汇总昨日进展、今日计划与风险项。", + cron: "0 9 * * 1-5", + prompt: + "基于当前工作区最近的会话与提交记录,生成一份每日站会摘要:昨日完成的关键进展、今日计划、以及需要关注的风险或阻塞项。用简洁的要点列表呈现。", + }, + { + id: "weekly-report", + title: "每周工作周报", + description: "每周一生成结构化周报。", + cron: "0 8 * * 1", + prompt: + "汇总过去 7 天的工作内容,生成一份结构化周报,包含:本周完成事项、关键数据或产出、下周计划、需要协作或支持的事项。", + }, + { + id: "daily-news", + title: "每日新闻/行业简报", + description: "每天抓取并整理当日资讯简报。", + cron: "0 7 * * *", + prompt: + "收集并整理今日与所在行业相关的新闻与动态,生成一份精炼的每日简报:重要新闻标题与一句话摘要、对业务的潜在影响、值得关注的机会或风险。", + }, + { + id: "data-report", + title: "数据日报", + description: "定时汇总关键数据指标。", + cron: "0 9 * * *", + prompt: + "汇总当前工作区可获取的关键数据指标,生成一份数据日报:核心指标数值、环比变化、异常波动说明、以及简要结论。", + }, + { + id: "meeting-followup", + title: "会议纪要跟进", + description: "定时提醒并跟进会议待办事项。", + cron: "0 10 * * 1-5", + prompt: + "回顾最近的会议纪要,提取其中尚未完成的待办事项与责任人,生成一份跟进清单:待办事项、责任人、截止时间、当前状态与下一步建议。", + }, + { + id: "todo-scan", + title: "提醒/待办巡检", + description: "定时检查待办与到期事项并提醒。", + cron: "0 */6 * * *", + prompt: + "巡检当前工作区内的待办事项与到期日程,找出即将到期或已逾期的事项,生成提醒清单并给出优先级建议。", + }, +]; + +export function templateById(id: string | null | undefined): ScheduledTaskTemplate | null { + if (!id) return null; + return SCHEDULED_TASK_TEMPLATES.find((t) => t.id === id) ?? null; +} diff --git a/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task.ts b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task.ts new file mode 100644 index 000000000..3ada675f4 --- /dev/null +++ b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task.ts @@ -0,0 +1,36 @@ +export type ScheduledTaskStatus = "ok" | "error" | "skipped"; + +export type ScheduledTask = { + id: string; + name: string; + description: string; + cron: string; + workspaceId: string; + prompt: string; + enabled: boolean; + templateId: string | null; + createdAt: number; + lastRunAt: number | null; + lastRunStatus: ScheduledTaskStatus | null; + nextRunAt?: number | null; +}; + +export type ScheduledTaskLogEntry = { + at: number; + status: ScheduledTaskStatus; + message: string; +}; + +export type ScheduledTaskCreateInput = { + name?: string; + description?: string; + cron?: string; + workspaceId?: string; + prompt?: string; + enabled?: boolean; + templateId?: string | null; +}; + +export type ScheduledTaskUpdatePatch = Partial< + Pick +>; diff --git a/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-tasks-panel.tsx b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-tasks-panel.tsx new file mode 100644 index 000000000..50ccb41c9 --- /dev/null +++ b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-tasks-panel.tsx @@ -0,0 +1,368 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { CalendarClock, Clock, Loader2, Play, Plus, RefreshCw, Trash2, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { isElectronRuntime } from "../../../../app/utils"; +import { isValidCron, nextRunAfter } from "./cron"; +import { SCHEDULED_TASK_TEMPLATES, type ScheduledTaskTemplate } from "./scheduled-task-templates"; +import type { ScheduledTask, ScheduledTaskLogEntry } from "./scheduled-task"; + +type ScheduledTasksPanelProps = { + workspaceRoot: string; + onClose?: () => void; +}; + +type Draft = { + id: string | null; + name: string; + description: string; + cron: string; + prompt: string; + templateId: string | null; +}; + +function emptyDraft(): Draft { + return { id: null, name: "", description: "", cron: "", prompt: "", templateId: null }; +} + +function draftFromTemplate(template: ScheduledTaskTemplate): Draft { + return { + id: null, + name: template.title, + description: template.description, + cron: template.cron, + prompt: template.prompt, + templateId: template.id, + }; +} + +function draftFromTask(task: ScheduledTask): Draft { + return { + id: task.id, + name: task.name, + description: task.description, + cron: task.cron, + prompt: task.prompt, + templateId: task.templateId, + }; +} + +function formatTime(value: number | null | undefined): string { + if (!value) return "—"; + return new Date(value).toLocaleString(); +} + +function cronHint(expression: string): string { + if (!expression.trim()) return "请输入 5 段 cron 表达式"; + if (!isValidCron(expression)) return "无效的 cron 表达式"; + const next = nextRunAfter(expression, new Date()); + return next ? `下次运行:${new Date(next).toLocaleString()}` : "无法计算下次运行时间"; +} + +export function ScheduledTasksPanel({ workspaceRoot, onClose }: ScheduledTasksPanelProps) { + const bridge = typeof window !== "undefined" ? window.__IPOLLOWORK_ELECTRON__?.scheduledTasks : undefined; + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [draft, setDraft] = useState(null); + const [logs, setLogs] = useState([]); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + if (!bridge?.list) { + setTasks([]); + setLoading(false); + return; + } + try { + const result = await bridge.list(); + setTasks(result ?? []); + } finally { + setLoading(false); + } + }, [bridge]); + + useEffect(() => { + void refresh(); + const off = bridge?.onChanged?.(() => { + void refresh(); + }); + return () => off?.(); + }, [bridge, refresh]); + + const selectedTask = useMemo( + () => tasks.find((task) => task.id === selectedId) ?? null, + [tasks, selectedId], + ); + + useEffect(() => { + if (!selectedTask || !bridge?.logs) { + setLogs([]); + return; + } + void bridge.logs(selectedTask.id).then((result) => setLogs(result ?? [])); + }, [selectedTask, bridge]); + + const openNew = () => { + setSelectedId(null); + setDraft(null); + setLogs([]); + }; + + const openEdit = (task: ScheduledTask) => { + setSelectedId(task.id); + setDraft(draftFromTask(task)); + }; + + const pickTemplate = (template: ScheduledTaskTemplate) => { + setSelectedId(null); + setDraft(draftFromTemplate(template)); + }; + + const save = async () => { + if (!draft || !bridge?.create || !bridge?.update) return; + if (!draft.name.trim() || !isValidCron(draft.cron) || !draft.prompt.trim()) return; + setBusy(true); + try { + const input = { + name: draft.name, + description: draft.description, + cron: draft.cron, + prompt: draft.prompt, + workspaceId: workspaceRoot, + templateId: draft.templateId, + }; + const saved = draft.id + ? await bridge.update(draft.id, input) + : await bridge.create(input); + await refresh(); + if (saved) { + setSelectedId(saved.id); + setDraft(draftFromTask(saved)); + } + } finally { + setBusy(false); + } + }; + + const toggleEnabled = async (task: ScheduledTask, enabled: boolean) => { + if (!bridge?.setEnabled) return; + await bridge.setEnabled(task.id, enabled); + void refresh(); + }; + + const runNow = async (task: ScheduledTask) => { + if (!bridge?.runNow) return; + setBusy(true); + try { + await bridge.runNow(task.id); + await refresh(); + } finally { + setBusy(false); + } + }; + + const remove = async (task: ScheduledTask) => { + if (!bridge?.remove) return; + await bridge.remove(task.id); + setSelectedId(null); + setDraft(null); + void refresh(); + }; + + const available = Boolean(bridge); + const cronValue = draft?.cron ?? ""; + + return ( +
+
+
+ + 定时任务 + {tasks.length} 个 +
+
+ + {onClose ? ( + + ) : null} +
+
+ + {!available ? ( +
+ 定时任务面板仅在桌面应用可用。 +
+ ) : ( +
+ + +
+ {!draft ? ( +
+

选择模板

+
+ {SCHEDULED_TASK_TEMPLATES.map((template) => ( + + ))} +
+
+ ) : ( +
+
+
+ + setDraft({ ...draft, name: event.target.value })} + placeholder="例如:每日站会摘要" + /> +
+
+ + setDraft({ ...draft, cron: event.target.value })} + placeholder="0 9 * * 1-5" + className="font-mono" + /> +

+ {cronHint(cronValue)} +

+
+
+ +