From 06b9ad9fb5be882717c1b3ebc12ec276fcf02bb9 Mon Sep 17 00:00:00 2001 From: Oleksandr Zabolotnyi Date: Mon, 10 Aug 2026 21:12:50 +0200 Subject: [PATCH 1/2] feat(jcode): add jcode provider, project board, and stdio MCP bridge Ship jcode as an ACP provider with shared project board todos, a web Board panel, and a stdio MCP bridge so jcode can use board_* tools without ACP mcpServers. Co-authored-by: Cursor --- apps/mobile/src/components/ProviderIcon.tsx | 182 ++- apps/mobile/src/features/home/HomeScreen.tsx | 1 + .../threads/ThreadNavigationSidebar.tsx | 1 + .../features/threads/thread-list-v2-items.tsx | 9 +- apps/server/src/bin.ts | 23 +- apps/server/src/keybindings.test.ts | 1 + apps/server/src/mcp/McpHttpServer.ts | 7 + apps/server/src/mcp/McpInvocationContext.ts | 2 +- apps/server/src/mcp/McpSessionRegistry.ts | 2 +- .../src/mcp/jcodeMcpStdioBridge.test.ts | 28 + apps/server/src/mcp/jcodeMcpStdioBridge.ts | 265 ++++ .../server/src/mcp/toolkits/board/handlers.ts | 207 +++ apps/server/src/mcp/toolkits/board/tools.ts | 97 ++ .../Layers/OrchestrationEngine.ts | 2 + .../Layers/ProjectionPipeline.ts | 41 + .../Layers/ProjectionSnapshotQuery.ts | 9 + .../Layers/ProviderCommandReactor.ts | 19 +- apps/server/src/orchestration/Schemas.ts | 4 + .../decider.projectBoard.test.ts | 240 +++ apps/server/src/orchestration/decider.ts | 75 + .../orchestration/projectBoardPrompt.test.ts | 50 + .../src/orchestration/projectBoardPrompt.ts | 31 + apps/server/src/orchestration/projector.ts | 54 + .../persistence/Layers/ProjectionProjects.ts | 8 +- apps/server/src/persistence/Migrations.ts | 2 + .../041_ProjectionProjectsBoardItems.ts | 16 + .../Services/ProjectionProjects.ts | 2 + .../src/provider/Drivers/JcodeDriver.ts | 163 ++ .../src/provider/Layers/JcodeAdapter.ts | 1345 +++++++++++++++++ .../src/provider/Layers/JcodeProvider.test.ts | 110 ++ .../src/provider/Layers/JcodeProvider.ts | 335 ++++ .../src/provider/Services/JcodeAdapter.ts | 16 + .../src/provider/acp/AcpSessionRuntime.ts | 24 +- .../src/provider/acp/JcodeAcpSupport.test.ts | 74 + .../src/provider/acp/JcodeAcpSupport.ts | 106 ++ apps/server/src/provider/builtInDrivers.ts | 3 + .../src/provider/jcodeMcpConfig.test.ts | 92 ++ apps/server/src/provider/jcodeMcpConfig.ts | 134 ++ .../src/textGeneration/JcodeTextGeneration.ts | 246 +++ .../src/textGeneration/TextGeneration.ts | 8 +- apps/server/src/ws.ts | 2 + apps/web/index.html | 45 + apps/web/package.json | 1 + apps/web/src/assets/jcode-favicon.svg | 113 ++ apps/web/src/components/ChatView.tsx | 32 +- apps/web/src/components/CommandPalette.tsx | 21 + apps/web/src/components/Icons.tsx | 121 ++ apps/web/src/components/ProjectBoardPanel.tsx | 244 +++ apps/web/src/components/RightPanelTabs.tsx | 22 +- apps/web/src/components/Sidebar.tsx | 72 +- .../src/components/SidebarStageBackdrop.tsx | 173 +-- .../src/components/chat/JcodeAsciiIdle.tsx | 220 +++ .../components/chat/ThreadProviderIcons.tsx | 96 ++ .../components/chat/jcodeAsciiTheme.test.ts | 45 + .../src/components/chat/jcodeAsciiTheme.ts | 83 + .../src/components/chat/providerIconUtils.ts | 3 +- .../src/components/settings/ThemeSettings.tsx | 6 + .../components/settings/providerDriverMeta.ts | 18 +- apps/web/src/index.css | 20 +- apps/web/src/lib/utils.ts | 5 +- apps/web/src/rightPanelStore.ts | 6 +- apps/web/src/session-logic.ts | 6 + apps/web/src/themeBoot.test.ts | 14 +- apps/web/src/themePalette.test.ts | 14 +- apps/web/src/themePalette.ts | 63 + docs/README.md | 1 + docs/internals/jcode-provider.md | 106 ++ docs/internals/project-board.md | 39 + docs/user/install.md | 15 +- docs/user/project-board.md | 26 + docs/user/providers-jcode.md | 73 + .../client-runtime/src/operations/commands.ts | 20 + .../src/state/projectCommands.ts | 18 + packages/contracts/src/baseSchemas.ts | 2 + packages/contracts/src/keybindings.ts | 1 + packages/contracts/src/model.ts | 4 + packages/contracts/src/orchestration.ts | 73 + packages/contracts/src/previewAutomation.ts | 2 +- packages/contracts/src/settings.ts | 59 + packages/shared/package.json | 4 + .../shared/src/jcodeInnerProvider.test.ts | 53 + packages/shared/src/jcodeInnerProvider.ts | 93 ++ packages/shared/src/keybindings.ts | 1 + pnpm-lock.yaml | 9 + 84 files changed, 5921 insertions(+), 157 deletions(-) create mode 100644 apps/server/src/mcp/jcodeMcpStdioBridge.test.ts create mode 100644 apps/server/src/mcp/jcodeMcpStdioBridge.ts create mode 100644 apps/server/src/mcp/toolkits/board/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/board/tools.ts create mode 100644 apps/server/src/orchestration/decider.projectBoard.test.ts create mode 100644 apps/server/src/orchestration/projectBoardPrompt.test.ts create mode 100644 apps/server/src/orchestration/projectBoardPrompt.ts create mode 100644 apps/server/src/persistence/Migrations/041_ProjectionProjectsBoardItems.ts create mode 100644 apps/server/src/provider/Drivers/JcodeDriver.ts create mode 100644 apps/server/src/provider/Layers/JcodeAdapter.ts create mode 100644 apps/server/src/provider/Layers/JcodeProvider.test.ts create mode 100644 apps/server/src/provider/Layers/JcodeProvider.ts create mode 100644 apps/server/src/provider/Services/JcodeAdapter.ts create mode 100644 apps/server/src/provider/acp/JcodeAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/JcodeAcpSupport.ts create mode 100644 apps/server/src/provider/jcodeMcpConfig.test.ts create mode 100644 apps/server/src/provider/jcodeMcpConfig.ts create mode 100644 apps/server/src/textGeneration/JcodeTextGeneration.ts create mode 100644 apps/web/src/assets/jcode-favicon.svg create mode 100644 apps/web/src/components/ProjectBoardPanel.tsx create mode 100644 apps/web/src/components/chat/JcodeAsciiIdle.tsx create mode 100644 apps/web/src/components/chat/ThreadProviderIcons.tsx create mode 100644 apps/web/src/components/chat/jcodeAsciiTheme.test.ts create mode 100644 apps/web/src/components/chat/jcodeAsciiTheme.ts create mode 100644 docs/internals/jcode-provider.md create mode 100644 docs/internals/project-board.md create mode 100644 docs/user/project-board.md create mode 100644 docs/user/providers-jcode.md create mode 100644 packages/shared/src/jcodeInnerProvider.test.ts create mode 100644 packages/shared/src/jcodeInnerProvider.ts diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index bdddf2c45951..5fae9f961a75 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,17 +1,29 @@ import { useColorScheme } from "react-native"; -import { Path, Svg } from "react-native-svg"; +import { Circle, Path, Svg } from "react-native-svg"; +import { + resolveJcodeInnerProviderIconKind, + type JcodeInnerProviderIconKind, +} from "@t3tools/shared/jcodeInnerProvider"; +import { View } from "react-native"; type ProviderIconProps = { readonly provider: string | null | undefined; readonly size?: number; + /** Selected model slug — used to pick a nested LLM brand when provider is jcode. */ + readonly model?: string | null | undefined; + /** jcode `-p` setting when known. */ + readonly jcodeProvider?: string | null | undefined; }; -export function ProviderIcon(props: ProviderIconProps) { - const isDarkMode = useColorScheme() === "dark"; - const size = props.size ?? 16; - const mono = isDarkMode ? "#e5e5e5" : "#171717"; +function BrandGlyph(props: { + readonly kind: string; + readonly size: number; + readonly mono: string; + readonly isDarkMode: boolean; +}) { + const { kind, size, mono, isDarkMode } = props; - if (props.provider === "claudeAgent") { + if (kind === "claudeAgent") { return ( @@ -38,7 +50,7 @@ export function ProviderIcon(props: ProviderIconProps) { ); } - if (props.provider === "cursor") { + if (kind === "cursor") { return ( @@ -58,6 +70,122 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (kind === "jcode") { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + } + // codex (and unknown drivers) return ( @@ -68,3 +196,39 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + +function innerKindToProvider(kind: JcodeInnerProviderIconKind): string { + return kind; +} + +export function ProviderIcon(props: ProviderIconProps) { + const isDarkMode = useColorScheme() === "dark"; + const size = props.size ?? 16; + const mono = isDarkMode ? "#e5e5e5" : "#171717"; + const provider = props.provider ?? "codex"; + + const innerKind = + provider === "jcode" + ? resolveJcodeInnerProviderIconKind({ + jcodeProvider: props.jcodeProvider, + model: props.model, + }) + : null; + + if (!innerKind) { + return ; + } + + const nestedSize = Math.max(10, Math.round(size * 0.85)); + return ( + + + + + ); +} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 64f0480d2231..df3dbaca449d 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -796,6 +796,7 @@ export function HomeScreen(props: HomeScreenProps) { (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), )?.driver ?? null } + providerModel={thread.modelSelection.model} environmentLabel={ Object.keys(props.savedConnectionsById).length > 1 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index d80d906ada17..15cf3f10697b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -930,6 +930,7 @@ function ThreadNavigationSidebarPane( (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), )?.driver ?? null } + providerModel={thread.modelSelection.model} environmentLabel={ Object.keys(savedConnectionsById).length > 1 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 17b6a4ea965a..83a1e4ddb2af 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -321,6 +321,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly project: EnvironmentProject | null; readonly projectTitle?: string; readonly providerDriver: string | null; + readonly providerModel?: string | null; + readonly jcodeProvider?: string | null; /** Which machine hosts the thread. Null when only one environment is connected — repeating the same label on every row is noise. Mirrors the web sidebar's remote-environment cloud icon, but as text since @@ -737,7 +739,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} {props.providerDriver ? ( - + ) : null} diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index d1bdcf90997c..6a9360446c3e 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -62,9 +62,22 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => export const cli = makeCli(); if (import.meta.main) { - Command.run(cli, { version: packageJson.version }).pipe( - Effect.scoped, - Effect.provide(CliRuntimeLayer), - NodeRuntime.runMain, - ); + // Hidden stdio MCP entry for jcode (see provider/jcodeMcpConfig.ts). Must run + // before Effect CLI so stdin stays available for NDJSON framing. + if (process.argv[2] === "__jcode-mcp-stdio") { + const { runJcodeMcpStdioBridge } = await import("./mcp/jcodeMcpStdioBridge.ts"); + try { + await runJcodeMcpStdioBridge(); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + process.stderr.write(`t3 jcode MCP bridge failed: ${detail}\n`); + process.exit(1); + } + } else { + Command.run(cli, { version: packageJson.version }).pipe( + Effect.scoped, + Effect.provide(CliRuntimeLayer), + NodeRuntime.runMain, + ); + } } diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index b674688f0410..e26b56ce6402 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -203,6 +203,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); + assert.equal(defaultsByCommand.get("board.toggle"), "mod+shift+b"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 87975a49de2c..f8c238f07596 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -13,6 +13,8 @@ import packageJson from "../../package.json" with { type: "json" }; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; +import { BoardToolkitHandlersLive } from "./toolkits/board/handlers.ts"; +import { BoardToolkit } from "./toolkits/board/tools.ts"; import { PreviewSnapshotToolkitHandlersLive, PreviewStandardToolkitHandlersLive, @@ -211,9 +213,14 @@ const PreviewSnapshotRegistrationLive = Layer.effectDiscard(registerPreviewSnaps Layer.provide(PreviewSnapshotToolkitHandlersLive), ); +const BoardToolkitRegistrationLive = McpServer.toolkit(BoardToolkit).pipe( + Layer.provide(BoardToolkitHandlersLive), +); + export const PreviewToolkitRegistrationLive = Layer.mergeAll( PreviewStandardToolkitRegistrationLive, PreviewSnapshotRegistrationLive, + BoardToolkitRegistrationLive, ); const McpTransportLive = McpServer.layerHttp({ diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 49273485a44d..ee28aaf1b65e 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -7,7 +7,7 @@ import { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -export type McpCapability = "preview"; +export type McpCapability = "preview" | "board"; export interface McpInvocationScope { readonly environmentId: EnvironmentId; diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index f19a4f4e8c49..27f856acbf2b 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -128,7 +128,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), - capabilities: new Set(["preview"]), + capabilities: new Set(["preview", "board"]), issuedAt, }; yield* SynchronizedRef.update(state, ({ records }) => { diff --git a/apps/server/src/mcp/jcodeMcpStdioBridge.test.ts b/apps/server/src/mcp/jcodeMcpStdioBridge.test.ts new file mode 100644 index 000000000000..111580fcea8f --- /dev/null +++ b/apps/server/src/mcp/jcodeMcpStdioBridge.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseHttpMcpResponseBody } from "./jcodeMcpStdioBridge.ts"; + +describe("parseHttpMcpResponseBody", () => { + it("parses application/json bodies", () => { + const parsed = parseHttpMcpResponseBody( + JSON.stringify({ jsonrpc: "2.0", id: 1, result: { ok: true } }), + "application/json", + ); + expect(parsed).toEqual({ jsonrpc: "2.0", id: 1, result: { ok: true } }); + }); + + it("parses SSE data frames", () => { + const body = [ + "event: message", + 'data: {"jsonrpc":"2.0","id":2,"result":{"tools":[]}}', + "", + "data: [DONE]", + "", + ].join("\n"); + expect(parseHttpMcpResponseBody(body, "text/event-stream")).toEqual({ + jsonrpc: "2.0", + id: 2, + result: { tools: [] }, + }); + }); +}); diff --git a/apps/server/src/mcp/jcodeMcpStdioBridge.ts b/apps/server/src/mcp/jcodeMcpStdioBridge.ts new file mode 100644 index 000000000000..d244f88ba537 --- /dev/null +++ b/apps/server/src/mcp/jcodeMcpStdioBridge.ts @@ -0,0 +1,265 @@ +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalFetch:off +// Standalone stdio child process: Node builtins + fetch are the intentional boundary. +/** + * NDJSON stdio MCP bridge for jcode. + * + * jcode only loads stdio MCP from its config files (it rejects ACP mcpServers and + * skips HTTP/SSE entries). This process speaks jcode's newline-delimited JSON-RPC + * on stdin/stdout and proxies tool RPCs to T3's Streamable HTTP `/mcp` endpoint. + * + * Env: + * - T3_MCP_ENDPOINT: absolute URL to the server's `/mcp` route + * - T3_MCP_AUTHORIZATION: full Authorization header value (e.g. "Bearer …") + * - T3_MCP_AUTH_FILE: optional path; when set, authorization is read from this file + */ +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; + +const PROTOCOL_VERSION_FOR_JCODE = "2024-11-05"; +const PROTOCOL_VERSION_FOR_HTTP = "2025-06-18"; + +type JsonRpcId = string | number | null; + +type JsonRpcMessage = { + readonly jsonrpc?: string; + readonly id?: JsonRpcId; + readonly method?: string; + readonly params?: unknown; + readonly result?: unknown; + readonly error?: unknown; +}; + +function writeStdout(message: unknown): void { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function readAuthorization(env: NodeJS.ProcessEnv = process.env): string { + const fromEnv = env.T3_MCP_AUTHORIZATION?.trim(); + if (fromEnv) return fromEnv; + const authFile = env.T3_MCP_AUTH_FILE?.trim(); + if (!authFile) { + throw new Error("Missing T3_MCP_AUTHORIZATION or T3_MCP_AUTH_FILE."); + } + const value = NodeFS.readFileSync(authFile, "utf8").trim(); + if (!value) { + throw new Error(`Authorization file is empty: ${authFile}`); + } + return value; +} + +/** Extract the first JSON-RPC payload from a JSON or SSE HTTP body. */ +export function parseHttpMcpResponseBody(body: string, contentType: string | null): unknown { + const trimmed = body.trim(); + if (!trimmed) { + throw new Error("Empty MCP HTTP response body."); + } + + const isEventStream = (contentType ?? "").toLowerCase().includes("text/event-stream"); + if (!isEventStream && (trimmed.startsWith("{") || trimmed.startsWith("["))) { + return JSON.parse(trimmed) as unknown; + } + + for (const block of trimmed.split("\n\n")) { + const dataLines: string[] = []; + for (const line of block.split("\n")) { + if (line.startsWith("data:")) { + dataLines.push(line.slice("data:".length).trimStart()); + } + } + if (dataLines.length === 0) continue; + const data = dataLines.join("\n").trim(); + if (!data || data === "[DONE]") continue; + return JSON.parse(data) as unknown; + } + + // Last resort: body might still be bare JSON despite a misleading content-type. + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + return JSON.parse(trimmed) as unknown; + } + + throw new Error("Could not parse MCP HTTP response as JSON or SSE."); +} + +class HttpMcpProxy { + #sessionId: string | undefined; + #nextId = 1; + readonly endpoint: string; + readonly authorization: string; + + constructor(endpoint: string, authorization: string) { + this.endpoint = endpoint; + this.authorization = authorization; + } + + async initialize(): Promise { + const response = await this.#post({ + jsonrpc: "2.0", + id: this.#nextId++, + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION_FOR_HTTP, + capabilities: {}, + clientInfo: { name: "t3-jcode-mcp-bridge", version: "1.0.0" }, + }, + }); + if (response.error) { + throw new Error(`MCP initialize failed: ${JSON.stringify(response.error)}`); + } + await this.#postNotification({ + jsonrpc: "2.0", + method: "notifications/initialized", + }); + } + + async request(method: string, params: unknown): Promise { + const response = await this.#post({ + jsonrpc: "2.0", + id: this.#nextId++, + method, + params: params ?? {}, + }); + if (response.error) { + throw new Error(`${method} failed: ${JSON.stringify(response.error)}`); + } + return response.result; + } + + async #post(payload: JsonRpcMessage): Promise { + const headers: Record = { + accept: "application/json, text/event-stream", + "content-type": "application/json", + authorization: this.authorization, + }; + if (this.#sessionId) { + headers["mcp-session-id"] = this.#sessionId; + } + + const res = await fetch(this.endpoint, { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + + const sessionId = res.headers.get("mcp-session-id"); + if (sessionId) { + this.#sessionId = sessionId; + } + + const body = await res.text(); + if (!res.ok) { + throw new Error(`MCP HTTP ${res.status}: ${body.slice(0, 400)}`); + } + + const parsed = parseHttpMcpResponseBody(body, res.headers.get("content-type")); + if (!parsed || typeof parsed !== "object") { + throw new Error("MCP HTTP response was not a JSON object."); + } + return parsed as JsonRpcMessage; + } + + async #postNotification(payload: JsonRpcMessage): Promise { + const headers: Record = { + accept: "application/json, text/event-stream", + "content-type": "application/json", + authorization: this.authorization, + }; + if (this.#sessionId) { + headers["mcp-session-id"] = this.#sessionId; + } + const res = await fetch(this.endpoint, { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + // Some servers return 202/204 for notifications; ignore body. + if (!res.ok && res.status !== 202 && res.status !== 204) { + const body = await res.text(); + throw new Error(`MCP notification HTTP ${res.status}: ${body.slice(0, 400)}`); + } + } +} + +export async function runJcodeMcpStdioBridge(env: NodeJS.ProcessEnv = process.env): Promise { + const endpoint = env.T3_MCP_ENDPOINT?.trim(); + if (!endpoint) { + throw new Error("Missing T3_MCP_ENDPOINT."); + } + + const authorization = readAuthorization(env); + const proxy = new HttpMcpProxy(endpoint, authorization); + await proxy.initialize(); + + const rl = NodeReadline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let message: JsonRpcMessage; + try { + message = JSON.parse(trimmed) as JsonRpcMessage; + } catch { + continue; + } + + const method = message.method; + if (!method) continue; + + // Notifications (no id): acknowledge initialized; ignore the rest. + if (message.id === undefined) { + continue; + } + + try { + if (method === "initialize") { + writeStdout({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: PROTOCOL_VERSION_FOR_JCODE, + capabilities: { tools: {} }, + serverInfo: { name: "t3-code", version: "1.0.0" }, + }, + }); + continue; + } + + if (method === "ping") { + writeStdout({ jsonrpc: "2.0", id: message.id, result: {} }); + continue; + } + + if (method === "shutdown") { + writeStdout({ jsonrpc: "2.0", id: message.id, result: null }); + process.exit(0); + } + + if (method === "tools/list" || method === "tools/call") { + const result = await proxy.request(method, message.params ?? {}); + writeStdout({ jsonrpc: "2.0", id: message.id, result }); + continue; + } + + writeStdout({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32601, message: `Method not found: ${method}` }, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + writeStdout({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32000, message: detail }, + }); + } + } +} + +if (import.meta.main) { + runJcodeMcpStdioBridge().catch((error: unknown) => { + const detail = error instanceof Error ? error.message : String(error); + process.stderr.write(`t3 jcode MCP bridge failed: ${detail}\n`); + process.exit(1); + }); +} diff --git a/apps/server/src/mcp/toolkits/board/handlers.ts b/apps/server/src/mcp/toolkits/board/handlers.ts new file mode 100644 index 000000000000..a2f40921cdc3 --- /dev/null +++ b/apps/server/src/mcp/toolkits/board/handlers.ts @@ -0,0 +1,207 @@ +import { + CommandId, + ProjectBoardItemId, + type ProjectBoardItem, + type ProjectBoardItemStatus, + type ProjectId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { OrchestrationEngineService } from "../../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { BoardToolkit, BoardToolError } from "./tools.ts"; + +function errorMessage(error: { readonly message?: string } | unknown): string { + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ) { + return error.message; + } + return String(error); +} + +const requireBoardScope = Effect.fn("BoardToolkit.requireScope")(function* () { + const invocation = yield* McpInvocationContext.McpInvocationContext; + if (!invocation.capabilities.has("board")) { + return yield* new BoardToolError({ + message: "MCP credential does not grant the board capability.", + }); + } + return invocation; +}); + +const resolveProjectId = Effect.fn("BoardToolkit.resolveProjectId")(function* ( + threadId: McpInvocationContext.McpInvocationScope["threadId"], +) { + const snapshots = yield* ProjectionSnapshotQuery; + const thread = yield* snapshots.getThreadShellById(threadId).pipe( + Effect.mapError( + (error) => + new BoardToolError({ + message: `Failed to resolve thread for board access: ${errorMessage(error)}`, + }), + ), + ); + if (Option.isNone(thread)) { + return yield* new BoardToolError({ + message: `Thread '${threadId}' was not found.`, + }); + } + return thread.value.projectId; +}); + +const loadBoard = Effect.fn("BoardToolkit.loadBoard")(function* (projectId: ProjectId) { + const snapshots = yield* ProjectionSnapshotQuery; + const project = yield* snapshots.getProjectShellById(projectId).pipe( + Effect.mapError( + (error) => + new BoardToolError({ + message: `Failed to load project board: ${errorMessage(error)}`, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new BoardToolError({ + message: `Project '${projectId}' was not found.`, + }); + } + return project.value; +}); + +const nextCommandId = Effect.fn("BoardToolkit.nextCommandId")(function* () { + const crypto = yield* Crypto.Crypto; + const uuid = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + return CommandId.make(`board:${uuid}`); +}); + +const nextItemId = Effect.fn("BoardToolkit.nextItemId")(function* () { + const crypto = yield* Crypto.Crypto; + const uuid = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + return ProjectBoardItemId.make(uuid); +}); + +const dispatchUpsert = Effect.fn("BoardToolkit.dispatchUpsert")(function* (input: { + readonly projectId: ProjectId; + readonly itemId: ProjectBoardItemId; + readonly title: string; + readonly status: ProjectBoardItemStatus; + readonly notes?: string | null | undefined; + readonly sourceThreadId: McpInvocationContext.McpInvocationScope["threadId"]; +}) { + const engine = yield* OrchestrationEngineService; + yield* engine + .dispatch({ + type: "project.board.item.upsert", + commandId: yield* nextCommandId(), + projectId: input.projectId, + itemId: input.itemId, + title: input.title, + status: input.status, + ...(input.notes !== undefined ? { notes: input.notes } : {}), + source: "agent", + sourceThreadId: input.sourceThreadId, + }) + .pipe( + Effect.mapError( + (error) => + new BoardToolError({ + message: errorMessage(error), + }), + ), + ); +}); + +const handlers = { + board_list: (_input: unknown) => + Effect.gen(function* () { + const scope = yield* requireBoardScope(); + const projectId = yield* resolveProjectId(scope.threadId); + const project = yield* loadBoard(projectId); + return { + projectId, + items: project.boardItems ?? [], + }; + }), + + board_upsert: (input: { + readonly itemId?: ProjectBoardItemId | undefined; + readonly title: string; + readonly status: ProjectBoardItemStatus; + readonly notes?: string | null | undefined; + }) => + Effect.gen(function* () { + const scope = yield* requireBoardScope(); + const projectId = yield* resolveProjectId(scope.threadId); + const itemId = input.itemId ?? (yield* nextItemId()); + yield* dispatchUpsert({ + projectId, + itemId, + title: input.title, + status: input.status, + ...(input.notes !== undefined ? { notes: input.notes } : {}), + sourceThreadId: scope.threadId, + }); + const project = yield* loadBoard(projectId); + const item = + (project.boardItems ?? []).find((entry: ProjectBoardItem) => entry.id === itemId) ?? null; + return { projectId, item }; + }), + + board_set_status: (input: { + readonly itemId: ProjectBoardItemId; + readonly status: ProjectBoardItemStatus; + }) => + Effect.gen(function* () { + const scope = yield* requireBoardScope(); + const projectId = yield* resolveProjectId(scope.threadId); + const project = yield* loadBoard(projectId); + const existing = (project.boardItems ?? []).find((entry) => entry.id === input.itemId); + if (!existing) { + return yield* new BoardToolError({ + message: `Board item '${input.itemId}' was not found.`, + }); + } + yield* dispatchUpsert({ + projectId, + itemId: existing.id, + title: existing.title, + status: input.status, + notes: existing.notes ?? null, + sourceThreadId: scope.threadId, + }); + const next = yield* loadBoard(projectId); + const item = (next.boardItems ?? []).find((entry) => entry.id === input.itemId) ?? null; + return { projectId, item }; + }), + + board_delete: (input: { readonly itemId: ProjectBoardItemId }) => + Effect.gen(function* () { + const scope = yield* requireBoardScope(); + const projectId = yield* resolveProjectId(scope.threadId); + const engine = yield* OrchestrationEngineService; + yield* engine + .dispatch({ + type: "project.board.item.delete", + commandId: yield* nextCommandId(), + projectId, + itemId: input.itemId, + }) + .pipe( + Effect.mapError( + (error) => + new BoardToolError({ + message: errorMessage(error), + }), + ), + ); + return { projectId, item: null }; + }), +} satisfies Parameters[0]; + +export const BoardToolkitHandlersLive = BoardToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/board/tools.ts b/apps/server/src/mcp/toolkits/board/tools.ts new file mode 100644 index 000000000000..3f537519c69c --- /dev/null +++ b/apps/server/src/mcp/toolkits/board/tools.ts @@ -0,0 +1,97 @@ +import { + ProjectBoardItem, + ProjectBoardItemId, + ProjectBoardItemStatus, + ProjectId, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { OrchestrationEngineService } from "../../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; + +export class BoardToolError extends Schema.TaggedErrorClass()("BoardToolError", { + message: Schema.String, +}) {} + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + OrchestrationEngineService, + ProjectionSnapshotQuery, + Crypto.Crypto, +]; + +const BoardListResult = Schema.Struct({ + projectId: ProjectId, + items: Schema.Array(ProjectBoardItem), +}); + +const BoardMutateResult = Schema.Struct({ + projectId: ProjectId, + item: Schema.NullOr(ProjectBoardItem), +}); + +export const BoardListTool = Tool.make("board_list", { + description: + "List the project board todos for the current thread's project. These items are shared across all threads in the project.", + parameters: Schema.Struct({}), + success: BoardListResult, + failure: BoardToolError, + dependencies, +}) + .annotate(Tool.Title, "List project board") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + +export const BoardUpsertTool = Tool.make("board_upsert", { + description: + "Create or update a project board todo. Pass itemId to update an existing item; omit it to create a new one. Status is pending | inProgress | completed.", + parameters: Schema.Struct({ + itemId: Schema.optional(ProjectBoardItemId), + title: TrimmedNonEmptyString, + status: ProjectBoardItemStatus, + notes: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + }), + success: BoardMutateResult, + failure: BoardToolError, + dependencies, +}) + .annotate(Tool.Title, "Upsert project board item") + .annotate(Tool.Destructive, false); + +export const BoardSetStatusTool = Tool.make("board_set_status", { + description: "Set the status of an existing project board todo.", + parameters: Schema.Struct({ + itemId: ProjectBoardItemId, + status: ProjectBoardItemStatus, + }), + success: BoardMutateResult, + failure: BoardToolError, + dependencies, +}) + .annotate(Tool.Title, "Set project board item status") + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + +export const BoardDeleteTool = Tool.make("board_delete", { + description: "Delete a project board todo by id.", + parameters: Schema.Struct({ + itemId: ProjectBoardItemId, + }), + success: BoardMutateResult, + failure: BoardToolError, + dependencies, +}) + .annotate(Tool.Title, "Delete project board item") + .annotate(Tool.Destructive, true); + +export const BoardToolkit = Toolkit.make( + BoardListTool, + BoardUpsertTool, + BoardSetStatusTool, + BoardDeleteTool, +); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 19184915ac7c..7fe5c4358a8c 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -64,6 +64,8 @@ function commandToAggregateRef(command: OrchestrationCommand): { case "project.create": case "project.meta.update": case "project.delete": + case "project.board.item.upsert": + case "project.board.item.delete": return { aggregateKind: "project", aggregateId: command.projectId, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e9a625dd91cf..f2043ae9e0ba 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -498,6 +498,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti defaultThreadEnvMode: null, faviconPath: event.payload.faviconPath ?? null, scripts: event.payload.scripts, + boardItems: [], createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, deletedAt: null, @@ -547,6 +548,46 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "project.board-item-upserted": { + const existingRow = yield* projectionProjectRepository.getById({ + projectId: event.payload.projectId, + }); + if (Option.isNone(existingRow)) { + return; + } + const currentItems = existingRow.value.boardItems ?? []; + const existingIndex = currentItems.findIndex((item) => item.id === event.payload.item.id); + const boardItems = + existingIndex === -1 + ? [...currentItems, event.payload.item] + : currentItems.map((item, index) => + index === existingIndex ? event.payload.item : item, + ); + yield* projectionProjectRepository.upsert({ + ...existingRow.value, + boardItems, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "project.board-item-deleted": { + const existingRow = yield* projectionProjectRepository.getById({ + projectId: event.payload.projectId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionProjectRepository.upsert({ + ...existingRow.value, + boardItems: (existingRow.value.boardItems ?? []).filter( + (item) => item.id !== event.payload.itemId, + ), + updatedAt: event.payload.updatedAt, + }); + return; + } + default: return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3e77f9cf875a..f77bc6d7d965 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -11,6 +11,7 @@ import { OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, + ProjectBoardItem, ProjectScript, TurnId, type OrchestrationCheckpointSummary, @@ -73,6 +74,7 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + boardItems: Schema.fromJsonString(Schema.Array(ProjectBoardItem)), }), ); const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( @@ -319,6 +321,7 @@ function mapProjectShellRow( defaultThreadEnvMode: row.defaultThreadEnvMode, faviconPath: row.faviconPath ?? null, scripts: row.scripts, + boardItems: row.boardItems ?? [], createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -396,6 +399,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", favicon_path AS "faviconPath", scripts_json AS "scripts", + board_items_json AS "boardItems", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -851,6 +855,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", favicon_path AS "faviconPath", scripts_json AS "scripts", + board_items_json AS "boardItems", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -875,6 +880,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", favicon_path AS "faviconPath", scripts_json AS "scripts", + board_items_json AS "boardItems", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -1553,6 +1559,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { defaultThreadEnvMode: row.defaultThreadEnvMode, faviconPath: row.faviconPath ?? null, scripts: row.scripts, + boardItems: row.boardItems ?? [], createdAt: row.createdAt, updatedAt: row.updatedAt, deletedAt: row.deletedAt, @@ -1684,6 +1691,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { defaultThreadEnvMode: row.defaultThreadEnvMode, faviconPath: row.faviconPath ?? null, scripts: row.scripts, + boardItems: row.boardItems ?? [], createdAt: row.createdAt, updatedAt: row.updatedAt, deletedAt: row.deletedAt, @@ -2177,6 +2185,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { defaultThreadEnvMode: option.value.defaultThreadEnvMode, faviconPath: option.value.faviconPath ?? null, scripts: option.value.scripts, + boardItems: option.value.boardItems ?? [], createdAt: option.value.createdAt, updatedAt: option.value.updatedAt, deletedAt: option.value.deletedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ff639797179f..dd38d0da631d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -34,6 +34,10 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { + appendProjectBoardToTurnInput, + formatProjectBoardPromptBlock, +} from "../projectBoardPrompt.ts"; import { ProviderCommandReactor, type ProviderCommandReactorShape, @@ -753,7 +757,6 @@ const make = Effect.gen(function* () { if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } - const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService .listSessions() @@ -783,9 +786,21 @@ const make = Effect.gen(function* () { : requestedModelSelection : input.modelSelection; + // jcode loads T3 MCP via a stdio bridge; keep a tiny board hint so the model + // knows board_* tools exist without dumping the full list every turn. + let turnInput = toNonEmptyProviderInput(input.messageText); + if (activeSession?.provider === "jcode") { + const project = yield* projectionSnapshotQuery.getProjectShellById(thread.projectId).pipe( + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ); + const boardBlock = formatProjectBoardPromptBlock(project?.boardItems ?? []); + turnInput = appendProjectBoardToTurnInput(turnInput, boardBlock); + } + return { threadId: input.threadId, - ...(normalizedInput ? { input: normalizedInput } : {}), + ...(turnInput ? { input: turnInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 7e866cf89592..c7d38f949d96 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -2,6 +2,8 @@ import { ProjectCreatedPayload as ContractsProjectCreatedPayloadSchema, ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema, ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, + ProjectBoardItemUpsertedPayload as ContractsProjectBoardItemUpsertedPayloadSchema, + ProjectBoardItemDeletedPayload as ContractsProjectBoardItemDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, ThreadSettledPayload as ContractsThreadSettledPayloadSchema, @@ -33,6 +35,8 @@ import { export const ProjectCreatedPayload = ContractsProjectCreatedPayloadSchema; export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchema; export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; +export const ProjectBoardItemUpsertedPayload = ContractsProjectBoardItemUpsertedPayloadSchema; +export const ProjectBoardItemDeletedPayload = ContractsProjectBoardItemDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.projectBoard.test.ts b/apps/server/src/orchestration/decider.projectBoard.test.ts new file mode 100644 index 000000000000..f258d08be2ae --- /dev/null +++ b/apps/server/src/orchestration/decider.projectBoard.test.ts @@ -0,0 +1,240 @@ +import { + CommandId, + EventId, + PROJECT_BOARD_ITEM_LIMIT, + ProjectBoardItemId, + ProjectId, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const asEventId = (value: string): EventId => EventId.make(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asItemId = (value: string): ProjectBoardItemId => ProjectBoardItemId.make(value); + +function createProjectEvent(input: { + readonly sequence: number; + readonly projectId: ProjectId; + readonly now: string; +}) { + return { + sequence: input.sequence, + eventId: asEventId(`evt-project-create-${input.sequence}`), + aggregateKind: "project" as const, + aggregateId: input.projectId, + type: "project.created" as const, + occurredAt: input.now, + commandId: CommandId.make(`cmd-project-create-${input.sequence}`), + causationEventId: null, + correlationId: CommandId.make(`cmd-project-create-${input.sequence}`), + metadata: {}, + payload: { + projectId: input.projectId, + title: "Board", + workspaceRoot: "/tmp/board", + defaultModelSelection: null, + scripts: [], + createdAt: input.now, + updatedAt: input.now, + }, + }; +} + +it.layer(NodeServices.layer)("decider project board", (it) => { + it.effect("upserts a new board item", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const projectId = asProjectId("project-board"); + const readModel = yield* projectEvent( + createEmptyReadModel(now), + createProjectEvent({ sequence: 1, projectId, now }), + ); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.board.item.upsert", + commandId: CommandId.make("cmd-board-upsert"), + projectId, + itemId: asItemId("item-1"), + title: "Ship board", + status: "pending", + source: "user", + }, + readModel, + }); + + const event = Array.isArray(result) ? result[0] : result; + expect(event?.type).toBe("project.board-item-upserted"); + if (event?.type !== "project.board-item-upserted") { + throw new Error("expected board upsert event"); + } + expect(event.payload.item.title).toBe("Ship board"); + expect(event.payload.item.status).toBe("pending"); + expect(event.payload.item.source).toBe("user"); + + const next = yield* projectEvent(readModel, event); + expect(next.projects[0]?.boardItems).toEqual([event.payload.item]); + }), + ); + + it.effect("updates an existing board item in place", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const projectId = asProjectId("project-board-update"); + let readModel = yield* projectEvent( + createEmptyReadModel(now), + createProjectEvent({ sequence: 1, projectId, now }), + ); + + const created = yield* decideOrchestrationCommand({ + command: { + type: "project.board.item.upsert", + commandId: CommandId.make("cmd-board-create"), + projectId, + itemId: asItemId("item-1"), + title: "Draft", + status: "pending", + }, + readModel, + }); + const createdEvent = Array.isArray(created) ? created[0] : created; + if (!createdEvent || createdEvent.type !== "project.board-item-upserted") { + throw new Error("expected create event"); + } + readModel = yield* projectEvent(readModel, createdEvent); + + const updated = yield* decideOrchestrationCommand({ + command: { + type: "project.board.item.upsert", + commandId: CommandId.make("cmd-board-complete"), + projectId, + itemId: asItemId("item-1"), + title: "Draft", + status: "completed", + }, + readModel, + }); + const updatedEvent = Array.isArray(updated) ? updated[0] : updated; + if (!updatedEvent || updatedEvent.type !== "project.board-item-upserted") { + throw new Error("expected update event"); + } + readModel = yield* projectEvent(readModel, updatedEvent); + + expect(readModel.projects[0]?.boardItems).toHaveLength(1); + expect(readModel.projects[0]?.boardItems?.[0]?.status).toBe("completed"); + expect(readModel.projects[0]?.boardItems?.[0]?.createdAt).toBe( + createdEvent.payload.item.createdAt, + ); + }), + ); + + it.effect("deletes a board item", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const projectId = asProjectId("project-board-delete"); + let readModel = yield* projectEvent( + createEmptyReadModel(now), + createProjectEvent({ sequence: 1, projectId, now }), + ); + + const created = yield* decideOrchestrationCommand({ + command: { + type: "project.board.item.upsert", + commandId: CommandId.make("cmd-board-create-delete"), + projectId, + itemId: asItemId("item-1"), + title: "Temporary", + status: "pending", + }, + readModel, + }); + const createdEvent = Array.isArray(created) ? created[0] : created; + if (!createdEvent) throw new Error("missing create"); + readModel = yield* projectEvent(readModel, createdEvent); + + const deleted = yield* decideOrchestrationCommand({ + command: { + type: "project.board.item.delete", + commandId: CommandId.make("cmd-board-delete"), + projectId, + itemId: asItemId("item-1"), + }, + readModel, + }); + const deletedEvent = Array.isArray(deleted) ? deleted[0] : deleted; + expect(deletedEvent?.type).toBe("project.board-item-deleted"); + if (!deletedEvent) throw new Error("missing delete"); + readModel = yield* projectEvent(readModel, deletedEvent); + expect(readModel.projects[0]?.boardItems).toEqual([]); + }), + ); + + it.effect("rejects delete of a missing board item", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const projectId = asProjectId("project-board-missing"); + const readModel = yield* projectEvent( + createEmptyReadModel(now), + createProjectEvent({ sequence: 1, projectId, now }), + ); + + const error = yield* decideOrchestrationCommand({ + command: { + type: "project.board.item.delete", + commandId: CommandId.make("cmd-board-delete-missing"), + projectId, + itemId: asItemId("missing"), + }, + readModel, + }).pipe(Effect.flip); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("enforces the board item limit on create", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const projectId = asProjectId("project-board-limit"); + let readModel = yield* projectEvent( + createEmptyReadModel(now), + createProjectEvent({ sequence: 1, projectId, now }), + ); + + for (let index = 0; index < PROJECT_BOARD_ITEM_LIMIT; index += 1) { + const created = yield* decideOrchestrationCommand({ + command: { + type: "project.board.item.upsert", + commandId: CommandId.make(`cmd-board-fill-${index}`), + projectId, + itemId: asItemId(`item-${index}`), + title: `Item ${index}`, + status: "pending", + }, + readModel, + }); + const event = Array.isArray(created) ? created[0] : created; + if (!event) throw new Error("missing fill event"); + readModel = yield* projectEvent(readModel, event); + } + + const overflow = yield* decideOrchestrationCommand({ + command: { + type: "project.board.item.upsert", + commandId: CommandId.make("cmd-board-overflow"), + projectId, + itemId: asItemId("item-overflow"), + title: "Too many", + status: "pending", + }, + readModel, + }).pipe(Effect.flip); + + expect(overflow._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index a48bb29e154b..2c281c186ac2 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1,8 +1,10 @@ import { EventId, + PROJECT_BOARD_ITEM_LIMIT, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + type ProjectBoardItem, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -349,6 +351,79 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "project.board.item.upsert": { + const project = yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + const occurredAt = yield* nowIso; + const boardItems = project.boardItems ?? []; + const existing = boardItems.find((item) => item.id === command.itemId); + if (!existing && boardItems.length >= PROJECT_BOARD_ITEM_LIMIT) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Project board is limited to ${PROJECT_BOARD_ITEM_LIMIT} items.`, + }); + } + const item: ProjectBoardItem = { + id: command.itemId, + title: command.title, + status: command.status, + notes: command.notes === undefined ? (existing?.notes ?? null) : command.notes, + source: command.source ?? existing?.source ?? "user", + sourceThreadId: + command.sourceThreadId === undefined + ? (existing?.sourceThreadId ?? null) + : command.sourceThreadId, + createdAt: existing?.createdAt ?? occurredAt, + updatedAt: occurredAt, + }; + return { + ...(yield* withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt, + commandId: command.commandId, + })), + type: "project.board-item-upserted" as const, + payload: { + projectId: command.projectId, + item, + updatedAt: occurredAt, + }, + }; + } + + case "project.board.item.delete": { + const project = yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + if (!(project.boardItems ?? []).some((item) => item.id === command.itemId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Board item '${command.itemId}' was not found on project '${command.projectId}'.`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt, + commandId: command.commandId, + })), + type: "project.board-item-deleted" as const, + payload: { + projectId: command.projectId, + itemId: command.itemId, + updatedAt: occurredAt, + }, + }; + } + case "thread.create": { yield* requireProject({ readModel, diff --git a/apps/server/src/orchestration/projectBoardPrompt.test.ts b/apps/server/src/orchestration/projectBoardPrompt.test.ts new file mode 100644 index 000000000000..74e0b366752f --- /dev/null +++ b/apps/server/src/orchestration/projectBoardPrompt.test.ts @@ -0,0 +1,50 @@ +import { ProjectBoardItemId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + appendProjectBoardToTurnInput, + formatProjectBoardPromptBlock, +} from "./projectBoardPrompt.ts"; + +describe("projectBoardPrompt", () => { + it("formats a short MCP hint with open-item counts", () => { + const empty = formatProjectBoardPromptBlock([]); + expect(empty).toContain(""); + expect(empty).toContain("empty"); + expect(empty).toContain("board_list"); + expect(empty).toContain("t3-code"); + expect(empty).not.toContain("id="); + + const populated = formatProjectBoardPromptBlock([ + { + id: ProjectBoardItemId.make("item-1"), + title: "Ship board", + status: "inProgress", + notes: "keep it short", + source: "agent", + sourceThreadId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + { + id: ProjectBoardItemId.make("item-2"), + title: "Done already", + status: "completed", + notes: null, + source: "user", + sourceThreadId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ]); + expect(populated).toContain("1 open (2 total)"); + expect(populated).not.toContain("Ship board"); + }); + + it("appends the board block after the user turn text", () => { + const block = formatProjectBoardPromptBlock([]); + expect(appendProjectBoardToTurnInput("Hello", block)).toBe(`Hello\n\n${block}`); + expect(appendProjectBoardToTurnInput(undefined, block)).toBe(block); + expect(appendProjectBoardToTurnInput("Hello", null)).toBe("Hello"); + }); +}); diff --git a/apps/server/src/orchestration/projectBoardPrompt.ts b/apps/server/src/orchestration/projectBoardPrompt.ts new file mode 100644 index 000000000000..e0abda63ddd2 --- /dev/null +++ b/apps/server/src/orchestration/projectBoardPrompt.ts @@ -0,0 +1,31 @@ +import type { ProjectBoardItem } from "@t3tools/contracts"; + +/** + * Short jcode turn hint for the project board. + * + * Full board dumps burned tokens on every turn. jcode sessions now get T3 MCP + * tools through a stdio bridge (`.jcode/mcp.json` → `t3-code`), so agents should + * call `board_*` on demand. This block stays tiny and only reminds the model. + */ +export function formatProjectBoardPromptBlock(items: ReadonlyArray): string { + const openCount = items.filter((item) => item.status !== "completed").length; + const summary = items.length === 0 ? "empty" : `${openCount} open (${items.length} total)`; + + return [ + "", + `Shared project todos (${summary}).`, + "Use MCP tools board_list / board_upsert / board_set_status / board_delete on server t3-code.", + "Do not ask the user to edit the Board panel for changes you can make with those tools.", + "", + ].join("\n"); +} + +export function appendProjectBoardToTurnInput( + input: string | undefined, + boardBlock: string | null, +): string | undefined { + if (!boardBlock) return input; + const trimmed = input?.trim(); + if (!trimmed) return boardBlock; + return `${trimmed}\n\n${boardBlock}`; +} diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..af48091aaa45 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -11,6 +11,8 @@ import * as Schema from "effect/Schema"; import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; import { MessageSentPayloadSchema, + ProjectBoardItemDeletedPayload, + ProjectBoardItemUpsertedPayload, ProjectCreatedPayload, ProjectDeletedPayload, ProjectMetaUpdatedPayload, @@ -217,6 +219,7 @@ export function projectEvent( defaultThreadEnvMode: null, faviconPath: payload.faviconPath ?? null, scripts: payload.scripts, + boardItems: [], createdAt: payload.createdAt, updatedAt: payload.updatedAt, deletedAt: null, @@ -278,6 +281,57 @@ export function projectEvent( })), ); + case "project.board-item-upserted": + return decodeForEvent( + ProjectBoardItemUpsertedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + projects: nextBase.projects.map((project) => { + if (project.id !== payload.projectId) return project; + const currentItems = project.boardItems ?? []; + const existingIndex = currentItems.findIndex((item) => item.id === payload.item.id); + const boardItems = + existingIndex === -1 + ? [...currentItems, payload.item] + : currentItems.map((item, index) => + index === existingIndex ? payload.item : item, + ); + return { + ...project, + boardItems, + updatedAt: payload.updatedAt, + }; + }), + })), + ); + + case "project.board-item-deleted": + return decodeForEvent( + ProjectBoardItemDeletedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + projects: nextBase.projects.map((project) => + project.id === payload.projectId + ? { + ...project, + boardItems: (project.boardItems ?? []).filter( + (item) => item.id !== payload.itemId, + ), + updatedAt: payload.updatedAt, + } + : project, + ), + })), + ); + case "thread.created": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index ba133bb24a47..aad0c1fee7e7 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ModelSelection, ProjectScript } from "@t3tools/contracts"; +import { ModelSelection, ProjectBoardItem, ProjectScript } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionProjectInput, @@ -19,6 +19,7 @@ const ProjectionProjectDbRow = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + boardItems: Schema.fromJsonString(Schema.Array(ProjectBoardItem)), }), ); type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type; @@ -38,6 +39,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { default_thread_env_mode, favicon_path, scripts_json, + board_items_json, created_at, updated_at, deleted_at @@ -50,6 +52,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.defaultThreadEnvMode}, ${row.faviconPath ?? null}, ${JSON.stringify(row.scripts)}, + ${JSON.stringify(row.boardItems ?? [])}, ${row.createdAt}, ${row.updatedAt}, ${row.deletedAt} @@ -62,6 +65,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { default_thread_env_mode = excluded.default_thread_env_mode, favicon_path = excluded.favicon_path, scripts_json = excluded.scripts_json, + board_items_json = excluded.board_items_json, created_at = excluded.created_at, updated_at = excluded.updated_at, deleted_at = excluded.deleted_at @@ -81,6 +85,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", favicon_path AS "faviconPath", scripts_json AS "scripts", + board_items_json AS "boardItems", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -102,6 +107,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { default_thread_env_mode AS "defaultThreadEnvMode", favicon_path AS "faviconPath", scripts_json AS "scripts", + board_items_json AS "boardItems", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b137cedfbedd..8f4e6e5cf18a 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_ProjectionProjectsBoardItems.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +106,7 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "ProjectionProjectsBoardItems", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionProjectsBoardItems.ts b/apps/server/src/persistence/Migrations/041_ProjectionProjectsBoardItems.ts new file mode 100644 index 000000000000..737051953539 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionProjectsBoardItems.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "board_items_json")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN board_items_json TEXT NOT NULL DEFAULT '[]' + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 339439fdfcb8..feeacf23b8d7 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -9,6 +9,7 @@ import { IsoDateTime, ModelSelection, + ProjectBoardItem, ProjectId, ProjectScript, ThreadEnvMode, @@ -28,6 +29,7 @@ export const ProjectionProject = Schema.Struct({ defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), faviconPath: Schema.optional(Schema.NullOr(Schema.String)), scripts: Schema.Array(ProjectScript), + boardItems: Schema.optional(Schema.Array(ProjectBoardItem)), createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/provider/Drivers/JcodeDriver.ts b/apps/server/src/provider/Drivers/JcodeDriver.ts new file mode 100644 index 000000000000..1d225f1441e5 --- /dev/null +++ b/apps/server/src/provider/Drivers/JcodeDriver.ts @@ -0,0 +1,163 @@ +import { JcodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeJcodeTextGeneration } from "../../textGeneration/JcodeTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeJcodeAdapter } from "../Layers/JcodeAdapter.ts"; +import { + buildInitialJcodeProviderSnapshot, + checkJcodeProviderStatus, + enrichJcodeSnapshot, +} from "../Layers/JcodeProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +const decodeJcodeSettings = Schema.decodeSync(JcodeSettings); + +const DRIVER_KIND = ProviderDriverKind.make("jcode"); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type JcodeDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const JcodeDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Jcode", + supportsMultipleInstances: true, + }, + configSchema: JcodeSettings, + defaultConfig: (): JcodeSettings => decodeJcodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies JcodeSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeJcodeAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeJcodeTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkJcodeProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialJcodeProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichJcodeSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Jcode snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/JcodeAdapter.ts b/apps/server/src/provider/Layers/JcodeAdapter.ts new file mode 100644 index 000000000000..6a410f102476 --- /dev/null +++ b/apps/server/src/provider/Layers/JcodeAdapter.ts @@ -0,0 +1,1345 @@ +import { + ApprovalRequestId, + type JcodeSettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { clearJcodeMcpAuthFile, installJcodeMcpBridgeFiles } from "../jcodeMcpConfig.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + applyJcodeAcpModelSelection, + currentJcodeModelIdFromSessionSetup, + makeJcodeAcpRuntime, + resolveJcodeAcpBaseModelId, +} from "../acp/JcodeAcpSupport.ts"; +import { type JcodeAdapterShape } from "../Services/JcodeAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("jcode"); +const JCODE_RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface JcodeAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +type PendingUserInputResolution = + | { readonly _tag: "answered"; readonly answers: ProviderUserInputAnswers } + | { readonly _tag: "cancelled" }; + +interface PendingUserInput { + readonly resolution: Deferred.Deferred; +} + +interface GrokSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Turns already interrupted; late prompt RPCs must not resurrect them. */ + interruptedTurnIds: Set; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + currentModelId: string | undefined; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function settlePendingUserInputsAsCancelled( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingUserInputs.values()), + (pending) => Deferred.succeed(pending.resolution, { _tag: "cancelled" }).pipe(Effect.ignore), + { discard: true }, + ); +} + +function appendPromptResultToTurn( + ctx: GrokSessionContext, + turnId: TurnId, + promptParts: ReadonlyArray, + result: EffectAcpSchema.PromptResponse, +): void { + const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); + ctx.turns = existingTurnRecord + ? ctx.turns.map((turn) => + turn.id === turnId + ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } + : turn, + ) + : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; + +const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; + +const resolveSessionCallbackTurnId = ( + sessions: ReadonlyMap, + threadId: ThreadId, +): TurnId | undefined => { + const ctx = sessions.get(threadId); + return ctx ? resolveCallbackTurnId(ctx) : undefined; +}; + +function parseJcodeResume(_raw: unknown): { sessionId: string } | undefined { + // MVP: no session resume for jcode. + return undefined; +} + +function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + const option = request.options.find((entry) => entry.kind === kind); + return option?.optionId.trim() || undefined; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); +} + +function completedStopReasonFromPromptResponse( + response: EffectAcpSchema.PromptResponse | undefined, +): EffectAcpSchema.StopReason | null { + if (response === undefined) { + return null; + } + return response.stopReason; +} + +export function grokPromptSettlementBelongsToContext(input: { + readonly liveAcpSessionId: string; + readonly expectedAcpSessionId: string; + readonly liveActiveTurnId: TurnId | undefined; + readonly liveSessionActiveTurnId: TurnId | undefined; + readonly turnId: TurnId; +}): boolean { + return ( + input.liveAcpSessionId === input.expectedAcpSessionId && + (input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId) + ); +} + +export function makeJcodeAdapter(jcodeSettings: JcodeSettings, options?: JcodeAdapterLiveOptions) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Jcode runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Jcode ACP callback.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const settlePromptInFlight = ( + threadId: ThreadId, + turnId: TurnId, + expectedAcpSessionId: string, + options?: { + readonly errorMessage?: string; + readonly completedStopReason?: EffectAcpSchema.StopReason | null; + readonly emitTurnCompletion?: boolean; + /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ + readonly settleAllPrompts?: boolean; + }, + ) => + Effect.gen(function* () { + const liveCtx = sessions.get(threadId); + if (!liveCtx) { + return; + } + const settlementBelongsToLiveContext = grokPromptSettlementBelongsToContext({ + liveAcpSessionId: liveCtx.acpSessionId, + expectedAcpSessionId, + liveActiveTurnId: liveCtx.activeTurnId, + liveSessionActiveTurnId: liveCtx.session.activeTurnId, + turnId, + }); + if (!settlementBelongsToLiveContext) { + // interruptTurn already consumed every prompt slot for this turn. A + // late prompt result must neither emit a second terminal event nor + // consume a slot belonging to a newer turn on the same ACP session. + if ( + liveCtx.acpSessionId !== expectedAcpSessionId || + liveCtx.interruptedTurnIds.has(turnId) + ) { + return; + } + if (options?.emitTurnCompletion !== false) { + if (options?.errorMessage !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (options?.completedStopReason !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + } + return; + } + let settleTurnId = turnId; + if (options?.settleAllPrompts) { + liveCtx.promptsInFlight = 0; + if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { + const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; + if (!fallbackTurnId) { + if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { + const updatedAt = yield* nowIso; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + return; + } + settleTurnId = fallbackTurnId; + } + } else { + const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); + if ( + remainingPrompts > 0 || + liveCtx.activeTurnId !== settleTurnId || + liveCtx.session.activeTurnId !== settleTurnId + ) { + liveCtx.promptsInFlight = remainingPrompts; + return; + } + liveCtx.promptsInFlight = remainingPrompts; + } + const updatedAt = yield* nowIso; + const canEmitTurnCompletion = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; + const shouldEmitCompletedTurn = + options?.completedStopReason !== undefined && canEmitTurnCompletion; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + if (options?.emitTurnCompletion === false) { + return; + } + if (shouldEmitFailedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + }); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to write native Jcode notification log.", { + cause, + threadId, + method, + }), + ), + ); + + const emitPlanUpdate = ( + ctx: GrokSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload, + source: "acp.jsonrpc", + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: GrokSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* Effect.sync(() => clearJcodeMcpAuthFile(serverConfig.secretsDir, ctx.threadId)).pipe( + Effect.ignore, + ); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: JcodeAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const jcodeModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const resumeSessionId = undefined; // MVP: no jcode resume + void parseJcodeResume(input.resumeCursor); + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + // jcode rejects ACP mcpServers / session/set_model; model via spawn `-m` only. + // Install a project-local stdio MCP entry that proxies to T3 HTTP /mcp + // before spawning so jcode loads board (and other) tools at connect time. + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + if (mcpSession) { + yield* Effect.try({ + try: () => + installJcodeMcpBridgeFiles({ + cwd, + secretsDir: serverConfig.secretsDir, + threadId: input.threadId, + endpoint: mcpSession.endpoint, + authorizationHeader: mcpSession.authorizationHeader, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: `Failed to install jcode MCP stdio bridge: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + cause, + }), + }).pipe( + Effect.tapError((error) => + Effect.logWarning("jcode MCP stdio bridge install failed; continuing without it", { + detail: error.detail, + }), + ), + Effect.ignore, + ); + } + + const requestedStartModelId = jcodeModelSelection?.model + ? resolveJcodeAcpBaseModelId(jcodeModelSelection.model) + : undefined; + const spawnJcodeSettings = { + ...jcodeSettings, + ...(requestedStartModelId ? { model: requestedStartModelId } : {}), + }; + const acp = yield* makeJcodeAcpRuntime({ + jcodeSettings: spawnJcodeSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* acp + .start() + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const boundModelId = applyJcodeAcpModelSelection({ + currentModelId: currentJcodeModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: requestedStartModelId, + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(boundModelId ? { model: resolveJcodeAcpBaseModelId(boundModelId) } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: JCODE_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + const ctx: GrokSessionContext = { + threadId: input.threadId, + acpSessionId: started.sessionId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + interruptedTurnIds: new Set(), + promptsInFlight: 0, + currentModelId: boundModelId, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if ( + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + } + + if (event._tag === "ModeChanged") { + return; + } + + const notificationTurnId = resolveNotificationTurnId(ctx); + if ( + notificationTurnId === undefined || + ctx.interruptedTurnIds.has(notificationTurnId) + ) { + return; + } + const stamp = yield* makeEventStamp(); + + switch (event._tag) { + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* emitPlanUpdate( + ctx, + notificationTurnId, + stamp, + event.payload, + event.rawPayload, + "session/update", + ); + return; + case "ToolCallUpdated": + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Jcode runtime notification.", { cause }), + ), + Effect.forkChild, + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Jcode ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: JcodeAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const prepared = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: the agent + // folds the new prompt into the ongoing work, so the active turn + // id is reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; decremented on + // preparation failure here, and after the prompt below otherwise. + ctx.promptsInFlight += 1; + // Bind the turn id before cooperative yields so interruptTurn can + // settle this prompt even if stop arrives during preparation. + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: steeringTurnId === undefined ? "connecting" : "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + return yield* Effect.gen(function* () { + // Mid-session ACP model switch unsupported; keep spawn-time `-m` model. + const currentModelId = ctx.currentModelId; + + const text = input.input?.trim(); + const imagePromptParts = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + return { + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...imagePromptParts, + ]; + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + ctx.currentModelId = currentModelId; + const displayModel = currentModelId + ? resolveJcodeAcpBaseModelId(currentModelId) + : undefined; + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + if (ctx.interruptedTurnIds.has(turnId)) { + yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + emitTurnCompletion: false, + settleAllPrompts: true, + }); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Jcode prompt was interrupted during preparation.", + }); + } + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(displayModel ? { model: displayModel } : {}), + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: displayModel ? { model: displayModel } : {}, + }); + } + + return { + acp: ctx.acp, + acpSessionId: ctx.acpSessionId, + displayModel, + promptParts, + turnId, + }; + }).pipe( + Effect.tapCause(() => + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + if (!liveCtx) { + return; + } + yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { + errorMessage: "Jcode prompt preparation failed.", + emitTurnCompletion: false, + }); + }), + ), + ); + }), + ); + const promptSettled = yield* Ref.make(false); + const promptRpcSucceeded = yield* Ref.make(false); + const promptResultRef = yield* Ref.make( + undefined, + ); + + const promptFailureMessageRef = yield* Ref.make(undefined); + + return yield* Effect.gen(function* () { + const result = yield* prepared.acp + .prompt({ + prompt: prepared.promptParts, + }) + .pipe( + Effect.tap((promptResult) => + Effect.all([ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + ]), + ), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, + ).pipe(Effect.andThen(prepared.acp.drainEvents)), + ), + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Jcode session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + yield* Ref.set(promptSettled, true); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Jcode session changed before the turn completed.", + }); + } + // Keep prompt settlement atomic with respect to Stop and steering. + // interruptTurn marks its target before waiting for this lock, so + // cancellation can still win while queued ACP events are drained. + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* prepared.acp.drainEvents; + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remainingPrompts; + + // Only the last remaining prompt settles the turn. A steer- + // superseded prompt resolving while another is in flight or + // pending must leave the merged turn running. + if ( + remainingPrompts === 0 && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId + ) { + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + const completedAt = yield* nowIso; + const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: completedAt, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const completedStopReason = completedStopReasonFromPromptResponse(result); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: completedStopReason, + }, + }); + ctx.interruptedTurnIds.delete(prepared.turnId); + yield* Ref.set(promptSettled, true); + } else if (remainingPrompts > 0) { + yield* Ref.set(promptSettled, true); + } + + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) { + return; + } + + if (yield* Ref.get(promptRpcSucceeded)) { + const promptResult = yield* Ref.get(promptResultRef); + if (promptResult === undefined) { + return; + } + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Jcode session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + return; + } + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + return; + } + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + return; + } + appendPromptResultToTurn( + ctx, + prepared.turnId, + prepared.promptParts, + promptResult, + ); + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + completedStopReason: completedStopReasonFromPromptResponse(promptResult), + }, + ); + }), + ); + return; + } + + const errorMessage = yield* Ref.get(promptFailureMessageRef); + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + errorMessage: errorMessage ?? "Jcode prompt request failed.", + }), + ); + }).pipe(Effect.catch(() => Effect.void)), + ), + ); + }); + + const interruptTurn: JcodeAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return { + _tag: "Proceed" as const, + acpSessionId: undefined, + interruptedTurnId: turnId, + }; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return { _tag: "Ignore" as const }; + } + const interruptedTurnId = turnId ?? activeTurnId; + if (interruptedTurnId !== undefined) { + ctx.interruptedTurnIds.add(interruptedTurnId); + } + return { + _tag: "Proceed" as const, + acpSessionId: ctx.acpSessionId, + interruptedTurnId, + }; + }); + if (observed._tag === "Ignore") { + return; + } + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { + return; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + if ( + observed.interruptedTurnId !== undefined && + activeTurnId !== undefined && + activeTurnId !== observed.interruptedTurnId + ) { + return; + } + const interruptedTurnId = + observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + if (interruptedTurnId) { + ctx.interruptedTurnIds.add(interruptedTurnId); + yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } else if ( + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting" + ) { + const updatedAt = yield* nowIso; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + }), + ); + }); + + const respondToRequest: JcodeAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: JcodeAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "_x.ai/ask_user_question", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.resolution, { _tag: "answered", answers }); + }); + + const readThread: JcodeAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: JcodeAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Jcode ACP sessions do not support provider-side rollback yet.", + }); + }); + + const stopSession: JcodeAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: JcodeAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: JcodeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: JcodeAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies JcodeAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/JcodeProvider.test.ts b/apps/server/src/provider/Layers/JcodeProvider.test.ts new file mode 100644 index 000000000000..89035b35db98 --- /dev/null +++ b/apps/server/src/provider/Layers/JcodeProvider.test.ts @@ -0,0 +1,110 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { JcodeSettings } from "@t3tools/contracts"; + +import { buildInitialJcodeProviderSnapshot, checkJcodeProviderStatus } from "./JcodeProvider.ts"; + +const decodeJcodeSettings = Schema.decodeSync(JcodeSettings); + +describe("buildInitialJcodeProviderSnapshot", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialJcodeProviderSnapshot( + decodeJcodeSettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + }), + ); + + it.effect("returns a pending snapshot by default", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialJcodeProviderSnapshot(decodeJcodeSettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking Jcode"); + expect(snapshot.requiresNewThreadForModelChange).toBe(true); + }), + ); +}); + +it.layer(NodeServices.layer)("checkJcodeProviderStatus", (it) => { + it.effect("reports the binary as missing when the binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkJcodeProviderStatus( + decodeJcodeSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/jcode-binary", + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }), + ); + + it.effect("reports an installed CLI as unhealthy when version exits non-zero", () => + Effect.gen(function* () { + const secretStderr = "broken jcode install: secret-token-value"; + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-jcode-version-" }); + const jcodePath = path.join(dir, "jcode"); + yield* fs.writeFileString( + jcodePath, + ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"), + ); + yield* fs.chmod(jcodePath, 0o755); + + return yield* checkJcodeProviderStatus( + decodeJcodeSettings({ enabled: true, binaryPath: jcodePath }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toBe("Jcode CLI is installed but failed to run."); + expect(snapshot.message).not.toContain(secretStderr); + }), + ); + + it.effect("reports an error when ACP model discovery is unavailable", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-jcode-success-" }); + const jcodePath = path.join(dir, "jcode"); + yield* fs.writeFileString( + jcodePath, + ["#!/bin/sh", 'printf "jcode-cli 0.0.99\\n"', "exit 0", ""].join("\n"), + ); + yield* fs.chmod(jcodePath, 0o755); + + return yield* checkJcodeProviderStatus( + decodeJcodeSettings({ enabled: true, binaryPath: jcodePath }), + ); + }), + ); + + expect(snapshot.status).toBe("error"); + expect(snapshot.installed).toBe(true); + expect(snapshot.models.map((model) => model.slug)).toEqual(["claude-opus-5"]); + expect(snapshot.message).toContain("ACP startup failed"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/JcodeProvider.ts b/apps/server/src/provider/Layers/JcodeProvider.ts new file mode 100644 index 000000000000..ddf056fa7690 --- /dev/null +++ b/apps/server/src/provider/Layers/JcodeProvider.ts @@ -0,0 +1,335 @@ +import { + type JcodeSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { makeJcodeAcpRuntime, resolveJcodeAcpBaseModelId } from "../acp/JcodeAcpSupport.ts"; + +const JCODE_PRESENTATION = { + displayName: "Jcode", + badgeLabel: "Fork", + showInteractionModeToggle: false, + requiresNewThreadForModelChange: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const JCODE_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +const JCODE_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "claude-opus-5", + name: "claude-opus-5", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +export function buildInitialJcodeProviderSnapshot( + jcodeSettings: JcodeSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = jcodeModelsFromSettings(jcodeSettings.customModels); + + if (!jcodeSettings.enabled) { + return buildServerProvider({ + presentation: JCODE_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Jcode is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: JCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Jcode CLI availability...", + }, + }); + }); +} + +function jcodeModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = JCODE_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +function buildJcodeDiscoveredModelsFromSessionModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) { + return []; + } + const seen = new Set(); + return modelState.availableModels + .map((model): ServerProviderModel | undefined => { + const slug = resolveJcodeAcpBaseModelId(model.modelId); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: model.name.trim() || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +const discoverJcodeModelsViaAcp = ( + jcodeSettings: JcodeSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeJcodeAcpRuntime({ + jcodeSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-jcode-provider-probe", version: "0.0.0" }, + }); + const started = yield* acp.start(); + return buildJcodeDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models); + }).pipe(Effect.scoped); + +const runJcodeVersionCommand = ( + jcodeSettings: JcodeSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = jcodeSettings.binaryPath || "jcode"; + const spawnCommand = yield* resolveSpawnCommand(command, ["version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export const checkJcodeProviderStatus = Effect.fn("checkJcodeProviderStatus")(function* ( + jcodeSettings: JcodeSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = jcodeModelsFromSettings(jcodeSettings.customModels); + + if (!jcodeSettings.enabled) { + return buildServerProvider({ + presentation: JCODE_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Jcode is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runJcodeVersionCommand(jcodeSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Jcode CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: JCODE_PRESENTATION, + enabled: jcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Jcode CLI (`jcode`) is not installed or not on PATH." + : "Failed to execute Jcode CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: JCODE_PRESENTATION, + enabled: jcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Jcode CLI is installed but timed out while running `jcode version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Jcode CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: JCODE_PRESENTATION, + enabled: jcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Jcode CLI is installed but failed to run.", + }, + }); + } + + const discoveryExit = yield* discoverJcodeModelsViaAcp(jcodeSettings, environment).pipe( + Effect.timeoutOption(JCODE_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Jcode ACP model discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildServerProvider({ + presentation: JCODE_PRESENTATION, + enabled: jcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Jcode CLI is installed but ACP startup failed. Check server logs for details.", + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + yield* Effect.logWarning( + `Jcode ACP model discovery timed out after ${JCODE_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + ); + return buildServerProvider({ + presentation: JCODE_PRESENTATION, + enabled: jcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `Jcode CLI is installed but ACP startup timed out after ${JCODE_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + }, + }); + } + const discoveredModels = discoveryExit.value.value; + const models = + discoveredModels.length > 0 + ? jcodeModelsFromSettings(jcodeSettings.customModels, discoveredModels) + : fallbackModels; + + return buildServerProvider({ + presentation: JCODE_PRESENTATION, + enabled: jcodeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); +}); + +export const enrichJcodeSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Jcode version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Services/JcodeAdapter.ts b/apps/server/src/provider/Services/JcodeAdapter.ts new file mode 100644 index 000000000000..224e23391e02 --- /dev/null +++ b/apps/server/src/provider/Services/JcodeAdapter.ts @@ -0,0 +1,16 @@ +/** + * JcodeAdapter — shape type for the Jcode provider adapter. + * + * The driver model ({@link ../Drivers/JcodeDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module JcodeAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * JcodeAdapterShape — per-instance Jcode adapter contract. + */ +export interface JcodeAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d..5e808cfe1e75 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -69,6 +69,12 @@ export interface AcpSessionRuntimeOptions { readonly version: string; }; readonly authMethodId: string; + /** + * When true, skip the ACP `authenticate` request after `initialize`. + * Needed for agents that advertise empty `authMethods` and reject + * `authenticate` (e.g. jcode). Cursor/Grok keep the default false. + */ + readonly skipAuthenticate?: boolean; readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { @@ -541,15 +547,17 @@ export const make = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + if (!options.skipAuthenticate) { + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: diff --git a/apps/server/src/provider/acp/JcodeAcpSupport.test.ts b/apps/server/src/provider/acp/JcodeAcpSupport.test.ts new file mode 100644 index 000000000000..4c8220b075ef --- /dev/null +++ b/apps/server/src/provider/acp/JcodeAcpSupport.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + applyJcodeAcpModelSelection, + buildJcodeAcpSpawnInput, + resolveJcodeAcpBaseModelId, +} from "./JcodeAcpSupport.ts"; + +describe("resolveJcodeAcpBaseModelId", () => { + it("normalizes empty and custom Jcode model ids", () => { + expect(resolveJcodeAcpBaseModelId(undefined)).toBe("claude-opus-5"); + expect(resolveJcodeAcpBaseModelId(" ")).toBe("claude-opus-5"); + expect(resolveJcodeAcpBaseModelId(" gpt-5.5 ")).toBe("gpt-5.5"); + }); +}); + +describe("buildJcodeAcpSpawnInput", () => { + it("builds the default jcode acp command", () => { + expect(buildJcodeAcpSpawnInput(undefined, "/tmp/project")).toEqual({ + command: "jcode", + args: ["acp", "--no-selfdev"], + cwd: "/tmp/project", + }); + }); + + it("includes binary path, model, provider profile, and jcode provider flags", () => { + expect( + buildJcodeAcpSpawnInput( + { + binaryPath: "/usr/local/bin/jcode", + model: "gpt-5.5", + providerProfile: "my-gateway", + jcodeProvider: "openai", + }, + "/tmp/project", + { PATH: "/usr/bin" }, + ), + ).toEqual({ + command: "/usr/local/bin/jcode", + args: [ + "acp", + "--no-selfdev", + "-p", + "openai", + "--provider-profile", + "my-gateway", + "-m", + "gpt-5.5", + ], + cwd: "/tmp/project", + env: { PATH: "/usr/bin" }, + }); + }); +}); + +describe("applyJcodeAcpModelSelection", () => { + it("prefers the requested model id for bookkeeping", () => { + expect( + applyJcodeAcpModelSelection({ + currentModelId: "claude-opus-5", + requestedModelId: "gpt-5.5", + }), + ).toBe("gpt-5.5"); + }); + + it("keeps the current model when nothing is requested", () => { + expect( + applyJcodeAcpModelSelection({ + currentModelId: "claude-opus-5", + requestedModelId: undefined, + }), + ).toBe("claude-opus-5"); + }); +}); diff --git a/apps/server/src/provider/acp/JcodeAcpSupport.ts b/apps/server/src/provider/acp/JcodeAcpSupport.ts new file mode 100644 index 000000000000..e2edd95794cf --- /dev/null +++ b/apps/server/src/provider/acp/JcodeAcpSupport.ts @@ -0,0 +1,106 @@ +import { type JcodeSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { normalizeModelSlug } from "@t3tools/shared/model"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const JCODE_DRIVER_KIND = ProviderDriverKind.make("jcode"); +/** Placeholder required by AcpSessionRuntimeOptions; jcode rejects `authenticate`. */ +const JCODE_AUTH_METHOD_UNUSED = "jcode"; + +type JcodeAcpRuntimeJcodeSettings = Pick< + JcodeSettings, + "binaryPath" | "model" | "providerProfile" | "jcodeProvider" +>; + +interface JcodeAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" | "skipAuthenticate" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly jcodeSettings: JcodeAcpRuntimeJcodeSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildJcodeAcpSpawnInput( + jcodeSettings: JcodeAcpRuntimeJcodeSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + const model = jcodeSettings?.model?.trim(); + const providerProfile = jcodeSettings?.providerProfile?.trim(); + const jcodeProvider = jcodeSettings?.jcodeProvider?.trim(); + const args: Array = ["acp", "--no-selfdev"]; + if (jcodeProvider) { + args.push("-p", jcodeProvider); + } + if (providerProfile) { + args.push("--provider-profile", providerProfile); + } + if (model) { + args.push("-m", model); + } + return { + command: jcodeSettings?.binaryPath?.trim() || "jcode", + args, + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeJcodeAcpRuntime = ( + input: JcodeAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildJcodeAcpSpawnInput(input.jcodeSettings, input.cwd, input.environment), + authMethodId: JCODE_AUTH_METHOD_UNUSED, + skipAuthenticate: true, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export function resolveJcodeAcpBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + const base = trimmed && trimmed.length > 0 ? trimmed : "claude-opus-5"; + return normalizeModelSlug(base, JCODE_DRIVER_KIND) ?? "claude-opus-5"; +} + +export function currentJcodeModelIdFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + return sessionSetupResult.models?.currentModelId?.trim() || undefined; +} + +/** + * jcode rejects ACP `session/set_model`. Model is selected only via spawn `-m` + * (see `buildJcodeAcpSpawnInput`). This helper only resolves the bookkeeping id. + */ +export function applyJcodeAcpModelSelection(input: { + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; +}): string | undefined { + return input.requestedModelId ?? input.currentModelId; +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..c5f9a59ab006 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { JcodeDriver, type JcodeDriverEnv } from "./Drivers/JcodeDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | JcodeDriverEnv | OpenCodeDriverEnv; /** @@ -49,5 +51,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray { + it("merges the managed t3-code server without clobbering peers", () => { + const server = buildJcodeMcpServerEntry({ + nodeExecutable: "/usr/bin/node", + serverEntryPath: "/app/bin.mjs", + endpoint: "http://127.0.0.1:3773/mcp", + authFilePath: "/tmp/auth", + }); + + const merged = mergeJcodeMcpConfigJson( + JSON.stringify({ + mcpServers: { + other: { command: "echo", args: ["hi"] }, + }, + }), + server, + ); + + const parsed = JSON.parse(merged) as { + mcpServers: Record }>; + servers?: unknown; + }; + expect(parsed.servers).toBeUndefined(); + expect(parsed.mcpServers.other).toEqual({ command: "echo", args: ["hi"] }); + expect(parsed.mcpServers[JCODE_MCP_SERVER_NAME]).toMatchObject({ + command: "/usr/bin/node", + args: ["/app/bin.mjs", JCODE_MCP_STDIO_CLI_FLAG], + env: { + T3_MCP_ENDPOINT: "http://127.0.0.1:3773/mcp", + T3_MCP_AUTH_FILE: "/tmp/auth", + }, + shared: false, + }); + }); + + it("reads legacy servers key and rewrites as mcpServers", () => { + const server = buildJcodeMcpServerEntry({ + nodeExecutable: "node", + serverEntryPath: "bin.ts", + endpoint: "http://localhost/mcp", + authFilePath: "/auth", + }); + const merged = mergeJcodeMcpConfigJson( + JSON.stringify({ servers: { legacy: { command: "true" } } }), + server, + ); + const parsed = JSON.parse(merged) as { mcpServers: Record }; + expect(parsed.mcpServers.legacy).toEqual({ command: "true" }); + expect(parsed.mcpServers[JCODE_MCP_SERVER_NAME]).toBeDefined(); + }); + + it("writes auth + mcp.json under cwd/secrets", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOs.tmpdir(), "jcode-mcp-config-")); + const cwd = NodePath.join(root, "project"); + const secretsDir = NodePath.join(root, "secrets"); + NodeFS.mkdirSync(cwd, { recursive: true }); + + const threadId = ThreadId.make("thread-board-1"); + const installed = installJcodeMcpBridgeFiles({ + cwd, + secretsDir, + threadId, + endpoint: "http://127.0.0.1:9/mcp", + authorizationHeader: "Bearer test-token", + nodeExecutable: "/bin/node", + serverEntryPath: "/opt/t3/bin.mjs", + }); + + expect(NodeFS.readFileSync(installed.authFilePath, "utf8").trim()).toBe("Bearer test-token"); + const config = JSON.parse(NodeFS.readFileSync(installed.configPath, "utf8")) as { + mcpServers: Record }>; + }; + expect(config.mcpServers[JCODE_MCP_SERVER_NAME]?.env.T3_MCP_AUTH_FILE).toBe( + installed.authFilePath, + ); + }); +}); diff --git a/apps/server/src/provider/jcodeMcpConfig.ts b/apps/server/src/provider/jcodeMcpConfig.ts new file mode 100644 index 000000000000..447f9fe27f85 --- /dev/null +++ b/apps/server/src/provider/jcodeMcpConfig.ts @@ -0,0 +1,134 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Project-local `.jcode/mcp.json` helpers so jcode can load T3's MCP tools over + * stdio (the only transport jcode supports today). Sync fs is intentional: the + * file must exist before jcode is spawned in the same turn. + */ +import type { ThreadId } from "@t3tools/contracts"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +export const JCODE_MCP_SERVER_NAME = "t3-code"; +export const JCODE_MCP_CONFIG_RELATIVE_PATH = ".jcode/mcp.json"; +export const JCODE_MCP_STDIO_CLI_FLAG = "__jcode-mcp-stdio"; + +export type JcodeMcpServerEntry = { + readonly command: string; + readonly args: ReadonlyArray; + readonly env: Readonly>; + readonly shared: false; +}; + +type McpConfigFile = { + mcpServers?: Record; + servers?: Record; +}; + +export function jcodeMcpAuthFilePath(secretsDir: string, threadId: ThreadId): string { + return NodePath.join(secretsDir, "jcode-mcp", `${threadId}.authorization`); +} + +export function buildJcodeMcpServerEntry(input: { + readonly nodeExecutable: string; + readonly serverEntryPath: string; + readonly endpoint: string; + readonly authFilePath: string; +}): JcodeMcpServerEntry { + return { + command: input.nodeExecutable, + args: [input.serverEntryPath, JCODE_MCP_STDIO_CLI_FLAG], + env: { + T3_MCP_ENDPOINT: input.endpoint, + T3_MCP_AUTH_FILE: input.authFilePath, + }, + shared: false, + }; +} + +function readServers(parsed: McpConfigFile): Record { + const fromCanonical = parsed.mcpServers; + const fromLegacy = parsed.servers; + if (fromCanonical && typeof fromCanonical === "object" && !Array.isArray(fromCanonical)) { + return { ...fromCanonical }; + } + if (fromLegacy && typeof fromLegacy === "object" && !Array.isArray(fromLegacy)) { + return { ...fromLegacy }; + } + return {}; +} + +/** Merge our managed stdio server into an existing mcp.json document. */ +export function mergeJcodeMcpConfigJson( + existingRaw: string | null | undefined, + server: JcodeMcpServerEntry, +): string { + let parsed: McpConfigFile = {}; + if (existingRaw && existingRaw.trim().length > 0) { + try { + parsed = JSON.parse(existingRaw) as McpConfigFile; + } catch { + parsed = {}; + } + } + + const servers = readServers(parsed); + servers[JCODE_MCP_SERVER_NAME] = { + command: server.command, + args: [...server.args], + env: { ...server.env }, + shared: false, + }; + + // Prefer the Claude-compatible key; jcode accepts it via serde alias. + const next: McpConfigFile = { ...parsed, mcpServers: servers }; + delete next.servers; + return `${JSON.stringify(next, null, 2)}\n`; +} + +export function installJcodeMcpBridgeFiles(input: { + readonly cwd: string; + readonly secretsDir: string; + readonly threadId: ThreadId; + readonly endpoint: string; + readonly authorizationHeader: string; + readonly nodeExecutable?: string; + readonly serverEntryPath?: string; +}): { readonly configPath: string; readonly authFilePath: string } { + const nodeExecutable = input.nodeExecutable ?? process.execPath; + const rawEntry = input.serverEntryPath ?? process.argv[1]; + if (!rawEntry) { + throw new Error("Cannot resolve T3 server entry path for jcode MCP bridge."); + } + // jcode spawns with the project cwd, so the entry must be absolute. + const serverEntryPath = NodePath.resolve(rawEntry); + + const authFilePath = jcodeMcpAuthFilePath(input.secretsDir, input.threadId); + NodeFS.mkdirSync(NodePath.dirname(authFilePath), { recursive: true }); + NodeFS.writeFileSync(authFilePath, `${input.authorizationHeader}\n`, { + encoding: "utf8", + mode: 0o600, + }); + + const server = buildJcodeMcpServerEntry({ + nodeExecutable, + serverEntryPath, + endpoint: input.endpoint, + authFilePath, + }); + + const configPath = NodePath.join(input.cwd, JCODE_MCP_CONFIG_RELATIVE_PATH); + NodeFS.mkdirSync(NodePath.dirname(configPath), { recursive: true }); + const existing = NodeFS.existsSync(configPath) ? NodeFS.readFileSync(configPath, "utf8") : null; + NodeFS.writeFileSync(configPath, mergeJcodeMcpConfigJson(existing, server), "utf8"); + + return { configPath, authFilePath }; +} + +export function clearJcodeMcpAuthFile(secretsDir: string, threadId: ThreadId): void { + const authFilePath = jcodeMcpAuthFilePath(secretsDir, threadId); + try { + NodeFS.unlinkSync(authFilePath); + } catch { + // absent is fine + } +} diff --git a/apps/server/src/textGeneration/JcodeTextGeneration.ts b/apps/server/src/textGeneration/JcodeTextGeneration.ts new file mode 100644 index 000000000000..0661f71d4a5d --- /dev/null +++ b/apps/server/src/textGeneration/JcodeTextGeneration.ts @@ -0,0 +1,246 @@ +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { type JcodeSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + makeJcodeAcpRuntime, + resolveJcodeAcpBaseModelId, +} from "../provider/acp/JcodeAcpSupport.ts"; + +const JCODE_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +export const makeJcodeTextGeneration = Effect.fn("makeJcodeTextGeneration")(function* ( + jcodeSettings: JcodeSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runGrokJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const resolvedModel = resolveJcodeAcpBaseModelId(modelSelection.model); + const outputRef = yield* Ref.make(""); + const runtime = yield* makeJcodeAcpRuntime({ + jcodeSettings: { ...jcodeSettings, model: resolvedModel }, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-jcode-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(JCODE_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Jcode ACP request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Jcode ACP request failed.", + cause, + }), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Jcode ACP request was cancelled." + : "Jcode Agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Jcode Agent returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Jcode ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("JcodeTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + + const generated = yield* runGrokJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("JcodeTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + + const generated = yield* runGrokJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("JcodeTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runGrokJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("JcodeTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runGrokJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..3146c5bc6539 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "jcode" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5cbe64cd413a..beb93bd0beac 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -545,6 +545,8 @@ const makeWsRpcLayer = ( switch (event.type) { case "project.created": case "project.meta-updated": + case "project.board-item-upserted": + case "project.board-item-deleted": return projectUpsertOrRemove(event.payload.projectId, event.sequence); case "project.deleted": return Effect.succeed( diff --git a/apps/web/index.html b/apps/web/index.html index f51d9432daef..af0a9eaf7e41 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -120,6 +120,48 @@ chrome: "#1d1929", }, }, + signal: { + light: { + background: "#fff5f2", + foreground: "#241523", + accent: "#ff3300", + chrome: "#fff5f2", + }, + dark: { + background: "#0a0a0a", + foreground: "#fffaff", + accent: "#ff3300", + chrome: "#0a0a0a", + }, + }, + volt: { + light: { + background: "#f4faf3", + foreground: "#241523", + accent: "#5ee000", + chrome: "#f4faf3", + }, + dark: { + background: "#0a0a0a", + foreground: "#fffaff", + accent: "#b8ff3c", + chrome: "#0a0a0a", + }, + }, + ion: { + light: { + background: "#f2f9fb", + foreground: "#241523", + accent: "#0891b2", + chrome: "#f2f9fb", + }, + dark: { + background: "#0a0a0a", + foreground: "#fffaff", + accent: "#22d3ee", + chrome: "#0a0a0a", + }, + }, }; // Keep the built-in mode list in sync with the runtime so the selected // T3 Chat palette is visible before React has mounted. @@ -138,6 +180,9 @@ "ocean", "ember", "iris", + "signal", + "volt", + "ion", "t3-chat-dark", "t3-grove", "t3-ocean", diff --git a/apps/web/package.json b/apps/web/package.json index dfec330a1079..42be9fba9013 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,6 +43,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", + "textmode.js": "^0.17.1", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/apps/web/src/assets/jcode-favicon.svg b/apps/web/src/assets/jcode-favicon.svg new file mode 100644 index 000000000000..76992c7488ee --- /dev/null +++ b/apps/web/src/assets/jcode-favicon.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 880bf8a507fe..72333bfdfc05 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -145,6 +145,7 @@ import { } from "../previewMiniPlayerStore"; import { RightPanelTabs } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; +import { ProjectBoardPanel } from "./ProjectBoardPanel"; import { deriveAgentPanelModel, foldSubagentActivities, @@ -243,9 +244,11 @@ import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; +import { JcodeAsciiIdle } from "./chat/JcodeAsciiIdle"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; +import { isJcodeDriverKind } from "./chat/ThreadProviderIcons"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; @@ -2602,6 +2605,11 @@ function ChatViewContent(props: ChatViewProps) { ? activeProviderStatus : null; const hasTimelineTopBanner = Boolean(threadError) || visibleProviderStatus !== null; + const showJcodeAsciiIdle = + isJcodeDriverKind(activeProviderStatus?.driver ?? selectedProvider) && + timelineEntries.length === 0 && + !isWorking && + !threadDetailLoading; const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -3219,6 +3227,14 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadRef) return; useRightPanelStore.getState().open(activeThreadRef, "agents"); }, [activeThreadRef]); + const addBoardSurface = useCallback(() => { + if (!activeThreadRef) return; + useRightPanelStore.getState().open(activeThreadRef, "board"); + }, [activeThreadRef]); + const toggleBoardSurface = useCallback(() => { + if (!activeThreadRef) return; + useRightPanelStore.getState().toggle(activeThreadRef, "board"); + }, [activeThreadRef]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -4624,6 +4640,13 @@ function ChatViewContent(props: ChatViewProps) { return; } + if (command === "board.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleBoardSurface(); + return; + } + if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); @@ -4718,6 +4741,7 @@ function ChatViewContent(props: ChatViewProps) { splitPanelTerminal, keybindings, onToggleDiff, + toggleBoardSurface, toggleRightPanel, toggleTerminalVisibility, composerRef, @@ -5988,6 +6012,8 @@ function ChatViewContent(props: ChatViewProps) { environmentId={activeThreadRef?.environmentId ?? null} threadId={activeThreadRef?.threadId ?? null} /> + ) : activeRightPanelSurface?.kind === "board" && activeProject ? ( + ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && activeProject && activeWorkspaceRoot ? ( @@ -6071,9 +6097,8 @@ function ChatViewContent(props: ChatViewProps) { error={threadError} onDismiss={() => setThreadError(activeThread.id, null)} /> - {/* Main content area with optional plan sidebar */} + {/* Chat column */}
- {/* Chat column */}
{/* Provider status overlays the timeline without changing its content height. */}
@@ -6084,6 +6109,7 @@ function ChatViewContent(props: ChatViewProps) {
{/* Messages Wrapper */}
+ {showJcodeAsciiIdle ? : null} {/* Messages — LegendList handles virtualization and scrolling internally */} , + shortcutCommand: "board.toggle", + run: async () => { + if (!boardThreadRef) return; + useRightPanelStore.getState().toggle(boardThreadRef, "board"); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..48cad6e1b094 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -211,6 +211,127 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +/** Official mark from https://jcode.sh/favicon.svg (currentColor for theme). */ +export const JcodeIcon: Icon = ({ className, ...props }) => ( + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/ProjectBoardPanel.tsx b/apps/web/src/components/ProjectBoardPanel.tsx new file mode 100644 index 000000000000..53ebb751c39d --- /dev/null +++ b/apps/web/src/components/ProjectBoardPanel.tsx @@ -0,0 +1,244 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ProjectBoardItem, ProjectId } from "@t3tools/contracts"; +import { ChevronDown, ChevronRight, ListTodo, X } from "lucide-react"; +import { useCallback, useMemo, useState, type FormEvent, type KeyboardEvent } from "react"; + +import { projectEnvironment } from "~/state/projects"; +import { useProject } from "~/state/entities"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { cn, newProjectBoardItemId } from "~/lib/utils"; +import { Checkbox } from "~/components/ui/checkbox"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible"; +import { Input } from "~/components/ui/input"; +import { ScrollArea } from "~/components/ui/scroll-area"; + +interface ProjectBoardPanelProps { + environmentId: EnvironmentId; + projectId: ProjectId; +} + +function isOpenStatus(status: ProjectBoardItem["status"]): boolean { + return status === "pending" || status === "inProgress"; +} + +function BoardItemRow({ + item, + onToggle, + onDelete, +}: { + item: ProjectBoardItem; + onToggle: (item: ProjectBoardItem) => void; + onDelete: (item: ProjectBoardItem) => void; +}) { + const completed = item.status === "completed"; + return ( +
+ onToggle(item)} + className="mt-0.5" + aria-label={completed ? `Mark "${item.title}" open` : `Mark "${item.title}" done`} + /> +
+
+ + {item.title} + + {item.source === "agent" ? ( + + agent + + ) : null} +
+ {item.notes ? ( +

{item.notes}

+ ) : null} +
+ +
+ ); +} + +export function ProjectBoardPanel({ environmentId, projectId }: ProjectBoardPanelProps) { + const project = useProject(scopeProjectRef(environmentId, projectId)); + const upsertBoardItem = useAtomCommand(projectEnvironment.upsertBoardItem); + const deleteBoardItem = useAtomCommand(projectEnvironment.deleteBoardItem); + const [draftTitle, setDraftTitle] = useState(""); + const [doneOpen, setDoneOpen] = useState(false); + + const boardItems = project?.boardItems ?? []; + const { openItems, doneItems } = useMemo(() => { + const open: ProjectBoardItem[] = []; + const done: ProjectBoardItem[] = []; + for (const item of boardItems) { + if (isOpenStatus(item.status)) open.push(item); + else done.push(item); + } + open.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + done.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return { openItems: open, doneItems: done }; + }, [boardItems]); + + const submitDraft = useCallback(async () => { + const title = draftTitle.trim(); + if (!title || !project) return; + setDraftTitle(""); + await upsertBoardItem({ + environmentId, + input: { + projectId, + itemId: newProjectBoardItemId(), + title, + status: "pending", + source: "user", + }, + }); + }, [draftTitle, environmentId, project, projectId, upsertBoardItem]); + + const onToggle = useCallback( + async (item: ProjectBoardItem) => { + await upsertBoardItem({ + environmentId, + input: { + projectId, + itemId: item.id, + title: item.title, + status: item.status === "completed" ? "pending" : "completed", + ...(item.notes !== undefined ? { notes: item.notes } : {}), + source: item.source, + ...(item.sourceThreadId !== undefined ? { sourceThreadId: item.sourceThreadId } : {}), + }, + }); + }, + [environmentId, projectId, upsertBoardItem], + ); + + const onDelete = useCallback( + async (item: ProjectBoardItem) => { + await deleteBoardItem({ + environmentId, + input: { projectId, itemId: item.id }, + }); + }, + [deleteBoardItem, environmentId, projectId], + ); + + const onDraftKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) return; + event.preventDefault(); + void submitDraft(); + }; + + const onDraftSubmit = (event: FormEvent) => { + event.preventDefault(); + void submitDraft(); + }; + + if (!project) { + return ( +
+ +

No project

+

+ Open a project thread to manage its board. +

+
+ ); + } + + const isEmpty = openItems.length === 0 && doneItems.length === 0; + + return ( +
+
+ setDraftTitle(event.target.value)} + onKeyDown={onDraftKeyDown} + placeholder="Add item…" + aria-label="Add board item" + className="min-w-0 flex-1" + /> + +
+ {isEmpty ? ( +
+ +

No board items

+

+ Track project todos here. Agents can add items too. +

+
+ ) : ( + +
+
+
+ Open + {openItems.length > 0 ? ( + + {openItems.length} + + ) : null} +
+ {openItems.length === 0 ? ( +

Nothing open

+ ) : ( + openItems.map((item) => ( + + )) + )} +
+ {doneItems.length > 0 ? ( + + + {doneOpen ? ( + + ) : ( + + )} + Done + + {doneItems.length} + + + + {doneItems.map((item) => ( + + ))} + + + ) : null} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5fa0d6c1c368..e8b17d18bbd2 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,6 @@ import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { Bot, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { Bot, FileDiff, Files, Globe2, ListTodo, Plus, TerminalSquare, X } from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, @@ -45,6 +45,7 @@ interface RightPanelTabsProps { onAddDiff: () => void; onAddFiles: () => void; onAddAgents: () => void; + onAddBoard: () => void; browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; @@ -95,6 +96,7 @@ function RightPanelEmptyState(props: { onAddDiff: () => void; onAddFiles: () => void; onAddAgents: () => void; + onAddBoard: () => void; browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; @@ -146,6 +148,15 @@ function RightPanelEmptyState(props: { onClick: props.onAddAgents, badgeCount: props.liveAgentCount, }, + { + label: "Board", + description: "Track project todos across threads.", + icon: ListTodo, + available: true, + disabledReason: null, + onClick: props.onAddBoard, + badgeCount: 0, + }, ] as const; return ( @@ -233,6 +244,8 @@ function surfaceTitle( ); case "agents": return "Agents"; + case "board": + return "Board"; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -294,6 +307,8 @@ function SurfaceIcon({ return ; case "agents": return ; + case "board": + return ; } } @@ -498,6 +513,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { Agents + + + Board + ) : null} @@ -513,6 +532,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddDiff={props.onAddDiff} onAddFiles={props.onAddFiles} onAddAgents={props.onAddAgents} + onAddBoard={props.onAddBoard} browserAvailable={props.browserAvailable} diffAvailable={props.diffAvailable} filesAvailable={props.filesAvailable} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 22c28df1ce66..06fb8bb8f49a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -151,10 +151,16 @@ import { type SnoozePreset, } from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; -import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; +import { + formatJcodeThreadProviderLabel, + isJcodeDriverKind, + resolveThreadInnerProviderIconKind, + ThreadProviderIcons, +} from "./chat/ThreadProviderIcons"; import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; -import { primaryServerProvidersAtom } from "../state/server"; +import { primaryServerProvidersAtom, primaryServerSettingsAtom } from "../state/server"; +import type { JcodeInnerProviderIconKind } from "@t3tools/shared/jcodeInnerProvider"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; @@ -242,6 +248,7 @@ function SidebarThreadTooltip({ driverKind, modelInstanceId, modelLabel, + innerProviderKind, branchMismatch, terminalStatus, terminalProcessCount, @@ -254,6 +261,7 @@ function SidebarThreadTooltip({ driverKind: ProviderInstanceEntry["driverKind"] | null; modelInstanceId: string; modelLabel: string; + innerProviderKind: JcodeInnerProviderIconKind | null; branchMismatch: { threadBranch: string; currentBranch: string; @@ -307,12 +315,20 @@ function SidebarThreadTooltip({ ) : null} {driverKind ? (
- -
{modelLabel}
+
+ {isJcodeDriverKind(driverKind) + ? formatJcodeThreadProviderLabel({ + innerKind: innerProviderKind, + modelLabel, + }) + : modelLabel} +
) : null} {terminalStatus ? ( @@ -678,6 +694,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { projectFaviconPath: string | null; projectTitle: string | null; providerEntryByInstanceId: ReadonlyMap; + jcodeIconContext: { + readonly providerInstances: + | Readonly> + | undefined; + readonly legacyJcodeProvider: string | undefined; + }; timestampFormat: TimestampFormat; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; onThreadActivate: (threadRef: ScopedThreadRef) => void; @@ -857,6 +879,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const modelLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : thread.modelSelection.model; + const innerProviderKind = resolveThreadInnerProviderIconKind({ + driverKind, + model: thread.modelSelection.model, + instanceId: modelInstanceId, + providerInstances: props.jcodeIconContext.providerInstances, + legacyJcodeProvider: props.jcodeIconContext.legacyJcodeProvider, + }); const isRemote = props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; @@ -871,6 +900,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { driverKind={driverKind} modelInstanceId={modelInstanceId} modelLabel={modelLabel} + innerProviderKind={innerProviderKind} branchMismatch={branchMismatch} terminalStatus={terminalStatus} terminalProcessCount={terminalProcessCount} @@ -1439,9 +1469,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} {driverKind ? ( - @@ -1473,6 +1504,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { projectTitle: string | null; environmentLabel: string | null; providerEntryByInstanceId: ReadonlyMap; + jcodeIconContext: { + readonly providerInstances: + | Readonly> + | undefined; + readonly legacyJcodeProvider: string | undefined; + }; isHighlighted: boolean; isRouteActive: boolean; resultId: string; @@ -1506,6 +1543,13 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { const modelLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : thread.modelSelection.model; + const innerProviderKind = resolveThreadInnerProviderIconKind({ + driverKind, + model: thread.modelSelection.model, + instanceId: modelInstanceId, + providerInstances: props.jcodeIconContext.providerInstances, + legacyJcodeProvider: props.jcodeIconContext.legacyJcodeProvider, + }); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, threadId: thread.id, @@ -1560,6 +1604,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { driverKind={driverKind} modelInstanceId={modelInstanceId} modelLabel={modelLabel} + innerProviderKind={innerProviderKind} branchMismatch={branchMismatch} terminalStatus={terminalStatus} terminalProcessCount={runningTerminalIds.length} @@ -1713,6 +1758,21 @@ export default function Sidebar() { [sidebarProjectSortOrder, threads, unsortedProjectGroups], ); const serverProviders = useAtomValue(primaryServerProvidersAtom); + const serverSettings = useAtomValue(primaryServerSettingsAtom); + const jcodeIconContext = useMemo( + () => ({ + providerInstances: serverSettings.providerInstances as + | Readonly> + | undefined, + legacyJcodeProvider: + typeof (serverSettings.providers as { jcode?: { jcodeProvider?: string } }).jcode + ?.jcodeProvider === "string" + ? (serverSettings.providers as { jcode?: { jcodeProvider?: string } }).jcode + ?.jcodeProvider + : undefined, + }), + [serverSettings.providerInstances, serverSettings.providers], + ); const providerEntryByInstanceId = useMemo( () => new Map( @@ -3380,6 +3440,7 @@ export default function Sidebar() { } environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} providerEntryByInstanceId={providerEntryByInstanceId} + jcodeIconContext={jcodeIconContext} isHighlighted={activeSearchResultIndex === index} isRouteActive={routeThreadKey === threadKey} resultId={`sidebar-thread-search-result-${index}`} @@ -3487,6 +3548,7 @@ export default function Sidebar() { ) ?? null } providerEntryByInstanceId={providerEntryByInstanceId} + jcodeIconContext={jcodeIconContext} timestampFormat={timestampFormat} onThreadClick={handleThreadClick} onThreadActivate={navigateToThread} diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index ee669e94bd47..0e96ce99287e 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -193,15 +193,41 @@ function NightlySkyArt() { function DevBlueprintArt() { const idPrefix = useId().replaceAll(":", ""); - const paperId = `${idPrefix}-stage-bp-paper`; - const glowId = `${idPrefix}-stage-bp-glow`; - const celesteGlowId = `${idPrefix}-stage-bp-glow-celeste`; - const violetGlowId = `${idPrefix}-stage-bp-glow-violet`; - const minorGridId = `${idPrefix}-stage-bp-grid-minor`; - const majorGridId = `${idPrefix}-stage-bp-grid-major`; - const rulerId = `${idPrefix}-stage-bp-ruler`; - const glowsId = `${idPrefix}-stage-bp-glows`; - const annotationsId = `${idPrefix}-stage-bp-annotations`; + const paperId = `${idPrefix}-stage-jc-paper`; + const glowId = `${idPrefix}-stage-jc-glow`; + const glow2Id = `${idPrefix}-stage-jc-glow-2`; + const dotsId = `${idPrefix}-stage-jc-dots`; + const softId = `${idPrefix}-stage-jc-soft`; + const glowsId = `${idPrefix}-stage-jc-glows`; + + // Sparse sampling of the jcode mark, mapped into a 288×96 tile. + const tileDots: ReadonlyArray<{ cx: number; cy: number; r: number; o: number }> = [ + { cx: 42, cy: 18, r: 1.8, o: 0.55 }, + { cx: 52, cy: 14, r: 2.1, o: 0.7 }, + { cx: 62, cy: 20, r: 1.9, o: 0.6 }, + { cx: 72, cy: 28, r: 2.0, o: 0.65 }, + { cx: 82, cy: 36, r: 1.7, o: 0.5 }, + { cx: 34, cy: 28, r: 1.6, o: 0.45 }, + { cx: 44, cy: 34, r: 2.0, o: 0.62 }, + { cx: 54, cy: 42, r: 2.2, o: 0.72 }, + { cx: 64, cy: 50, r: 2.1, o: 0.68 }, + { cx: 74, cy: 58, r: 1.8, o: 0.55 }, + { cx: 26, cy: 40, r: 1.5, o: 0.4 }, + { cx: 36, cy: 48, r: 1.9, o: 0.58 }, + { cx: 46, cy: 56, r: 2.0, o: 0.64 }, + { cx: 56, cy: 64, r: 1.9, o: 0.6 }, + { cx: 66, cy: 72, r: 1.6, o: 0.48 }, + { cx: 188, cy: 16, r: 1.7, o: 0.5 }, + { cx: 198, cy: 22, r: 2.0, o: 0.66 }, + { cx: 208, cy: 30, r: 1.8, o: 0.58 }, + { cx: 218, cy: 38, r: 2.1, o: 0.7 }, + { cx: 228, cy: 46, r: 1.7, o: 0.52 }, + { cx: 180, cy: 34, r: 1.5, o: 0.42 }, + { cx: 190, cy: 42, r: 1.9, o: 0.6 }, + { cx: 200, cy: 50, r: 2.0, o: 0.66 }, + { cx: 210, cy: 58, r: 1.8, o: 0.55 }, + { cx: 220, cy: 66, r: 1.5, o: 0.42 }, + ]; return ( - + - - - - - - - - + + + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + {tileDots.map((dot) => ( + + ))} - - - - - - - - + + + + + + + + + + - - - - + ); } diff --git a/apps/web/src/components/chat/JcodeAsciiIdle.tsx b/apps/web/src/components/chat/JcodeAsciiIdle.tsx new file mode 100644 index 000000000000..c4d51dfa21f9 --- /dev/null +++ b/apps/web/src/components/chat/JcodeAsciiIdle.tsx @@ -0,0 +1,220 @@ +import { memo, useEffect, useRef } from "react"; +import { textmode, type Textmodifier } from "textmode.js"; + +import { cn } from "~/lib/utils"; +import { + idleBackgroundRgb, + idleGlyphRgba, + readJcodeAsciiThemeColors, + type JcodeAsciiThemeColors, +} from "./jcodeAsciiTheme"; + +const RAMP = " .·:;=+*#%@"; +const TARGET_FPS = 12; +/** Keep the JS cell loop bounded — textmode's responsive grid can get large on wide panes. */ +const MAX_COLS = 64; +const MAX_ROWS = 28; +const PERF_KEY = "t3.debugJcodeAscii"; + +function prefersReducedMotion(): boolean { + return ( + typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +function measure(el: HTMLElement) { + const rect = el.getBoundingClientRect(); + return { + w: Math.max(0, Math.floor(rect.width)), + h: Math.max(0, Math.floor(rect.height)), + }; +} + +function clampGrid(tm: Textmodifier) { + const grid = tm.grid; + if (!grid) return; + if (grid.cols > MAX_COLS) grid.cols = MAX_COLS; + if (grid.rows > MAX_ROWS) grid.rows = MAX_ROWS; +} + +function paintBlob( + tm: Textmodifier, + ramp: { at: (n: number) => string }, + theme: JcodeAsciiThemeColors, +) { + const grid = tm.grid; + if (!grid) return; + + const bg = idleBackgroundRgb(theme.canvas); + tm.background(bg.r, bg.g, bg.b); + + const cols = grid.cols; + const rows = grid.rows; + if (cols < 2 || rows < 2) return; + + const halfCols = cols / 2; + const halfRows = rows / 2; + const t = tm.frameCount * 0.018; + const maxR = Math.hypot(halfCols, halfRows * 2) || 1; + + for (let row = 0; row < rows; row += 1) { + const y = row - halfRows + 0.5; + for (let col = 0; col < cols; col += 1) { + const x = col - halfCols + 0.5; + const dx = x; + const dy = y * 2; + const dist = Math.hypot(dx, dy) / maxR; + if (dist > 0.95) continue; + + const angle = Math.atan2(dy, dx); + const wobble = + tm.noise(Math.cos(angle) * 0.9 + 3, Math.sin(angle) * 0.9 + 3, t) * 0.45 + + tm.noise(col * 0.07, row * 0.07, t * 0.7) * 0.25; + const radius = 0.32 + wobble; + const edge = radius - dist; + if (edge < -0.06) continue; + + const interior = tm.noise(col * 0.11 + t, row * 0.11 - t * 0.6); + const level = Math.min(1, Math.max(0, edge * 2.4) * (0.35 + interior * 0.65)); + if (level < 0.08) continue; + + const [r, g, b, a] = idleGlyphRgba(theme.accent, level, interior, t + dist * 4); + + tm.push(); + tm.translate(x, y); + tm.char(ramp.at(level)); + tm.charColor(r, g, b, a); + tm.point(); + tm.pop(); + } + } +} + +/** Decorative ASCII blob — empty jcode chats only. Colors follow the active theme. */ +export const JcodeAsciiIdle = memo(function JcodeAsciiIdle(props: { className?: string }) { + const wrapRef = useRef(null); + + useEffect(() => { + const wrap = wrapRef.current; + if (!wrap) return; + + const reduced = prefersReducedMotion(); + const showPerf = typeof window !== "undefined" && window.localStorage.getItem(PERF_KEY) === "1"; + + let tm: Textmodifier | null = null; + let disposed = false; + let resizeTimer: ReturnType | null = null; + let perfEl: HTMLDivElement | null = null; + let frameMsEma = 0; + let lastPerfUi = 0; + let theme = readJcodeAsciiThemeColors(); + + if (showPerf) { + perfEl = document.createElement("div"); + perfEl.className = + "pointer-events-none absolute right-3 bottom-3 z-20 rounded bg-black/60 px-1.5 py-0.5 font-mono text-[10px] text-emerald-200/90"; + wrap.appendChild(perfEl); + } + + const mount = (w: number, h: number) => { + if (disposed || tm) return; + + try { + tm = textmode.create({ + width: w, + height: h, + fontSize: 14, + frameRate: reduced ? 1 : TARGET_FPS, + pixelDensity: Math.min(window.devicePixelRatio || 1, 1.25), + }); + } catch (error) { + console.error("[JcodeAsciiIdle] textmode create failed", error); + return; + } + + const canvas = tm.canvas; + canvas.className = "block size-full opacity-[0.48] dark:opacity-[0.55]"; + canvas.style.width = "100%"; + canvas.style.height = "100%"; + wrap.appendChild(canvas); + + clampGrid(tm); + const activeRamp = tm.createGlyphRamp(RAMP); + + tm.draw(() => { + if (!tm) return; + const started = showPerf ? performance.now() : 0; + paintBlob(tm, activeRamp, theme); + if (showPerf && perfEl) { + const ms = performance.now() - started; + frameMsEma = frameMsEma === 0 ? ms : frameMsEma * 0.9 + ms * 0.1; + const now = performance.now(); + if (now - lastPerfUi > 250) { + lastPerfUi = now; + const grid = tm.grid; + perfEl.textContent = `${frameMsEma.toFixed(1)}ms · ${grid?.cols ?? "?"}×${grid?.rows ?? "?"} · ${TARGET_FPS}fps cap`; + } + } + }); + + if (reduced) { + tm.noLoop(); + tm.redraw(); + } + }; + + const onVisibility = () => { + if (!tm || reduced || disposed) return; + if (document.hidden) tm.noLoop(); + else tm.loop(); + }; + document.addEventListener("visibilitychange", onVisibility); + + const themeObserver = new MutationObserver(() => { + theme = readJcodeAsciiThemeColors(); + if (reduced && tm) tm.redraw(); + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style", "data-theme-id"], + }); + + const observer = new ResizeObserver(() => { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + if (disposed) return; + const { w, h } = measure(wrap); + if (w < 8 || h < 8) return; + if (!tm) { + mount(w, h); + return; + } + tm.resizeCanvas(w, h); + clampGrid(tm); + }, 0); + }); + observer.observe(wrap); + + const { w, h } = measure(wrap); + if (w >= 8 && h >= 8) mount(w, h); + + return () => { + disposed = true; + if (resizeTimer) clearTimeout(resizeTimer); + document.removeEventListener("visibilitychange", onVisibility); + themeObserver.disconnect(); + observer.disconnect(); + perfEl?.remove(); + tm?.destroy(); + tm = null; + }; + }, []); + + return ( +
+ ); +}); diff --git a/apps/web/src/components/chat/ThreadProviderIcons.tsx b/apps/web/src/components/chat/ThreadProviderIcons.tsx new file mode 100644 index 000000000000..7db073035d22 --- /dev/null +++ b/apps/web/src/components/chat/ThreadProviderIcons.tsx @@ -0,0 +1,96 @@ +import { memo } from "react"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import { + readJcodeProviderSetting, + resolveJcodeInnerProviderIconKind, + type JcodeInnerProviderIconKind, +} from "@t3tools/shared/jcodeInnerProvider"; + +import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; +import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; +import { cn } from "~/lib/utils"; + +const INNER_KIND_TO_DRIVER: Record = { + claudeAgent: ProviderDriverKind.make("claudeAgent"), + cursor: ProviderDriverKind.make("cursor"), + codex: ProviderDriverKind.make("codex"), + grok: ProviderDriverKind.make("grok"), +}; + +const INNER_KIND_LABEL: Record = { + claudeAgent: "Claude", + cursor: "Cursor", + codex: "Codex", + grok: "Grok", +}; + +const JCODE_DRIVER = ProviderDriverKind.make("jcode"); + +export function formatJcodeThreadProviderLabel(input: { + readonly innerKind: JcodeInnerProviderIconKind | null | undefined; + readonly modelLabel: string; +}): string { + const model = input.modelLabel.trim() || "model"; + const inner = input.innerKind ? INNER_KIND_LABEL[input.innerKind] : null; + return inner ? `Jcode · ${inner} · ${model}` : `Jcode · ${model}`; +} + +export function isJcodeDriverKind(driverKind: ProviderDriverKind | null | undefined): boolean { + return driverKind === JCODE_DRIVER; +} + +export function resolveThreadInnerProviderIconKind(input: { + readonly driverKind: ProviderDriverKind | null | undefined; + readonly model: string | null | undefined; + readonly instanceId: string; + readonly providerInstances?: + | Readonly> + | null + | undefined; + readonly legacyJcodeProvider?: string | null | undefined; +}): JcodeInnerProviderIconKind | null { + if (input.driverKind !== JCODE_DRIVER) return null; + return resolveJcodeInnerProviderIconKind({ + jcodeProvider: readJcodeProviderSetting({ + instanceId: input.instanceId, + providerInstances: input.providerInstances, + legacyJcodeProvider: input.legacyJcodeProvider, + }), + model: input.model, + }); +} + +/** Jcode harness icon, optionally followed by the inner LLM brand icon. */ +export const ThreadProviderIcons = memo(function ThreadProviderIcons(props: { + driverKind: ProviderDriverKind; + displayName: string; + innerKind?: JcodeInnerProviderIconKind | null; + iconClassName?: string; + className?: string; +}) { + const innerDriver = props.innerKind ? INNER_KIND_TO_DRIVER[props.innerKind] : null; + const InnerIcon = innerDriver ? (PROVIDER_ICON_BY_PROVIDER[innerDriver] ?? null) : null; + const innerLabel = props.innerKind ? INNER_KIND_LABEL[props.innerKind] : null; + const title = + props.driverKind === JCODE_DRIVER + ? innerLabel + ? `Jcode · ${innerLabel}` + : "Jcode" + : props.displayName; + + return ( + + + {InnerIcon ? ( + + ) : null} + + ); +}); diff --git a/apps/web/src/components/chat/jcodeAsciiTheme.test.ts b/apps/web/src/components/chat/jcodeAsciiTheme.test.ts new file mode 100644 index 000000000000..08742a831bdd --- /dev/null +++ b/apps/web/src/components/chat/jcodeAsciiTheme.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + idleBackgroundRgb, + idleGlyphRgba, + parseCssColor, + readJcodeAsciiThemeColors, +} from "./jcodeAsciiTheme"; + +describe("jcodeAsciiTheme", () => { + it("parses hex and rgb theme colors", () => { + expect(parseCssColor("#0a0a0a")).toEqual({ r: 10, g: 10, b: 10 }); + expect(parseCssColor("#f30")).toEqual({ r: 255, g: 51, b: 0 }); + expect(parseCssColor("rgb(34, 211, 238)")).toEqual({ r: 34, g: 211, b: 238 }); + }); + + it("reads app theme CSS variables with fallbacks", () => { + const style = { + getPropertyValue: (name: string) => { + if (name === "--app-theme-canvas") return "#0a0a0a"; + if (name === "--app-theme-accent") return "#ff3300"; + return ""; + }, + }; + expect(readJcodeAsciiThemeColors(style)).toEqual({ + canvas: { r: 10, g: 10, b: 10 }, + accent: { r: 255, g: 51, b: 0 }, + }); + }); + + it("keeps idle backgrounds dark even on light canvases", () => { + expect(idleBackgroundRgb({ r: 255, g: 245, b: 242 })).toEqual({ + r: 56, + g: 54, + b: 53, + }); + }); + + it("tints glyphs from the accent", () => { + const [r, g, b, a] = idleGlyphRgba({ r: 255, g: 51, b: 0 }, 0.8, 0.5, 0); + expect(r).toBeGreaterThan(g); + expect(g).toBeGreaterThan(b); + expect(a).toBeGreaterThan(150); + }); +}); diff --git a/apps/web/src/components/chat/jcodeAsciiTheme.ts b/apps/web/src/components/chat/jcodeAsciiTheme.ts new file mode 100644 index 000000000000..af5405edc96a --- /dev/null +++ b/apps/web/src/components/chat/jcodeAsciiTheme.ts @@ -0,0 +1,83 @@ +export type ThemeRgb = Readonly<{ r: number; g: number; b: number }>; + +export type JcodeAsciiThemeColors = Readonly<{ + canvas: ThemeRgb; + accent: ThemeRgb; +}>; + +const FALLBACK_CANVAS: ThemeRgb = { r: 10, g: 10, b: 10 }; +const FALLBACK_ACCENT: ThemeRgb = { r: 255, g: 51, b: 0 }; + +function clampByte(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} + +/** Parse `#rgb` / `#rrggbb` / `rgb()` / `rgba()` from computed theme CSS. */ +export function parseCssColor(value: string): ThemeRgb | null { + const raw = value.trim(); + if (!raw) return null; + + const hex = raw.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hex?.[1]) { + const body = hex[1]; + if (body.length === 3) { + return { + r: Number.parseInt(body[0]! + body[0]!, 16), + g: Number.parseInt(body[1]! + body[1]!, 16), + b: Number.parseInt(body[2]! + body[2]!, 16), + }; + } + return { + r: Number.parseInt(body.slice(0, 2), 16), + g: Number.parseInt(body.slice(2, 4), 16), + b: Number.parseInt(body.slice(4, 6), 16), + }; + } + + const rgb = raw.match( + /^rgba?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)\s*[, ]\s*([0-9.]+)(?:\s*[,/]\s*[0-9.%]+)?\s*\)$/i, + ); + if (!rgb) return null; + return { + r: clampByte(Number(rgb[1])), + g: clampByte(Number(rgb[2])), + b: clampByte(Number(rgb[3])), + }; +} + +export function readJcodeAsciiThemeColors( + style: Pick = getComputedStyle(document.documentElement), +): JcodeAsciiThemeColors { + return { + canvas: parseCssColor(style.getPropertyValue("--app-theme-canvas")) ?? FALLBACK_CANVAS, + accent: parseCssColor(style.getPropertyValue("--app-theme-accent")) ?? FALLBACK_ACCENT, + }; +} + +/** Dark wash derived from the theme canvas so light themes still read as a field. */ +export function idleBackgroundRgb(canvas: ThemeRgb): ThemeRgb { + return { + r: clampByte(canvas.r * 0.22), + g: clampByte(canvas.g * 0.22), + b: clampByte(canvas.b * 0.22), + }; +} + +/** Accent-tinted glyph color; level/interior lift toward white, phase adds a tiny wobble. */ +export function idleGlyphRgba( + accent: ThemeRgb, + level: number, + interior: number, + phase: number, +): readonly [number, number, number, number] { + const lift = 0.35 + level * 0.5 + interior * 0.15; + const wobble = Math.sin(phase) * 0.08; + const towardWhite = Math.max(0, lift - 0.55 + wobble); + const base = 0.45 + lift * 0.4; + return [ + clampByte(accent.r * base + 255 * towardWhite), + clampByte(accent.g * base + 255 * towardWhite * 0.92), + clampByte(accent.b * base + 255 * towardWhite * 0.85), + clampByte(130 + level * 110), + ]; +} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1fe..b447aba98c04 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, Icon, JcodeIcon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("jcode")]: JcodeIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index 02897205e03a..3c0b6b22fe99 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -19,8 +19,11 @@ import { T3_CHAT_THEME, EMBER_THEME, GROVE_THEME, + ION_THEME, IRIS_THEME, OCEAN_THEME, + SIGNAL_THEME, + VOLT_THEME, } from "../../themePalette"; import { AlertDialog, @@ -52,6 +55,9 @@ const MAINTAINER_THEMES: ReadonlyArray = [ OCEAN_THEME, EMBER_THEME, IRIS_THEME, + SIGNAL_THEME, + VOLT_THEME, + ION_THEME, ]; function downloadThemeFile(filename: string, contents: string): void { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..6b87dff4a1b0 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,11 +3,20 @@ import { CodexSettings, CursorSettings, GrokSettings, + JcodeSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GrokIcon, + type Icon, + JcodeIcon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -61,6 +70,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("jcode"), + label: "Jcode", + icon: JcodeIcon, + badgeLabel: "Fork", + settingsSchema: JcodeSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 72983930aafb..af9e9d0f3bc3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -310,16 +310,24 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil ); } + /* Dev/jcode stage header follows the active theme accent. */ .stage-blueprint { - --stage-bp-top: #67c2ff; - --stage-bp-mid: #347ff8; - --stage-bp-bottom: #1538d0; + --stage-accent: var(--app-theme-accent, #a21caf); + --stage-surface: var(--app-theme-sidebar, var(--app-theme-canvas, #1a0a1f)); + --stage-bp-top: color-mix(in srgb, var(--stage-accent) 72%, white); + --stage-bp-mid: var(--stage-accent); + --stage-bp-bottom: color-mix(in srgb, var(--stage-accent) 28%, var(--stage-surface)); + --stage-bp-glow: color-mix(in srgb, var(--stage-accent) 80%, white); + --stage-bp-glow-mid: color-mix(in srgb, var(--stage-accent) 70%, transparent); + --stage-bp-glow-fade: color-mix(in srgb, var(--stage-surface) 80%, transparent); + --stage-bp-dot: color-mix(in srgb, var(--stage-accent) 45%, white); + --stage-bp-spark: color-mix(in srgb, var(--stage-accent) 30%, white); } .dark .stage-blueprint { - --stage-bp-top: #3a7ad1; - --stage-bp-mid: #2050ae; - --stage-bp-bottom: #101f6e; + --stage-bp-top: color-mix(in srgb, var(--stage-accent) 78%, white); + --stage-bp-mid: color-mix(in srgb, var(--stage-accent) 88%, black); + --stage-bp-bottom: color-mix(in srgb, var(--stage-accent) 22%, var(--stage-surface)); } .workspace-topbar { diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index b2be5eb7176a..31a11c65d51f 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -1,4 +1,4 @@ -import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { CommandId, MessageId, ProjectBoardItemId, ProjectId, ThreadId } from "@t3tools/contracts"; import { type CxOptions, cx } from "class-variance-authority"; import * as Encoding from "effect/Encoding"; import { twMerge } from "tailwind-merge"; @@ -36,6 +36,9 @@ export const newCommandId = (): CommandId => CommandId.make(randomUUID()); export const newProjectId = (): ProjectId => ProjectId.make(randomUUID()); +export const newProjectBoardItemId = (): ProjectBoardItemId => + ProjectBoardItemId.make(randomUUID()); + export const newThreadId = (): ThreadId => ThreadId.make(randomUUID()); export const newDraftId = (): DraftId => DraftId.make(randomUUID()); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 2e72c7b4e10a..4515544a1f79 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -21,6 +21,7 @@ export const RIGHT_PANEL_KINDS = [ "preview", "terminal", "agents", + "board", ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; @@ -44,7 +45,8 @@ export type RightPanelSurface = revealLine: number | null; revealRequestId: number; } - | { id: "agents"; kind: "agents" }; + | { id: "agents"; kind: "agents" } + | { id: "board"; kind: "board" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v9 removed the "plan" surface kind (plans render inline in the transcript). @@ -100,6 +102,8 @@ const singletonSurface = ( return { id: "files", kind }; case "agents": return { id: "agents", kind }; + case "board": + return { id: "board", kind }; } }; diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4d0a76cf133b..59ea5d94e421 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("jcode"), + label: "Jcode", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/apps/web/src/themeBoot.test.ts b/apps/web/src/themeBoot.test.ts index d6137e643818..597022495524 100644 --- a/apps/web/src/themeBoot.test.ts +++ b/apps/web/src/themeBoot.test.ts @@ -11,8 +11,11 @@ import { T3_CHAT_THEME, EMBER_THEME, GROVE_THEME, + ION_THEME, IRIS_THEME, OCEAN_THEME, + SIGNAL_THEME, + VOLT_THEME, THEME_APPEARANCE_MODE_STORAGE_KEY, THEME_FOLLOW_SYSTEM_STORAGE_KEY, } from "./themePalette"; @@ -279,7 +282,16 @@ describe("index.html boot script", () => { // boot script's hand-maintained copy into a CI-enforced contract: any // palette change breaks this test until the copy in index.html is updated. it("keeps every built-in boot splash in sync with the real palettes", () => { - for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) { + for (const theme of [ + T3_CHAT_THEME, + GROVE_THEME, + OCEAN_THEME, + EMBER_THEME, + IRIS_THEME, + SIGNAL_THEME, + VOLT_THEME, + ION_THEME, + ]) { // The boot script resolves every built-in from a light base appearance. expect(theme.appearance).toBe("light"); for (const mode of ["light", "dark"] as const) { diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 671b5dbb76d2..ef1f6ff0ee77 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -23,8 +23,11 @@ import { T3_CHAT_THEME, EMBER_THEME, GROVE_THEME, + ION_THEME, IRIS_THEME, OCEAN_THEME, + SIGNAL_THEME, + VOLT_THEME, updateCustomTheme, CUSTOM_THEMES_STORAGE_KEY, createManagedThemeColors, @@ -322,7 +325,16 @@ describe("theme files", () => { }); it("includes the dual-mode maintainer themes", () => { - for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) { + for (const theme of [ + T3_CHAT_THEME, + GROVE_THEME, + OCEAN_THEME, + EMBER_THEME, + IRIS_THEME, + SIGNAL_THEME, + VOLT_THEME, + ION_THEME, + ]) { expect(getThemeDefinition(theme.id)).toBe(theme); expect(getThemeModes(theme)).toEqual(["light", "dark"]); expect(theme.colors.accent).toMatch(/^#[0-9a-f]{6}$/i); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 686201fa2d92..7b0447a72ca4 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -10,6 +10,12 @@ export const EMBER_THEME_ID = "ember" as const; export const EMBER_THEME_LABEL = "Ember"; export const IRIS_THEME_ID = "iris" as const; export const IRIS_THEME_LABEL = "Iris"; +export const SIGNAL_THEME_ID = "signal" as const; +export const SIGNAL_THEME_LABEL = "Signal"; +export const VOLT_THEME_ID = "volt" as const; +export const VOLT_THEME_LABEL = "Volt"; +export const ION_THEME_ID = "ion" as const; +export const ION_THEME_LABEL = "Ion"; export const THEME_FILE_VERSION = 1 as const; export const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; export const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; @@ -122,6 +128,9 @@ const RESERVED_THEME_IDS = new Set([ OCEAN_THEME_ID, EMBER_THEME_ID, IRIS_THEME_ID, + SIGNAL_THEME_ID, + VOLT_THEME_ID, + ION_THEME_ID, LEGACY_T3_CHAT_DARK_THEME_ID, "t3-grove", "t3-ocean", @@ -1384,12 +1393,66 @@ export const IRIS_THEME: ThemeDefinition = { }, }; +/** Near-black + hot orange — matches the user's Superset custom theme seeds. */ +export const SIGNAL_THEME: ThemeDefinition = { + id: SIGNAL_THEME_ID, + label: SIGNAL_THEME_LABEL, + appearance: "light", + colors: { + ...createManagedThemeColors("light", "#fff5f2", "#ff3300", { exactSeeds: true }), + ...themeActionColors("#ff3300"), + }, + variants: { + dark: { + ...createManagedThemeColors("dark", "#0a0a0a", "#ff3300", { exactSeeds: true }), + ...themeActionColors("#ff3300"), + }, + }, +}; + +/** Near-black + acid lime. */ +export const VOLT_THEME: ThemeDefinition = { + id: VOLT_THEME_ID, + label: VOLT_THEME_LABEL, + appearance: "light", + colors: { + ...createManagedThemeColors("light", "#f4faf3", "#5ee000", { exactSeeds: true }), + ...themeActionColors("#5ee000"), + }, + variants: { + dark: { + ...createManagedThemeColors("dark", "#0a0a0a", "#b8ff3c", { exactSeeds: true }), + ...themeActionColors("#b8ff3c"), + }, + }, +}; + +/** Near-black + cold cyan. */ +export const ION_THEME: ThemeDefinition = { + id: ION_THEME_ID, + label: ION_THEME_LABEL, + appearance: "light", + colors: { + ...createManagedThemeColors("light", "#f2f9fb", "#0891b2", { exactSeeds: true }), + ...themeActionColors("#0891b2"), + }, + variants: { + dark: { + ...createManagedThemeColors("dark", "#0a0a0a", "#22d3ee", { exactSeeds: true }), + ...themeActionColors("#22d3ee"), + }, + }, +}; + const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray = [ T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME, + SIGNAL_THEME, + VOLT_THEME, + ION_THEME, ]; export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | null { diff --git a/docs/README.md b/docs/README.md index 51277fd73d28..afc62e54e420 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ - [Keyboard shortcuts](./user/keybindings.md) - [Organizing threads](./user/thread-sidebar.md) - [Customize a project icon](./user/project-settings.md) +- [Project Board](./user/project-board.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) diff --git a/docs/internals/jcode-provider.md b/docs/internals/jcode-provider.md new file mode 100644 index 000000000000..aee3afebea71 --- /dev/null +++ b/docs/internals/jcode-provider.md @@ -0,0 +1,106 @@ +# Jcode provider (fork) + +> Maintainer notes for the `jcode` driverKind added in this fork. + +## What jcode is + +[jcode](https://jcode.sh/) is an open-source terminal coding agent (Rust). It speaks the +[Agent Client Protocol](https://agentclientprotocol.com/) via: + +```bash +jcode acp +``` + +Confirmed against `jcode v0.75.0`: ACP over stdio uses newline-delimited JSON-RPC. +`initialize` returns `agentInfo.name: "jcode"`. `session/new` accepts a per-session `cwd`. + +## Chosen transport + +**Primary: `jcode acp`** (same family as Cursor/Grok ACP adapters). + +| Option | Why not for MVP | +| ------------------------------------------ | ----------------------------------------------------- | +| `jcode run --json/--ndjson` | One-shot; poor fit for T3 turn/session lifecycle | +| `jcode api-bridge` + `@1jehuang/jcode-sdk` | Parallel architecture; revisit if ACP is insufficient | +| TUI stdout parsing | Fragile; forbidden for MVP | + +Spawn shape (defaults): + +```text + [--quiet] acp [-C ] [-m ] [--provider-profile ] [-p ] [--socket ] +``` + +Process `cwd` and ACP `session/new.cwd` both use the T3 thread/worktree path +(`AcpSessionRuntime` already passes `options.cwd` into `session/new`). + +## Isolation (shared daemon) + +jcode ACP is “backed by the Jcode daemon”. Findings from local probes on +`jcode v0.75.0`: + +1. `initialize` succeeds without model auth and returns empty `authMethods`. +2. jcode **rejects** ACP `authenticate` (`Unsupported ACP method`). T3’s + `AcpSessionRuntime` therefore supports `skipAuthenticate: true` for this + driver only. +3. `session/new` works against a shared daemon with distinct `sessionId`s per + call. Isolation is **per ACP session `cwd`**, not one agent per workspace. +4. Stale default sockets under `$TMPDIR` can surface as `Address already in +use` during daemon boot; clearing stale `jcode.sock` / using a fresh + `--socket` recovers. +5. Prompting without configured model credentials fails with a clear + “no usable provider” error until `jcode login`. +6. Non-empty ACP `session/new.mcpServers` is rejected (`ACP mcpServers are not +supported yet`). Configure MCP in `~/.jcode/mcp.json` or project-local + `.jcode/mcp.json` / `.mcp.json` (stdio only; HTTP/SSE entries are skipped). + T3 does **not** pass HTTP `mcpServers` over ACP. On session start it upserts a + managed stdio `t3-code` entry into the project `.jcode/mcp.json` that proxies + to T3 HTTP `/mcp`. Turns also get a short `` hint for `board_*`. +7. ACP `session/set_model` is rejected (`Model switching is not available for +this provider`). Model selection is applied only via spawn `-m` (settings + and/or the thread’s selected model at session start). + +MVP guarantee: each T3 thread’s jcode session is created with that thread’s +worktree cwd. Spawn always includes `--no-selfdev`. Do not auto-start swarm. + +## Out of scope (MVP) + +- Session resume (`--resume` / ACP resume) — even though jcode advertises it +- Swarm, memory UI, browser automation UI +- api-bridge / TypeScript SDK +- Storing jcode OAuth/API keys inside T3 settings +- Injecting T3 MCP over ACP `mcpServers` (stdio bridge via `.jcode/mcp.json` is used instead) + +## Mapping onto T3 + +``` +UI (provider instance "Jcode") + -> orchestration.dispatch (existing) + -> ProviderCommandReactor (existing) + -> JcodeAdapter (ACP runtime events -> ProviderRuntimeEvent) + -> jcode acp subprocess +``` + +Files (mirror Grok/Cursor ACP stack): + +- `apps/server/src/provider/Drivers/JcodeDriver.ts` +- `apps/server/src/provider/Layers/JcodeAdapter.ts` +- `apps/server/src/provider/Layers/JcodeProvider.ts` +- `apps/server/src/provider/acp/JcodeAcpSupport.ts` +- `packages/contracts` `JcodeSettings` + `ServerSettings.providers.jcode` +- Web picker/settings/icons + +## Auth / probe UX + +Auth stays in jcode. Probe should: + +- detect missing binary (`jcode` / configured `binaryPath`); +- surface ACP/daemon startup failures with actionable text (`jcode login`, PATH); +- not claim “ready” if ACP session setup cannot start. + +## Future work + +1. Resume across T3 restarts +2. Optional per-instance `--socket` +3. Richer model catalog from jcode session model state / `jcode model` +4. api-bridge path if ACP gaps appear +5. Approvals/permissions polish beyond generic ACP mapping diff --git a/docs/internals/project-board.md b/docs/internals/project-board.md new file mode 100644 index 000000000000..5702d8286a78 --- /dev/null +++ b/docs/internals/project-board.md @@ -0,0 +1,39 @@ +# Project Board (internals) + +Project-scoped todos shared across threads. Source of truth is the orchestration event log; the UI reads `boardItems` on the project shell. + +## Data model + +- Contracts: `ProjectBoardItem`, `ProjectBoardItemId`, `PROJECT_BOARD_ITEM_LIMIT` in `packages/contracts` +- Commands: `project.board.item.upsert`, `project.board.item.delete` +- Events: `project.board-item-upserted`, `project.board-item-deleted` +- Aggregate: existing `project` kind (`aggregateId = projectId`) +- Projection: `projection_projects.board_items_json` (migration `041_ProjectionProjectsBoardItems`) +- Shell: `OrchestrationProjectShell.boardItems` (optional on the wire; treat missing as `[]`) + +## Live updates + +Board mutations fan out through the existing shell stream as `project-upserted` (`ws.ts` → `toShellStreamEvent`). No dedicated board subscription. + +## MCP + +Toolkit under `apps/server/src/mcp/toolkits/board/`: + +- `board_list` +- `board_upsert` +- `board_set_status` +- `board_delete` + +Capability `"board"` is issued with every MCP session token. Handlers resolve `projectId` from the invocation `threadId`, then dispatch through `OrchestrationEngineService` so UI and agents share one write path. + +### jcode exception + +jcode rejects non-empty ACP `mcpServers` and only loads stdio MCP from its config files (HTTP/SSE entries are skipped). On jcode `startSession`, the adapter upserts a managed stdio server named `t3-code` into the project’s `.jcode/mcp.json` that runs `node __jcode-mcp-stdio` and proxies NDJSON MCP to T3’s HTTP `/mcp` (auth via a 0600 file under `secretsDir/jcode-mcp/`). + +That gives jcode the same `board_*` (and other) MCP tools as Claude/Cursor. Turns still get a tiny `` hint (`projectBoardPrompt.ts`) so the model knows the tools exist—without dumping the full board into every prompt. + +## Clients + +- Web/desktop: right-panel surface `"board"` → `ProjectBoardPanel` +- Mobile: deferred (phase 2) +- Client commands: `upsertProjectBoardItem` / `deleteProjectBoardItem` in `packages/client-runtime` diff --git a/docs/user/install.md b/docs/user/install.md index fe0b418ca1e5..5afc8d46b2f7 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -46,13 +46,14 @@ yay -S t3code-bin T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want to use, then authenticate it. -| Provider | CLI | Default binary | Log in with | -| ---------- | ----------------------------------------------------- | -------------- | --------------------- | -| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | -| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | -| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | -| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | -| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | +| Provider | CLI | Default binary | Log in with | +| ------------ | ----------------------------------------------------- | -------------- | ------------------------------------------------- | +| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | +| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | +| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | +| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | +| Jcode (fork) | [jcode](https://jcode.sh/) | `jcode` | `jcode login` — see [Jcode](./providers-jcode.md) | +| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. diff --git a/docs/user/project-board.md b/docs/user/project-board.md new file mode 100644 index 000000000000..b6ddebaac34b --- /dev/null +++ b/docs/user/project-board.md @@ -0,0 +1,26 @@ +# Project Board + +Project Board is a shared todo list for a project. Items persist across threads in that project, so you and your agents can track work without copying checklists between chats. + +## Open the board + +In a thread for the project: + +- Open the right panel and choose **Board** +- Use the command palette: **Toggle project board** +- Shortcut: `Mod+Shift+B` (configurable) + +## Use the board + +- Add an item with the input at the top of the panel +- Check an item to mark it done; uncheck to reopen it +- Delete an item with the × control on the row +- Items created by an agent show a small **agent** badge + +Done items live in a collapsible **Done** section. The board is capped at 100 items per project. + +## Agents + +When an agent session is connected through T3 Code’s MCP tools (Claude, Cursor, Codex, Grok, OpenCode), it can list and update the same board with `board_list`, `board_upsert`, `board_set_status`, and `board_delete`. Changes show up live in the panel for every connected client. + +**jcode:** T3 installs a small local MCP bridge into the project’s `.jcode/mcp.json` when a jcode session starts, so jcode can use the same `board_*` tools. Each turn also gets a short reminder that those tools exist. Prefer letting the agent update the board via tools; the Board panel still works for manual edits. diff --git a/docs/user/providers-jcode.md b/docs/user/providers-jcode.md new file mode 100644 index 000000000000..cf37fd946703 --- /dev/null +++ b/docs/user/providers-jcode.md @@ -0,0 +1,73 @@ +# Jcode + +Use [jcode](https://jcode.sh/) as a coding-agent backend inside T3 Code (this fork). + +## What this integration supports (MVP) + +- Provider option **Jcode** in the model/provider picker +- Spawns `jcode acp` with the thread/worktree as cwd +- Streams assistant text, tools, and approvals through the existing ACP → orchestration path +- Optional settings: binary path, model (`-m`), jcode provider (`-p`), provider profile + +## Not supported yet + +- Session resume across T3 restarts +- Swarm / memory / browser automation UI +- `jcode api-bridge` / TypeScript SDK path +- Storing jcode OAuth/API keys in T3 settings +- Passing T3 MCP servers over ACP (T3 writes a local stdio bridge into `.jcode/mcp.json` instead) +- Mid-session model switching over ACP (pick the model before the session starts, or set it in Jcode settings) + +## Requirements + +1. Install jcode and put it on `PATH` (or set **Binary path** in provider settings). +2. Authenticate **LLM backends inside jcode itself** (this is separate from T3’s + Settings → Providers list). Enabling Codex/Claude/Cursor in T3 does **not** + log jcode in: + +```bash +jcode login --provider claude # Anthropic / Claude subscription +# or: jcode login --provider openai +# or: jcode login --provider cursor # interactive; must approve Cursor IDE creds +jcode auth status +jcode usage +``` + +3. Quick CLI check: + +```bash +jcode version +jcode acp --help +``` + +Optional in T3 → Jcode settings: **Jcode provider** (`-p`, e.g. `cursor`) and +**Model** (`-m`). Leave them empty to let jcode auto-pick after login. + +## Enable in T3 Code + +1. Start T3 as usual (`vp run dev` / desktop / `npx t3`). +2. Open provider settings and ensure a **Jcode** instance is enabled (or add one). +3. Create/select a thread and pick **Jcode** in the provider picker. +4. Send a prompt. File edits should show up in T3 diffs/checkpoints like other providers. + +## Diagnostics + +| Symptom | Likely cause | What to do | +| ------------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------- | +| “not installed or not on PATH” | binary missing | Install jcode or set Binary path | +| ACP startup failed | daemon/auth/socket | Run `jcode auth status`, `jcode login`, retry; check server logs | +| “No tokens/providers left” / “no usable provider” | jcode has no LLM login | `jcode login --provider …`, then `jcode auth status` / `jcode usage` | +| Cursor creds “available” but still fail | jcode won’t reuse Cursor IDE state without approval | Run `jcode login --provider cursor` in an interactive terminal | +| Unexpected self-dev behavior | cwd is a jcode checkout | T3 always passes `--no-selfdev` for harness control | +| “ACP mcpServers are not supported yet” | older build injected T3 MCP over ACP | Update / restart T3; board tools use the stdio bridge in `.jcode/mcp.json` | +| “Model switching is not available…” | older build used ACP set_model | Update / restart T3; model is set via `jcode acp -m …` at session start | + +## Limits + +- One ACP session per T3 thread (not “one global agent per workspace”) +- Shared jcode daemon is OK; sessions are isolated by `session/new` cwd +- jcode’s ACP surface may change across releases + +## See also + +- Design notes: [internals/jcode-provider.md](../internals/jcode-provider.md) diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index cb74f117b772..e392e508bce6 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -31,6 +31,8 @@ type CommandInput = Omit< export type CreateProjectInput = CommandInput<"project.create">; export type UpdateProjectInput = CommandInput<"project.meta.update">; export type DeleteProjectInput = CommandInput<"project.delete">; +export type UpsertProjectBoardItemInput = CommandInput<"project.board.item.upsert">; +export type DeleteProjectBoardItemInput = CommandInput<"project.board.item.delete">; export type CreateThreadInput = CommandInput<"thread.create">; export type DeleteThreadInput = CommandInput<"thread.delete">; export type ArchiveThreadInput = CommandInput<"thread.archive">; @@ -118,6 +120,24 @@ export const deleteProject: (input: DeleteProjectInput) => CommandEffect = Effec }); }); +export const upsertProjectBoardItem: (input: UpsertProjectBoardItemInput) => CommandEffect = + Effect.fn("EnvironmentCommands.upsertProjectBoardItem")(function* (input) { + return yield* dispatch({ + ...input, + type: "project.board.item.upsert", + commandId: yield* commandId(input), + }); + }); + +export const deleteProjectBoardItem: (input: DeleteProjectBoardItemInput) => CommandEffect = + Effect.fn("EnvironmentCommands.deleteProjectBoardItem")(function* (input) { + return yield* dispatch({ + ...input, + type: "project.board.item.delete", + commandId: yield* commandId(input), + }); + }); + export const createThread: (input: CreateThreadInput) => CommandEffect = Effect.fn( "EnvironmentCommands.createThread", )(function* (input) { diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc321547..fcccd12069ab 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -10,18 +10,24 @@ import { } from "./runtime.ts"; import { type CreateProjectInput, + type DeleteProjectBoardItemInput, type DeleteProjectInput, type UpdateProjectInput, + type UpsertProjectBoardItemInput, createProject, deleteProject, + deleteProjectBoardItem, updateProject, + upsertProjectBoardItem, } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; export type { CreateProjectInput, + DeleteProjectBoardItemInput, DeleteProjectInput, UpdateProjectInput, + UpsertProjectBoardItemInput, } from "../operations/commands.ts"; export interface OptimisticProjectFile { @@ -92,6 +98,18 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: projectConcurrency, }), + upsertBoardItem: createEnvironmentCommand(runtime, { + label: "environment-data:commands:project:board-item:upsert", + execute: (input: UpsertProjectBoardItemInput) => upsertProjectBoardItem(input), + scheduler: projectScheduler, + concurrency: projectConcurrency, + }), + deleteBoardItem: createEnvironmentCommand(runtime, { + label: "environment-data:commands:project:board-item:delete", + execute: (input: DeleteProjectBoardItemInput) => deleteProjectBoardItem(input), + scheduler: projectScheduler, + concurrency: projectConcurrency, + }), writeFile: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:write-file", tag: WS_METHODS.projectsWriteFile, diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 9a63f22c9ef2..cd353a0cb02a 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -56,6 +56,8 @@ export const ThreadId = makeEntityId("ThreadId"); export type ThreadId = typeof ThreadId.Type; export const ProjectId = makeEntityId("ProjectId"); export type ProjectId = typeof ProjectId.Type; +export const ProjectBoardItemId = makeEntityId("ProjectBoardItemId"); +export type ProjectBoardItemId = typeof ProjectBoardItemId.Type; export const EnvironmentId = makeEntityId("EnvironmentId"); export type EnvironmentId = typeof EnvironmentId.Type; export const CommandId = makeEntityId("CommandId"); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 3fcbf6ef5fdb..ed6c4ad66f80 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -55,6 +55,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "rightPanel.toggle", + "board.toggle", "diff.toggle", "preview.toggle", "preview.refresh", diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..fcf61ac585f5 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +const JCODE_DRIVER_KIND = ProviderDriverKind.make("jcode"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -152,6 +153,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", + [JCODE_DRIVER_KIND]: "Jcode", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 35fef721efa7..3bc918c2309c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -14,6 +14,7 @@ import { MessageId, NonNegativeInt, PositiveInt, + ProjectBoardItemId, ProjectId, ProviderItemId, ThreadId, @@ -217,6 +218,27 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check( ); export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; +/** Soft cap so shell snapshots stay small when the board rides on project-upserted. */ +export const PROJECT_BOARD_ITEM_LIMIT = 100; + +export const ProjectBoardItemStatus = Schema.Literals(["pending", "inProgress", "completed"]); +export type ProjectBoardItemStatus = typeof ProjectBoardItemStatus.Type; + +export const ProjectBoardItemSource = Schema.Literals(["user", "agent"]); +export type ProjectBoardItemSource = typeof ProjectBoardItemSource.Type; + +export const ProjectBoardItem = Schema.Struct({ + id: ProjectBoardItemId, + title: TrimmedNonEmptyString, + status: ProjectBoardItemStatus, + notes: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + source: ProjectBoardItemSource, + sourceThreadId: Schema.optional(Schema.NullOr(ThreadId)), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type ProjectBoardItem = typeof ProjectBoardItem.Type; + export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, @@ -229,6 +251,8 @@ export const OrchestrationProject = Schema.Struct({ // Optional on the wire so cached snapshots from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.Array(ProjectScript), + // Project-scoped todos shared across threads. Optional for older servers/clients. + boardItems: Schema.optional(Schema.Array(ProjectBoardItem)), createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), @@ -426,6 +450,8 @@ export const OrchestrationProjectShell = Schema.Struct({ // Optional on the wire so cached snapshots from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.Array(ProjectScript), + // Optional for older servers/clients; treat missing as []. + boardItems: Schema.optional(Schema.Array(ProjectBoardItem)), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -650,6 +676,25 @@ const ProjectDeleteCommand = Schema.Struct({ force: Schema.optional(Schema.Boolean), }); +const ProjectBoardItemUpsertCommand = Schema.Struct({ + type: Schema.Literal("project.board.item.upsert"), + commandId: CommandId, + projectId: ProjectId, + itemId: ProjectBoardItemId, + title: TrimmedNonEmptyString, + status: ProjectBoardItemStatus, + notes: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + source: Schema.optional(ProjectBoardItemSource), + sourceThreadId: Schema.optional(Schema.NullOr(ThreadId)), +}); + +const ProjectBoardItemDeleteCommand = Schema.Struct({ + type: Schema.Literal("project.board.item.delete"), + commandId: CommandId, + projectId: ProjectId, + itemId: ProjectBoardItemId, +}); + const ThreadCreateCommand = Schema.Struct({ type: Schema.Literal("thread.create"), commandId: CommandId, @@ -899,6 +944,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, + ProjectBoardItemUpsertCommand, + ProjectBoardItemDeleteCommand, ThreadCreateCommand, ThreadDeleteCommand, ThreadArchiveCommand, @@ -927,6 +974,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, + ProjectBoardItemUpsertCommand, + ProjectBoardItemDeleteCommand, ThreadCreateCommand, ThreadDeleteCommand, ThreadArchiveCommand, @@ -1045,6 +1094,8 @@ export const OrchestrationEventType = Schema.Literals([ "project.created", "project.meta-updated", "project.deleted", + "project.board-item-upserted", + "project.board-item-deleted", "thread.created", "thread.deleted", "thread.archived", @@ -1108,6 +1159,18 @@ export const ProjectDeletedPayload = Schema.Struct({ deletedAt: IsoDateTime, }); +export const ProjectBoardItemUpsertedPayload = Schema.Struct({ + projectId: ProjectId, + item: ProjectBoardItem, + updatedAt: IsoDateTime, +}); + +export const ProjectBoardItemDeletedPayload = Schema.Struct({ + projectId: ProjectId, + itemId: ProjectBoardItemId, + updatedAt: IsoDateTime, +}); + export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, @@ -1342,6 +1405,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("project.deleted"), payload: ProjectDeletedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("project.board-item-upserted"), + payload: ProjectBoardItemUpsertedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("project.board-item-deleted"), + payload: ProjectBoardItemDeletedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.created"), diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index e33615fa4c05..8c1ea5eb17d4 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -634,7 +634,7 @@ export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass()( "PreviewAutomationUnavailableError", { - capability: Schema.Literal("preview"), + capability: Schema.Literals(["preview", "board"]), environmentId: EnvironmentId, threadId: ThreadId, providerSessionId: TrimmedNonEmptyString, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 388205649c85..551b51e32f67 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -421,6 +421,54 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const JcodeSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("jcode").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the jcode CLI binary.", + providerSettingsForm: { placeholder: "jcode", clearWhenEmpty: "omit" }, + }), + ), + model: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Model", + description: "Optional jcode `-m/--model` value (e.g. claude-opus-5, gpt-5.5).", + providerSettingsForm: { placeholder: "claude-opus-5", clearWhenEmpty: "omit" }, + }), + ), + providerProfile: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Provider profile", + description: "Optional jcode `--provider-profile` from ~/.jcode/config.toml.", + providerSettingsForm: { placeholder: "my-gateway", clearWhenEmpty: "omit" }, + }), + ), + jcodeProvider: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Jcode provider", + description: "Optional jcode `-p/--provider` (claude, openai, openrouter, …).", + providerSettingsForm: { placeholder: "auto", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "model", "jcodeProvider", "providerProfile"], + }, +); +export type JcodeSettings = typeof JcodeSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -600,6 +648,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + jcode: JcodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -696,6 +745,15 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const JcodeSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + model: Schema.optionalKey(TrimmedString), + providerProfile: Schema.optionalKey(TrimmedString), + jcodeProvider: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -743,6 +801,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + jcode: Schema.optionalKey(JcodeSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), diff --git a/packages/shared/package.json b/packages/shared/package.json index f669bd0a452c..e1d09bd5232c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,6 +11,10 @@ "types": "./src/model.ts", "import": "./src/model.ts" }, + "./jcodeInnerProvider": { + "types": "./src/jcodeInnerProvider.ts", + "import": "./src/jcodeInnerProvider.ts" + }, "./advertisedEndpoint": { "types": "./src/advertisedEndpoint.ts", "import": "./src/advertisedEndpoint.ts" diff --git a/packages/shared/src/jcodeInnerProvider.test.ts b/packages/shared/src/jcodeInnerProvider.test.ts new file mode 100644 index 000000000000..8be0b4d44376 --- /dev/null +++ b/packages/shared/src/jcodeInnerProvider.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + readJcodeProviderSetting, + resolveJcodeInnerProviderIconKind, +} from "./jcodeInnerProvider.ts"; + +describe("resolveJcodeInnerProviderIconKind", () => { + it("maps explicit jcodeProvider values", () => { + expect(resolveJcodeInnerProviderIconKind({ jcodeProvider: "cursor" })).toBe("cursor"); + expect(resolveJcodeInnerProviderIconKind({ jcodeProvider: "claude" })).toBe("claudeAgent"); + expect(resolveJcodeInnerProviderIconKind({ jcodeProvider: "openai" })).toBe("codex"); + expect(resolveJcodeInnerProviderIconKind({ jcodeProvider: "auto" })).toBeNull(); + }); + + it("falls back to model slug heuristics", () => { + expect(resolveJcodeInnerProviderIconKind({ model: "claude-opus-5" })).toBe("claudeAgent"); + expect(resolveJcodeInnerProviderIconKind({ model: "gpt-5.5" })).toBe("codex"); + expect(resolveJcodeInnerProviderIconKind({ model: "composer-2" })).toBe("cursor"); + expect(resolveJcodeInnerProviderIconKind({ model: "grok-4" })).toBe("grok"); + }); + + it("prefers jcodeProvider over model", () => { + expect( + resolveJcodeInnerProviderIconKind({ + jcodeProvider: "cursor", + model: "claude-opus-5", + }), + ).toBe("cursor"); + }); +}); + +describe("readJcodeProviderSetting", () => { + it("reads instance config then legacy settings", () => { + expect( + readJcodeProviderSetting({ + instanceId: "jcode", + providerInstances: { + jcode: { config: { jcodeProvider: "cursor" } }, + }, + legacyJcodeProvider: "claude", + }), + ).toBe("cursor"); + + expect( + readJcodeProviderSetting({ + instanceId: "jcode", + providerInstances: {}, + legacyJcodeProvider: "openai", + }), + ).toBe("openai"); + }); +}); diff --git a/packages/shared/src/jcodeInnerProvider.ts b/packages/shared/src/jcodeInnerProvider.ts new file mode 100644 index 000000000000..91025143946f --- /dev/null +++ b/packages/shared/src/jcodeInnerProvider.ts @@ -0,0 +1,93 @@ +/** + * Resolve which LLM-backend brand icon to show next to the Jcode harness icon. + * + * jcode itself is not a model vendor — it routes to claude / openai / cursor / … + * via `-p` / login. T3 surfaces that as a second icon beside Jcode. + */ + +export type JcodeInnerProviderIconKind = "claudeAgent" | "cursor" | "codex" | "grok"; + +const JCODE_PROVIDER_TO_ICON: Record = { + claude: "claudeAgent", + "anthropic-api": "claudeAgent", + openai: "codex", + "openai-api": "codex", + "openai-compatible": "codex", + cursor: "cursor", + grok: "grok", + xai: "grok", +}; + +function normalizeToken(value: string | null | undefined): string { + return value?.trim().toLowerCase() ?? ""; +} + +function iconKindFromJcodeProvider(jcodeProvider: string): JcodeInnerProviderIconKind | null { + const token = normalizeToken(jcodeProvider); + if (!token || token === "auto") return null; + return JCODE_PROVIDER_TO_ICON[token] ?? null; +} + +function iconKindFromModelSlug(model: string): JcodeInnerProviderIconKind | null { + const slug = normalizeToken(model); + if (!slug) return null; + if ( + slug.includes("claude") || + slug.includes("anthropic") || + slug.includes("opus") || + slug.includes("sonnet") || + slug.includes("haiku") + ) { + return "claudeAgent"; + } + if (slug.includes("cursor") || slug.includes("composer")) { + return "cursor"; + } + if (slug.includes("grok") || slug.startsWith("xai")) { + return "grok"; + } + if ( + slug.includes("gpt") || + slug.includes("codex") || + slug.startsWith("o1") || + slug.startsWith("o3") || + slug.startsWith("o4") + ) { + return "codex"; + } + return null; +} + +/** + * Prefer an explicit jcode `-p` setting; fall back to model-slug heuristics. + */ +export function resolveJcodeInnerProviderIconKind(input: { + readonly jcodeProvider?: string | null | undefined; + readonly model?: string | null | undefined; +}): JcodeInnerProviderIconKind | null { + return ( + iconKindFromJcodeProvider(input.jcodeProvider ?? "") ?? iconKindFromModelSlug(input.model ?? "") + ); +} + +/** + * Read `jcodeProvider` from instance config or the legacy providers.jcode blob. + */ +export function readJcodeProviderSetting(input: { + readonly instanceId: string; + readonly providerInstances?: + | Readonly> + | null + | undefined; + readonly legacyJcodeProvider?: string | null | undefined; +}): string | undefined { + const config = input.providerInstances?.[input.instanceId]?.config; + if (config !== null && typeof config === "object") { + const value = (config as Record).jcodeProvider; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + const legacy = input.legacyJcodeProvider?.trim(); + return legacy || undefined; +} diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 158a9ffb1ac9..c000ea938aa6 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -22,6 +22,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+b", command: "sidebar.toggle" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+alt+b", command: "rightPanel.toggle" }, + { key: "mod+shift+b", command: "board.toggle", when: "!terminalFocus" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b9999931837..fbb63e7d7fa9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -602,6 +602,9 @@ importers: tailwind-merge: specifier: ^3.4.0 version: 3.6.0 + textmode.js: + specifier: ^0.17.1 + version: 0.17.1 zustand: specifier: ^5.0.11 version: 5.0.14(@types/react@19.2.16)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -9713,6 +9716,10 @@ packages: engines: {node: '>=10'} hasBin: true + textmode.js@0.17.1: + resolution: {integrity: sha512-lczkdYYf4taPwLv31F4FSMZkDiA3bOvHD27oOgUN+U2y1ZrOOhpSU7cB6OVUsUOxztioA5oNeNkK1hUXu464lA==} + engines: {node: '>=20.8.1'} + throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} @@ -21016,6 +21023,8 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + textmode.js@0.17.1: {} + throat@5.0.0: {} timestring@6.0.0: {} From 98be8a4f65b90627385000376d4470c7fd8be4fa Mon Sep 17 00:00:00 2001 From: Oleksandr Zabolotnyi Date: Mon, 10 Aug 2026 22:42:30 +0200 Subject: [PATCH 2/2] feat(chat): add file attachments, project board pet UI, and board turn linking Extends chat attachments beyond images, adds a pet UI over the project board, and links board turns from chat threads. Co-Authored-By: Claude Sonnet 5 --- .../settings/DesktopClientSettings.test.ts | 1 + apps/server/src/attachmentStore.ts | 14 +- apps/server/src/keybindings.test.ts | 1 + .../server/src/mcp/toolkits/board/handlers.ts | 76 ++++ apps/server/src/mcp/toolkits/board/tools.ts | 45 ++- .../Layers/ProjectionPipeline.ts | 2 +- apps/server/src/orchestration/Normalizer.ts | 50 ++- .../decider.projectBoard.test.ts | 47 +++ apps/server/src/orchestration/decider.ts | 6 + .../orchestration/projectBoardPrompt.test.ts | 1 + .../src/orchestration/projectBoardPrompt.ts | 3 +- .../src/provider/Layers/CodexAdapter.ts | 5 +- .../src/provider/Layers/CursorAdapter.ts | 3 + .../server/src/provider/Layers/GrokAdapter.ts | 2 +- .../src/provider/Layers/JcodeAdapter.ts | 2 +- .../src/provider/Layers/ProviderService.ts | 57 ++- apps/web/src/components/ChatView.logic.ts | 2 +- apps/web/src/components/ChatView.tsx | 35 +- apps/web/src/components/CommandPalette.tsx | 57 +++ .../ProjectBoardPanel.logic.test.ts | 116 ++++++ .../src/components/ProjectBoardPanel.logic.ts | 80 +++++ apps/web/src/components/ProjectBoardPanel.tsx | 332 ++++++++++++++---- apps/web/src/components/chat/ChatComposer.tsx | 175 +++++++-- .../src/components/chat/MessagesTimeline.tsx | 66 +++- apps/web/src/components/pet/PetAscii.tsx | 192 ++++++++++ apps/web/src/components/pet/PetHost.tsx | 96 +++++ apps/web/src/components/pet/PetOverlay.tsx | 111 ++++++ apps/web/src/components/pet/petMood.test.ts | 58 +++ apps/web/src/components/pet/petMood.ts | 48 +++ .../components/settings/SettingsPanels.tsx | 28 ++ .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/composerDraftStore.ts | 15 +- apps/web/src/hooks/useHandleNewThread.ts | 34 +- apps/web/src/lib/boardTurnLinkPending.ts | 26 ++ apps/web/src/lib/chatThreadActions.ts | 11 +- apps/web/src/petUiStore.test.ts | 25 ++ apps/web/src/petUiStore.ts | 58 +++ apps/web/src/routes/__root.tsx | 2 + apps/web/src/types.ts | 7 +- docs/internals/project-board.md | 21 ++ docs/user/keybindings.md | 3 + docs/user/project-board.md | 14 +- jcode-donut-area.png | Bin 0 -> 205680 bytes jcode-landing.png | Bin 0 -> 250313 bytes packages/contracts/src/keybindings.ts | 1 + packages/contracts/src/orchestration.ts | 56 ++- packages/contracts/src/settings.test.ts | 10 + packages/contracts/src/settings.ts | 4 + packages/shared/package.json | 8 + packages/shared/src/chatAttachments.test.ts | 38 ++ packages/shared/src/chatAttachments.ts | 138 ++++++++ packages/shared/src/keybindings.ts | 1 + packages/shared/src/projectBoard.test.ts | 90 +++++ packages/shared/src/projectBoard.ts | 86 +++++ 54 files changed, 2220 insertions(+), 144 deletions(-) create mode 100644 apps/web/src/components/ProjectBoardPanel.logic.test.ts create mode 100644 apps/web/src/components/ProjectBoardPanel.logic.ts create mode 100644 apps/web/src/components/pet/PetAscii.tsx create mode 100644 apps/web/src/components/pet/PetHost.tsx create mode 100644 apps/web/src/components/pet/PetOverlay.tsx create mode 100644 apps/web/src/components/pet/petMood.test.ts create mode 100644 apps/web/src/components/pet/petMood.ts create mode 100644 apps/web/src/lib/boardTurnLinkPending.ts create mode 100644 apps/web/src/petUiStore.test.ts create mode 100644 apps/web/src/petUiStore.ts create mode 100644 jcode-donut-area.png create mode 100644 jcode-landing.png create mode 100644 packages/shared/src/chatAttachments.test.ts create mode 100644 packages/shared/src/chatAttachments.ts create mode 100644 packages/shared/src/projectBoard.test.ts create mode 100644 packages/shared/src/projectBoard.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 861f72178a68..2092f5c26295 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -42,6 +42,7 @@ const clientSettings: ClientSettings = { legacySidebarEnabled: false, timestampFormat: "24-hour", wordWrap: true, + petEnabled: false, }; const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db217..8a69316ed9f4 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -8,9 +8,14 @@ import { normalizeAttachmentRelativePath, resolveAttachmentRelativePath, } from "./attachmentPaths.ts"; +import { inferChatFileExtension, SAFE_CHAT_FILE_EXTENSIONS } from "@t3tools/shared/chatAttachments"; import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; -const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; +const ATTACHMENT_FILENAME_EXTENSIONS = [ + ...SAFE_IMAGE_FILE_EXTENSIONS, + ...SAFE_CHAT_FILE_EXTENSIONS, + ".bin", +]; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; @@ -63,6 +68,13 @@ export function attachmentRelativePath(attachment: ChatAttachment): string { }); return `${attachment.id}${extension}`; } + case "file": { + const extension = inferChatFileExtension({ + mimeType: attachment.mimeType, + fileName: attachment.name, + }); + return `${attachment.id}${extension}`; + } } } diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index e26b56ce6402..fef0b0893002 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -204,6 +204,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.equal(defaultsByCommand.get("board.toggle"), "mod+shift+b"); + assert.equal(defaultsByCommand.get("composer.attachImages"), "mod+shift+a"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); diff --git a/apps/server/src/mcp/toolkits/board/handlers.ts b/apps/server/src/mcp/toolkits/board/handlers.ts index a2f40921cdc3..9d0e9c0df949 100644 --- a/apps/server/src/mcp/toolkits/board/handlers.ts +++ b/apps/server/src/mcp/toolkits/board/handlers.ts @@ -4,7 +4,9 @@ import { type ProjectBoardItem, type ProjectBoardItemStatus, type ProjectId, + type TurnId, } from "@t3tools/contracts"; +import { formatProjectBoardDigest } from "@t3tools/shared/projectBoard"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -74,6 +76,24 @@ const loadBoard = Effect.fn("BoardToolkit.loadBoard")(function* (projectId: Proj return project.value; }); +const resolveLatestTurnId = Effect.fn("BoardToolkit.resolveLatestTurnId")(function* ( + threadId: McpInvocationContext.McpInvocationScope["threadId"], +) { + const snapshots = yield* ProjectionSnapshotQuery; + const thread = yield* snapshots.getThreadShellById(threadId).pipe( + Effect.mapError( + (error) => + new BoardToolError({ + message: `Failed to resolve latest turn: ${errorMessage(error)}`, + }), + ), + ); + if (Option.isNone(thread)) { + return null; + } + return thread.value.latestTurn?.turnId ?? null; +}); + const nextCommandId = Effect.fn("BoardToolkit.nextCommandId")(function* () { const crypto = yield* Crypto.Crypto; const uuid = yield* crypto.randomUUIDv4.pipe(Effect.orDie); @@ -93,6 +113,7 @@ const dispatchUpsert = Effect.fn("BoardToolkit.dispatchUpsert")(function* (input readonly status: ProjectBoardItemStatus; readonly notes?: string | null | undefined; readonly sourceThreadId: McpInvocationContext.McpInvocationScope["threadId"]; + readonly linkTurnId?: TurnId | null | undefined; }) { const engine = yield* OrchestrationEngineService; yield* engine @@ -106,6 +127,7 @@ const dispatchUpsert = Effect.fn("BoardToolkit.dispatchUpsert")(function* (input ...(input.notes !== undefined ? { notes: input.notes } : {}), source: "agent", sourceThreadId: input.sourceThreadId, + ...(input.linkTurnId ? { linkTurnId: input.linkTurnId } : {}), }) .pipe( Effect.mapError( @@ -129,6 +151,22 @@ const handlers = { }; }), + board_digest: (_input: unknown) => + Effect.gen(function* () { + const scope = yield* requireBoardScope(); + const projectId = yield* resolveProjectId(scope.threadId); + const project = yield* loadBoard(projectId); + const items = project.boardItems ?? []; + return { + projectId, + digest: formatProjectBoardDigest(items), + inProgressCount: items.filter((item) => item.status === "inProgress").length, + pendingCount: items.filter((item) => item.status === "pending").length, + completedCount: items.filter((item) => item.status === "completed").length, + totalCount: items.length, + }; + }), + board_upsert: (input: { readonly itemId?: ProjectBoardItemId | undefined; readonly title: string; @@ -139,6 +177,7 @@ const handlers = { const scope = yield* requireBoardScope(); const projectId = yield* resolveProjectId(scope.threadId); const itemId = input.itemId ?? (yield* nextItemId()); + const linkTurnId = yield* resolveLatestTurnId(scope.threadId); yield* dispatchUpsert({ projectId, itemId, @@ -146,6 +185,7 @@ const handlers = { status: input.status, ...(input.notes !== undefined ? { notes: input.notes } : {}), sourceThreadId: scope.threadId, + linkTurnId, }); const project = yield* loadBoard(projectId); const item = @@ -167,6 +207,7 @@ const handlers = { message: `Board item '${input.itemId}' was not found.`, }); } + const linkTurnId = yield* resolveLatestTurnId(scope.threadId); yield* dispatchUpsert({ projectId, itemId: existing.id, @@ -174,6 +215,41 @@ const handlers = { status: input.status, notes: existing.notes ?? null, sourceThreadId: scope.threadId, + linkTurnId, + }); + const next = yield* loadBoard(projectId); + const item = (next.boardItems ?? []).find((entry) => entry.id === input.itemId) ?? null; + return { projectId, item }; + }), + + board_link_turn: (input: { + readonly itemId: ProjectBoardItemId; + readonly turnId?: TurnId | undefined; + }) => + Effect.gen(function* () { + const scope = yield* requireBoardScope(); + const projectId = yield* resolveProjectId(scope.threadId); + const project = yield* loadBoard(projectId); + const existing = (project.boardItems ?? []).find((entry) => entry.id === input.itemId); + if (!existing) { + return yield* new BoardToolError({ + message: `Board item '${input.itemId}' was not found.`, + }); + } + const linkTurnId = input.turnId ?? (yield* resolveLatestTurnId(scope.threadId)); + if (!linkTurnId) { + return yield* new BoardToolError({ + message: "No turnId provided and the current thread has no latest turn to link.", + }); + } + yield* dispatchUpsert({ + projectId, + itemId: existing.id, + title: existing.title, + status: existing.status, + notes: existing.notes ?? null, + sourceThreadId: scope.threadId, + linkTurnId, }); const next = yield* loadBoard(projectId); const item = (next.boardItems ?? []).find((entry) => entry.id === input.itemId) ?? null; diff --git a/apps/server/src/mcp/toolkits/board/tools.ts b/apps/server/src/mcp/toolkits/board/tools.ts index 3f537519c69c..61620280e71d 100644 --- a/apps/server/src/mcp/toolkits/board/tools.ts +++ b/apps/server/src/mcp/toolkits/board/tools.ts @@ -4,6 +4,7 @@ import { ProjectBoardItemStatus, ProjectId, TrimmedNonEmptyString, + TurnId, } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Schema from "effect/Schema"; @@ -29,6 +30,15 @@ const BoardListResult = Schema.Struct({ items: Schema.Array(ProjectBoardItem), }); +const BoardDigestResult = Schema.Struct({ + projectId: ProjectId, + digest: Schema.String, + inProgressCount: Schema.Number, + pendingCount: Schema.Number, + completedCount: Schema.Number, + totalCount: Schema.Number, +}); + const BoardMutateResult = Schema.Struct({ projectId: ProjectId, item: Schema.NullOr(ProjectBoardItem), @@ -47,9 +57,22 @@ export const BoardListTool = Tool.make("board_list", { .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true); +export const BoardDigestTool = Tool.make("board_digest", { + description: + "Return a compact project board digest (counts + titles by status). Prefer this over board_list when you only need orientation.", + parameters: Schema.Struct({}), + success: BoardDigestResult, + failure: BoardToolError, + dependencies, +}) + .annotate(Tool.Title, "Project board digest") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + export const BoardUpsertTool = Tool.make("board_upsert", { description: - "Create or update a project board todo. Pass itemId to update an existing item; omit it to create a new one. Status is pending | inProgress | completed.", + "Create or update a project board todo. Pass itemId to update an existing item; omit it to create a new one. Status is pending | inProgress | completed. Automatically links the project's current thread latest turn when available.", parameters: Schema.Struct({ itemId: Schema.optional(ProjectBoardItemId), title: TrimmedNonEmptyString, @@ -64,7 +87,8 @@ export const BoardUpsertTool = Tool.make("board_upsert", { .annotate(Tool.Destructive, false); export const BoardSetStatusTool = Tool.make("board_set_status", { - description: "Set the status of an existing project board todo.", + description: + "Set the status of an existing project board todo. Automatically links the current thread's latest turn when available.", parameters: Schema.Struct({ itemId: ProjectBoardItemId, status: ProjectBoardItemStatus, @@ -77,6 +101,21 @@ export const BoardSetStatusTool = Tool.make("board_set_status", { .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true); +export const BoardLinkTurnTool = Tool.make("board_link_turn", { + description: + "Link a turn to a project board item. Omit turnId to link the current thread's latest turn.", + parameters: Schema.Struct({ + itemId: ProjectBoardItemId, + turnId: Schema.optional(TurnId), + }), + success: BoardMutateResult, + failure: BoardToolError, + dependencies, +}) + .annotate(Tool.Title, "Link turn to board item") + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + export const BoardDeleteTool = Tool.make("board_delete", { description: "Delete a project board todo by id.", parameters: Schema.Struct({ @@ -91,7 +130,9 @@ export const BoardDeleteTool = Tool.make("board_delete", { export const BoardToolkit = Toolkit.make( BoardListTool, + BoardDigestTool, BoardUpsertTool, BoardSetStatusTool, + BoardLinkTurnTool, BoardDeleteTool, ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f2043ae9e0ba..967757d677b6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -338,7 +338,7 @@ function collectThreadAttachmentRelativePaths( const relativePaths = new Set(); for (const message of messages) { for (const attachment of message.attachments ?? []) { - if (attachment.type !== "image") { + if (attachment.type !== "image" && attachment.type !== "file") { continue; } const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 24c65900b296..fb3663c1e8e6 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -12,6 +12,7 @@ import { import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; +import { isAllowedChatFileMime, resolveChatFileMimeType } from "@t3tools/shared/chatAttachments"; import { parseBase64DataUrl } from "../imageMime.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -109,16 +110,16 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => (attachment) => Effect.gen(function* () { const parsed = parseBase64DataUrl(attachment.dataUrl); - if (!parsed || !parsed.mimeType.startsWith("image/")) { + if (!parsed) { return yield* new OrchestrationDispatchCommandError({ - message: `Invalid image attachment payload for '${attachment.name}'.`, + message: `Invalid attachment payload for '${attachment.name}'.`, }); } const bytes = Buffer.from(parsed.base64, "base64"); if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { return yield* new OrchestrationDispatchCommandError({ - message: `Image attachment '${attachment.name}' is empty or too large.`, + message: `Attachment '${attachment.name}' is empty or too large.`, }); } @@ -129,13 +130,42 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }); } - const persistedAttachment = { - type: "image" as const, - id: attachmentId, - name: attachment.name, - mimeType: parsed.mimeType.toLowerCase(), - sizeBytes: bytes.byteLength, - }; + let persistedAttachment; + if (attachment.type === "image") { + if (!parsed.mimeType.startsWith("image/")) { + return yield* new OrchestrationDispatchCommandError({ + message: `Invalid image attachment payload for '${attachment.name}'.`, + }); + } + persistedAttachment = { + type: "image" as const, + id: attachmentId, + name: attachment.name, + mimeType: parsed.mimeType.toLowerCase(), + sizeBytes: bytes.byteLength, + }; + } else { + const resolvedMime = + resolveChatFileMimeType({ + mimeType: parsed.mimeType || attachment.mimeType, + fileName: attachment.name, + }) ?? + (isAllowedChatFileMime(attachment.mimeType) + ? attachment.mimeType.toLowerCase() + : null); + if (!resolvedMime || !isAllowedChatFileMime(resolvedMime)) { + return yield* new OrchestrationDispatchCommandError({ + message: `Unsupported file attachment type for '${attachment.name}'.`, + }); + } + persistedAttachment = { + type: "file" as const, + id: attachmentId, + name: attachment.name, + mimeType: resolvedMime, + sizeBytes: bytes.byteLength, + }; + } const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, diff --git a/apps/server/src/orchestration/decider.projectBoard.test.ts b/apps/server/src/orchestration/decider.projectBoard.test.ts index f258d08be2ae..f29eb970c5a9 100644 --- a/apps/server/src/orchestration/decider.projectBoard.test.ts +++ b/apps/server/src/orchestration/decider.projectBoard.test.ts @@ -4,6 +4,7 @@ import { PROJECT_BOARD_ITEM_LIMIT, ProjectBoardItemId, ProjectId, + TurnId, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -196,6 +197,52 @@ it.layer(NodeServices.layer)("decider project board", (it) => { }), ); + it.effect("appends linkTurnId onto a board item", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const projectId = asProjectId("project-board-link-turn"); + let readModel = yield* projectEvent( + createEmptyReadModel(now), + createProjectEvent({ sequence: 1, projectId, now }), + ); + + const created = yield* decideOrchestrationCommand({ + command: { + type: "project.board.item.upsert", + commandId: CommandId.make("cmd-board-create-link"), + projectId, + itemId: asItemId("item-1"), + title: "Linked work", + status: "inProgress", + }, + readModel, + }); + const createdEvent = Array.isArray(created) ? created[0] : created; + if (!createdEvent || createdEvent.type !== "project.board-item-upserted") { + throw new Error("expected create event"); + } + readModel = yield* projectEvent(readModel, createdEvent); + + const linked = yield* decideOrchestrationCommand({ + command: { + type: "project.board.item.upsert", + commandId: CommandId.make("cmd-board-link"), + projectId, + itemId: asItemId("item-1"), + title: "Linked work", + status: "inProgress", + linkTurnId: TurnId.make("turn-1"), + }, + readModel, + }); + const linkedEvent = Array.isArray(linked) ? linked[0] : linked; + if (!linkedEvent || linkedEvent.type !== "project.board-item-upserted") { + throw new Error("expected link event"); + } + expect(linkedEvent.payload.item.linkedTurnIds).toEqual([TurnId.make("turn-1")]); + }), + ); + it.effect("enforces the board item limit on create", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 2c281c186ac2..82479b1be96b 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -6,6 +6,7 @@ import { type OrchestrationReadModel, type ProjectBoardItem, } from "@t3tools/contracts"; +import { mergeProjectBoardLinkedTurnIds } from "@t3tools/shared/projectBoard"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -376,6 +377,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command.sourceThreadId === undefined ? (existing?.sourceThreadId ?? null) : command.sourceThreadId, + linkedTurnIds: mergeProjectBoardLinkedTurnIds({ + existing: existing?.linkedTurnIds, + ...(command.linkedTurnIds !== undefined ? { linkedTurnIds: command.linkedTurnIds } : {}), + ...(command.linkTurnId !== undefined ? { linkTurnId: command.linkTurnId } : {}), + }), createdAt: existing?.createdAt ?? occurredAt, updatedAt: occurredAt, }; diff --git a/apps/server/src/orchestration/projectBoardPrompt.test.ts b/apps/server/src/orchestration/projectBoardPrompt.test.ts index 74e0b366752f..5bf938b416b5 100644 --- a/apps/server/src/orchestration/projectBoardPrompt.test.ts +++ b/apps/server/src/orchestration/projectBoardPrompt.test.ts @@ -11,6 +11,7 @@ describe("projectBoardPrompt", () => { const empty = formatProjectBoardPromptBlock([]); expect(empty).toContain(""); expect(empty).toContain("empty"); + expect(empty).toContain("board_digest"); expect(empty).toContain("board_list"); expect(empty).toContain("t3-code"); expect(empty).not.toContain("id="); diff --git a/apps/server/src/orchestration/projectBoardPrompt.ts b/apps/server/src/orchestration/projectBoardPrompt.ts index e0abda63ddd2..545f65775eeb 100644 --- a/apps/server/src/orchestration/projectBoardPrompt.ts +++ b/apps/server/src/orchestration/projectBoardPrompt.ts @@ -14,7 +14,8 @@ export function formatProjectBoardPromptBlock(items: ReadonlyArray", `Shared project todos (${summary}).`, - "Use MCP tools board_list / board_upsert / board_set_status / board_delete on server t3-code.", + "Use MCP tools board_digest / board_list / board_upsert / board_set_status / board_link_turn / board_delete on server t3-code.", + "Call board_digest for a compact status summary; board_link_turn to attach the current turn to a card.", "Do not ask the user to edit the Board panel for changes you can make with those tools.", "", ].join("\n"); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 6b99bf52b1e0..6e2a4a296dca 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1797,8 +1797,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }); const sendTurn: CodexAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + const imageAttachments = (input.attachments ?? []).filter( + (attachment) => attachment.type === "image", + ); const codexAttachments = yield* Effect.forEach( - input.attachments ?? [], + imageAttachments, (attachment) => resolveAttachment(input, attachment), { concurrency: 1 }, ); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 80475a5c2695..81adc5e10705 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -966,6 +966,9 @@ export function makeCursorAdapter( } if (input.attachments && input.attachments.length > 0) { for (const attachment of input.attachments) { + if (attachment.type !== "image") { + continue; + } const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment, diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 977cc8caaddb..a7354ca431a5 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -952,7 +952,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const text = input.input?.trim(); const imagePromptParts = yield* Effect.forEach( - input.attachments ?? [], + (input.attachments ?? []).filter((attachment) => attachment.type === "image"), (attachment) => Effect.gen(function* () { const attachmentPath = resolveAttachmentPath({ diff --git a/apps/server/src/provider/Layers/JcodeAdapter.ts b/apps/server/src/provider/Layers/JcodeAdapter.ts index 6a410f102476..d5c484f5d7ad 100644 --- a/apps/server/src/provider/Layers/JcodeAdapter.ts +++ b/apps/server/src/provider/Layers/JcodeAdapter.ts @@ -833,7 +833,7 @@ export function makeJcodeAdapter(jcodeSettings: JcodeSettings, options?: JcodeAd const text = input.input?.trim(); const imagePromptParts = yield* Effect.forEach( - input.attachments ?? [], + (input.attachments ?? []).filter((attachment) => attachment.type === "image"), (attachment) => Effect.gen(function* () { const attachmentPath = resolveAttachmentPath({ diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2ac00873df99..f0ebf46d925c 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off /** * ProviderServiceLive - Cross-provider orchestration layer. * @@ -36,6 +37,11 @@ import * as SchemaIssue from "effect/SchemaIssue"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { + isTextLikeChatFileMime, + PROVIDER_SEND_TURN_MAX_FILE_TEXT_CHARS, +} from "@t3tools/shared/chatAttachments"; +import * as NodeFS from "node:fs"; import * as ServerConfig from "../../config.ts"; import { increment, @@ -679,23 +685,52 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Adapters inline attachment pixels into the model prompt, but the model's // tools cannot dereference pixels. Appending the on-disk path is what lets // a turn like "include this screenshot in the PR" copy the actual file. - // This runs after schema decode, so the appended lines are exempt from the - // PROVIDER_SEND_TURN_MAX_INPUT_CHARS check; attachment count is capped, so - // the overhead is bounded. Unresolvable ids are skipped here and surface - // as adapter errors when the file is read for inlining. - const attachmentPathLines = attachments.flatMap((attachment) => { + // Text-like file attachments are also embedded (capped) so every provider + // sees their contents without per-adapter file APIs. This runs after schema + // decode, so the appended lines are exempt from + // PROVIDER_SEND_TURN_MAX_INPUT_CHARS; attachment count is capped, so the + // overhead is bounded. Unresolvable ids are skipped here and surface as + // adapter errors when the file is read for inlining. + const attachmentContextParts: string[] = []; + for (const attachment of attachments) { const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment, }); - return attachmentPath === null - ? [] - : [`[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`]; - }); + if (attachmentPath === null) continue; + attachmentContextParts.push( + `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, + ); + if (attachment.type === "file" && isTextLikeChatFileMime(attachment.mimeType)) { + try { + const raw = NodeFS.readFileSync(attachmentPath, "utf8"); + const truncated = + raw.length > PROVIDER_SEND_TURN_MAX_FILE_TEXT_CHARS + ? `${raw.slice(0, PROVIDER_SEND_TURN_MAX_FILE_TEXT_CHARS)} + +[...truncated ${raw.length - PROVIDER_SEND_TURN_MAX_FILE_TEXT_CHARS} characters]` + : raw; + attachmentContextParts.push( + `[Attached file "${attachment.name}" contents] +\`\`\` +${truncated} +\`\`\``, + ); + } catch { + attachmentContextParts.push( + `[Attached file "${attachment.name}" could not be read as text; use the path above.]`, + ); + } + } else if (attachment.type === "file") { + attachmentContextParts.push( + `[Attached file "${attachment.name}" (${attachment.mimeType}) — open/read it from the path above.]`, + ); + } + } const inputTextWithAttachmentPaths = - attachmentPathLines.length === 0 + attachmentContextParts.length === 0 ? parsed.input - : [parsed.input, attachmentPathLines.join("\n")] + : [parsed.input, attachmentContextParts.join("\n\n")] .filter((part): part is string => typeof part === "string" && part.length > 0) .join("\n\n"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 60df1cd966fd..04561b507c3e 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -43,7 +43,7 @@ export function hasEnvironmentReconnectWarningGraceElapsed( export function startNewThreadForProject( projectRef: ScopedProjectRef | null, - handleNewThread: (projectRef: ScopedProjectRef) => Promise, + handleNewThread: (projectRef: ScopedProjectRef) => Promise, ): boolean { if (projectRef === null) return false; void handleNewThread(projectRef); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 72333bfdfc05..6b6c3ce86413 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -42,7 +42,9 @@ import { createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; +import { indexProjectBoardItemsByTurnId } from "@t3tools/shared/projectBoard"; import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; +import { consumeBoardItemAwaitingTurnLink } from "~/lib/boardTurnLinkPending"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; @@ -1207,6 +1209,9 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const upsertBoardItem = useAtomCommand(projectEnvironment.upsertBoardItem, { + reportFailure: false, + }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); @@ -1658,6 +1663,31 @@ function ChatViewContent(props: ChatViewProps) { ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; const activeProject = useProject(activeProjectRef); + const boardItemsByTurnId = useMemo( + () => indexProjectBoardItemsByTurnId(activeProject?.boardItems ?? []), + [activeProject?.boardItems], + ); + useEffect(() => { + if (!activeThread || !activeLatestTurn?.turnId || !activeProject) return; + const itemId = consumeBoardItemAwaitingTurnLink(activeThread.id); + if (!itemId) return; + const item = (activeProject.boardItems ?? []).find((entry) => entry.id === itemId); + if (!item) return; + if (item.linkedTurnIds?.includes(activeLatestTurn.turnId)) return; + void upsertBoardItem({ + environmentId: activeThread.environmentId, + input: { + projectId: activeThread.projectId, + itemId: item.id, + title: item.title, + status: item.status, + ...(item.notes !== undefined ? { notes: item.notes } : {}), + source: item.source, + sourceThreadId: item.sourceThreadId ?? activeThread.id, + linkTurnId: activeLatestTurn.turnId, + }, + }); + }, [activeLatestTurn?.turnId, activeProject, activeThread, upsertBoardItem]); const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); @@ -5025,7 +5055,7 @@ function ChatViewContent(props: ChatViewProps) { }); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ - type: "image" as const, + type: image.type, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, @@ -5033,7 +5063,7 @@ function ChatViewContent(props: ChatViewProps) { })), ); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ - type: "image" as const, + type: image.type, id: image.id, name: image.name, mimeType: image.mimeType, @@ -6149,6 +6179,7 @@ function ChatViewContent(props: ChatViewProps) { hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} loadEarlier={loadEarlierTurns} + boardItemsByTurnId={boardItemsByTurnId} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4daed0c91a6c..97a441a81609 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -37,6 +37,7 @@ import { ListTodoIcon, MessageSquareIcon, PaletteIcon, + PaperclipIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -69,6 +70,8 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { formatProjectBoardDigest } from "@t3tools/shared/projectBoard"; +import { useComposerDraftStore } from "../composerDraftStore"; import { useProjects, useThreadShells } from "../state/entities"; import { useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; @@ -555,6 +558,7 @@ function OpenCommandPaletteDialog(props: { readonly clearOpenIntent: () => void; }) { const navigate = useNavigate(); + const composerHandleRef = useComposerHandleContext(); const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); @@ -1502,6 +1506,29 @@ function OpenCommandPaletteDialog(props: { ? scopeThreadRef(activeDraftThread.environmentId, activeDraftThread.threadId) : null; + actionItems.push({ + kind: "action", + value: "action:attach-composer-images", + searchTerms: [ + "attach", + "image", + "images", + "photo", + "upload", + "file", + "files", + "pdf", + "paperclip", + ], + title: "Attach files", + disabled: activeThread == null && activeDraftThread == null, + icon: , + shortcutCommand: "composer.attachImages", + run: async () => { + composerHandleRef?.current?.openImagePicker(); + }, + }); + actionItems.push({ kind: "action", value: "action:toggle-project-board", @@ -1516,6 +1543,36 @@ function OpenCommandPaletteDialog(props: { }, }); + actionItems.push({ + kind: "action", + value: "action:insert-project-board-digest", + searchTerms: ["board", "digest", "summary", "todos", "status", "project board"], + title: "Insert project board digest", + disabled: activeThread == null && activeDraftThread == null, + icon: , + run: async () => { + const environmentId = activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null; + const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + if (!environmentId || !projectId) return; + const project = projects.find( + (entry) => entry.id === projectId && entry.environmentId === environmentId, + ); + const digest = formatProjectBoardDigest(project?.boardItems ?? []); + const draftSession = useComposerDraftStore + .getState() + .getDraftSessionByProjectRef(scopeProjectRef(environmentId, projectId)); + const composerTarget = + activeThread != null + ? scopeThreadRef(activeThread.environmentId, activeThread.id) + : (draftSession?.draftId ?? null); + if (!composerTarget) return; + const existing = + useComposerDraftStore.getState().getComposerDraft(composerTarget)?.prompt ?? ""; + const nextPrompt = existing.trim().length > 0 ? `${existing.trim()}\n\n${digest}` : digest; + useComposerDraftStore.getState().setPrompt(composerTarget, nextPrompt); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", diff --git a/apps/web/src/components/ProjectBoardPanel.logic.test.ts b/apps/web/src/components/ProjectBoardPanel.logic.test.ts new file mode 100644 index 000000000000..afc5c56d420b --- /dev/null +++ b/apps/web/src/components/ProjectBoardPanel.logic.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { EnvironmentId, ProjectBoardItem, ThreadId } from "@t3tools/contracts"; + +import { + buildBoardImplementPrompt, + findDraftIdForThread, + nextProjectBoardItemStatus, + partitionProjectBoardItems, + projectBoardStatusLabel, +} from "./ProjectBoardPanel.logic"; + +function item( + partial: Pick & + Partial>, +): ProjectBoardItem { + return { + notes: null, + source: "user", + sourceThreadId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...partial, + }; +} + +describe("nextProjectBoardItemStatus", () => { + it("cycles pending → inProgress → completed → pending", () => { + expect(nextProjectBoardItemStatus("pending")).toBe("inProgress"); + expect(nextProjectBoardItemStatus("inProgress")).toBe("completed"); + expect(nextProjectBoardItemStatus("completed")).toBe("pending"); + }); +}); + +describe("partitionProjectBoardItems", () => { + it("splits and sorts sections", () => { + const items = [ + item({ + id: "done-old" as ProjectBoardItem["id"], + title: "Done old", + status: "completed", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + item({ + id: "pending-b" as ProjectBoardItem["id"], + title: "Pending B", + status: "pending", + createdAt: "2026-01-03T00:00:00.000Z", + }), + item({ + id: "pending-a" as ProjectBoardItem["id"], + title: "Pending A", + status: "pending", + createdAt: "2026-01-02T00:00:00.000Z", + }), + item({ + id: "wip" as ProjectBoardItem["id"], + title: "WIP", + status: "inProgress", + updatedAt: "2026-01-04T00:00:00.000Z", + }), + item({ + id: "done-new" as ProjectBoardItem["id"], + title: "Done new", + status: "completed", + updatedAt: "2026-01-05T00:00:00.000Z", + }), + ]; + + const partitioned = partitionProjectBoardItems(items); + expect(partitioned.inProgressItems.map((entry) => entry.id)).toEqual(["wip"]); + expect(partitioned.pendingItems.map((entry) => entry.id)).toEqual(["pending-a", "pending-b"]); + expect(partitioned.doneItems.map((entry) => entry.id)).toEqual(["done-new", "done-old"]); + }); +}); + +describe("buildBoardImplementPrompt", () => { + it("includes title, id, notes, and board tool reminder", () => { + const prompt = buildBoardImplementPrompt( + item({ + id: "item-1" as ProjectBoardItem["id"], + title: "Ship board implement", + status: "pending", + notes: "Keep the UI minimal.", + }), + ); + expect(prompt).toContain("Ship board implement"); + expect(prompt).toContain("item-1"); + expect(prompt).toContain("Keep the UI minimal."); + expect(prompt).toContain("board_set_status"); + }); +}); + +describe("projectBoardStatusLabel", () => { + it("labels each status", () => { + expect(projectBoardStatusLabel("pending")).toBe("Pending"); + expect(projectBoardStatusLabel("inProgress")).toBe("In progress"); + expect(projectBoardStatusLabel("completed")).toBe("Done"); + }); +}); + +describe("findDraftIdForThread", () => { + it("returns matching draft id", () => { + const environmentId = "env-1" as EnvironmentId; + const threadId = "thread-2" as ThreadId; + expect( + findDraftIdForThread({ + draftThreadsByThreadKey: { + "draft-1": { environmentId, threadId: "thread-1" as ThreadId }, + "draft-2": { environmentId, threadId }, + }, + environmentId, + threadId, + }), + ).toBe("draft-2"); + }); +}); diff --git a/apps/web/src/components/ProjectBoardPanel.logic.ts b/apps/web/src/components/ProjectBoardPanel.logic.ts new file mode 100644 index 000000000000..61d5d79a07d9 --- /dev/null +++ b/apps/web/src/components/ProjectBoardPanel.logic.ts @@ -0,0 +1,80 @@ +import type { + EnvironmentId, + ProjectBoardItem, + ProjectBoardItemStatus, + ThreadId, +} from "@t3tools/contracts"; + +/** Cycle open/done statuses for the board row control. */ +export function nextProjectBoardItemStatus(status: ProjectBoardItemStatus): ProjectBoardItemStatus { + switch (status) { + case "pending": + return "inProgress"; + case "inProgress": + return "completed"; + case "completed": + return "pending"; + } +} + +/** Find a local draft-session id for a board-linked thread id. */ +export function findDraftIdForThread(input: { + draftThreadsByThreadKey: Readonly< + Record + >; + environmentId: EnvironmentId; + threadId: ThreadId; +}): string | null { + for (const [draftId, session] of Object.entries(input.draftThreadsByThreadKey)) { + if (session.environmentId === input.environmentId && session.threadId === input.threadId) { + return draftId; + } + } + return null; +} + +export function partitionProjectBoardItems(items: ReadonlyArray): { + inProgressItems: ProjectBoardItem[]; + pendingItems: ProjectBoardItem[]; + doneItems: ProjectBoardItem[]; +} { + const inProgressItems: ProjectBoardItem[] = []; + const pendingItems: ProjectBoardItem[] = []; + const doneItems: ProjectBoardItem[] = []; + for (const item of items) { + if (item.status === "inProgress") inProgressItems.push(item); + else if (item.status === "pending") pendingItems.push(item); + else doneItems.push(item); + } + inProgressItems.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt)); + pendingItems.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + doneItems.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return { inProgressItems, pendingItems, doneItems }; +} + +export function buildBoardImplementPrompt(item: ProjectBoardItem): string { + const lines = [ + `Implement this project board item: ${item.title}`, + "", + `Board item id: ${item.id}`, + ]; + if (item.notes?.trim()) { + lines.push("", item.notes.trim()); + } + lines.push( + "", + "Use the project board MCP tools (`board_list`, `board_set_status`, `board_upsert`) to keep this item updated. Mark it completed when the work is done.", + ); + return lines.join("\n"); +} + +export function projectBoardStatusLabel(status: ProjectBoardItemStatus): string { + switch (status) { + case "pending": + return "Pending"; + case "inProgress": + return "In progress"; + case "completed": + return "Done"; + } +} diff --git a/apps/web/src/components/ProjectBoardPanel.tsx b/apps/web/src/components/ProjectBoardPanel.tsx index 53ebb751c39d..0a8d595339e1 100644 --- a/apps/web/src/components/ProjectBoardPanel.tsx +++ b/apps/web/src/components/ProjectBoardPanel.tsx @@ -1,41 +1,57 @@ -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId, ProjectBoardItem, ProjectId } from "@t3tools/contracts"; -import { ChevronDown, ChevronRight, ListTodo, X } from "lucide-react"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ProjectBoardItem, ProjectId, ThreadId } from "@t3tools/contracts"; +import { useRouter } from "@tanstack/react-router"; +import { ChevronDown, ChevronRight, ListTodo, Play, X } from "lucide-react"; import { useCallback, useMemo, useState, type FormEvent, type KeyboardEvent } from "react"; import { projectEnvironment } from "~/state/projects"; -import { useProject } from "~/state/entities"; +import { readThreadShell, useProject } from "~/state/entities"; import { useAtomCommand } from "~/state/use-atom-command"; +import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; +import { markBoardItemAwaitingTurnLink } from "~/lib/boardTurnLinkPending"; import { cn, newProjectBoardItemId } from "~/lib/utils"; import { Checkbox } from "~/components/ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible"; import { Input } from "~/components/ui/input"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; + +import { + buildBoardImplementPrompt, + findDraftIdForThread, + nextProjectBoardItemStatus, + partitionProjectBoardItems, + projectBoardStatusLabel, +} from "./ProjectBoardPanel.logic"; interface ProjectBoardPanelProps { environmentId: EnvironmentId; projectId: ProjectId; } -function isOpenStatus(status: ProjectBoardItem["status"]): boolean { - return status === "pending" || status === "inProgress"; -} - function BoardItemRow({ item, - onToggle, + onToggleDone, + onCycleStatus, onDelete, + onImplement, + onOpenLinkedThread, }: { item: ProjectBoardItem; - onToggle: (item: ProjectBoardItem) => void; + onToggleDone: (item: ProjectBoardItem) => void; + onCycleStatus: (item: ProjectBoardItem) => void; onDelete: (item: ProjectBoardItem) => void; + onImplement: (item: ProjectBoardItem) => void; + onOpenLinkedThread: (threadId: ThreadId) => void; }) { const completed = item.status === "completed"; + const linkedThreadId = item.sourceThreadId ?? null; return (
onToggle(item)} + onCheckedChange={() => onToggleDone(item)} className="mt-0.5" aria-label={completed ? `Mark "${item.title}" open` : `Mark "${item.title}" done`} /> @@ -49,6 +65,19 @@ function BoardItemRow({ > {item.title} + {item.source === "agent" ? ( agent @@ -58,7 +87,43 @@ function BoardItemRow({ {item.notes ? (

{item.notes}

) : null} + {(linkedThreadId || (item.linkedTurnIds?.length ?? 0) > 0) && ( +
+ {(item.linkedTurnIds?.length ?? 0) > 0 ? ( + + {item.linkedTurnIds?.length} linked turn + {item.linkedTurnIds?.length === 1 ? "" : "s"} + + ) : null} + {linkedThreadId ? ( + + ) : null} +
+ )}
+ {!completed ? ( + + onImplement(item)} + className="mt-0.5 inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground opacity-0 hover:bg-muted hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100" + aria-label={`Implement "${item.title}" in a new thread`} + > + + + } + /> + Implement in new thread + + ) : null}