diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 6774731a7..35bea0dd8 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -13,6 +13,9 @@ 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 * as ChatManager from "./toolkits/chat/ChatManager.ts"; +import { ChatToolkitHandlersLive } from "./toolkits/chat/handlers.ts"; +import { ChatToolkit } from "./toolkits/chat/tools.ts"; import { PreviewSnapshotToolkitHandlersLive, PreviewStandardToolkitHandlersLive, @@ -208,10 +211,18 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll( PreviewSnapshotRegistrationLive, ); +export const ChatToolkitRegistrationLive = McpServer.toolkit(ChatToolkit).pipe( + Layer.provide(ChatToolkitHandlersLive), + Layer.provide(ChatManager.layer), +); + const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, path: "/mcp", }).pipe(Layer.provide(McpAuthMiddlewareLive)); -export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive)); +export const layer = Layer.mergeAll( + PreviewToolkitRegistrationLive, + ChatToolkitRegistrationLive, +).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index b13bf2d31..5ada43d91 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 = "chat" | "preview"; export interface McpInvocationScope { readonly environmentId: EnvironmentId; @@ -25,7 +25,7 @@ export class McpInvocationContext extends Context.Service< >()("t3/mcp/McpInvocationContext") {} export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( - capability: McpCapability, + capability: "preview", ) { const invocation = yield* McpInvocationContext; if (!invocation.capabilities.has(capability)) { diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index a91d98feb..890b0a88d 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -47,6 +47,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t const resolved = yield* registry.resolve(token); expect(resolved?.threadId).toBe(threadId); + expect(resolved?.capabilities).toEqual(new Set(["chat", "preview"])); yield* registry.revokeThread(threadId); expect(yield* registry.resolve(token)).toBeUndefined(); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 67c4f2f0f..9dfdef4e1 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -114,7 +114,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(["chat", "preview"]), issuedAt, expiresAt, }; diff --git a/apps/server/src/mcp/toolkits/chat/ChatManager.test.ts b/apps/server/src/mcp/toolkits/chat/ChatManager.test.ts new file mode 100644 index 000000000..863baebb2 --- /dev/null +++ b/apps/server/src/mcp/toolkits/chat/ChatManager.test.ts @@ -0,0 +1,270 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThread, + type OrchestrationThreadShell, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../../../orchestration/Services/OrchestrationEngine.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { McpInvocationScope } from "../../McpInvocationContext.ts"; +import { ChatManager, deriveSpawnedChatTitle, layer } from "./ChatManager.ts"; + +const now = "2026-07-20T12:00:00.000Z"; +const environmentId = EnvironmentId.make("environment-chat-manager-test"); +const projectId = ProjectId.make("project-chat-manager-test"); +const currentThreadId = ThreadId.make("thread-current"); +const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", +}; + +const project = { + id: projectId, + title: "T3 Code", + workspaceRoot: "/workspace/t3", + repositoryIdentity: null, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("claude"), + model: "claude-sonnet-4-5", + }, + scripts: [], + createdAt: now, + updatedAt: now, +} satisfies OrchestrationProjectShell; + +const currentShell = { + id: currentThreadId, + projectId, + title: "Parent chat", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/chat-tools", + worktreePath: "/workspace/t3-worktree", + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, +} satisfies OrchestrationThreadShell; + +const currentDetail = { + id: currentThreadId, + projectId, + title: currentShell.title, + modelSelection, + runtimeMode: currentShell.runtimeMode, + interactionMode: currentShell.interactionMode, + branch: currentShell.branch, + worktreePath: currentShell.worktreePath, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, +} satisfies OrchestrationThread; + +const invocation: McpInvocationScope = { + environmentId, + threadId: currentThreadId, + providerSessionId: "provider-session-chat-manager-test", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["chat", "preview"]), + issuedAt: 1, + expiresAt: Number.MAX_SAFE_INTEGER, +}; + +function makeHarness() { + let sequence = 1; + let threads: OrchestrationThreadShell[] = [currentShell]; + const details = new Map([[currentThreadId, currentDetail]]); + const commands: OrchestrationCommand[] = []; + + const snapshot = (): OrchestrationShellSnapshot => ({ + snapshotSequence: sequence, + projects: [project], + threads, + updatedAt: now, + }); + + const engine = { + readEvents: () => Stream.empty, + streamDomainEvents: Stream.empty, + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + sequence += 1; + if (command.type === "thread.create") { + const shell = { + id: command.threadId, + projectId: command.projectId, + title: command.title, + modelSelection: command.modelSelection, + runtimeMode: command.runtimeMode, + interactionMode: command.interactionMode, + branch: command.branch, + worktreePath: command.worktreePath, + latestTurn: null, + createdAt: command.createdAt, + updatedAt: command.createdAt, + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } satisfies OrchestrationThreadShell; + threads = [...threads, shell]; + details.set(command.threadId, { + id: command.threadId, + projectId: command.projectId, + title: command.title, + modelSelection: command.modelSelection, + runtimeMode: command.runtimeMode, + interactionMode: command.interactionMode, + branch: command.branch, + worktreePath: command.worktreePath, + latestTurn: null, + createdAt: command.createdAt, + updatedAt: command.createdAt, + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }); + } + if (command.type === "thread.turn.start") { + threads = threads.map((thread) => + thread.id === command.threadId + ? { + ...thread, + latestTurn: { + turnId: TurnId.make("turn-starting"), + state: "running", + requestedAt: command.createdAt, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + latestUserMessageAt: command.createdAt, + updatedAt: command.createdAt, + } + : thread, + ); + } + if (command.type === "thread.meta.update" && command.title !== undefined) { + threads = threads.map((thread) => + thread.id === command.threadId ? { ...thread, title: command.title! } : thread, + ); + } + return { sequence }; + }), + } satisfies OrchestrationEngineShape; + + const query = { + getShellSnapshot: () => Effect.succeed(snapshot()), + getArchivedShellSnapshot: () => + Effect.succeed({ ...snapshot(), threads: threads.filter((thread) => thread.archivedAt) }), + getThreadDetailById: (threadId: ThreadId) => + Effect.succeed(Option.fromNullishOr(details.get(threadId))), + } as unknown as ProjectionSnapshotQueryShape; + + const provide = (effect: Effect.Effect) => + effect.pipe( + Effect.provide(layer), + Effect.provideService(OrchestrationEngineService, engine), + Effect.provideService(ProjectionSnapshotQuery, query), + Effect.provide(NodeServices.layer), + ); + + return { commands, provide }; +} + +it("derives compact visible titles from prompts", () => { + expect(deriveSpawnedChatTitle(" Review the authentication flow\nIgnore this line")).toBe( + "Review the authentication flow", + ); + expect(deriveSpawnedChatTitle("x".repeat(100))).toBe(`${"x".repeat(69)}...`); +}); + +it.effect("spawns a visible chat that inherits the caller settings and receives its prompt", () => { + const harness = makeHarness(); + return harness.provide( + Effect.gen(function* () { + const manager = yield* ChatManager; + const result = yield* manager.spawn(invocation, { + prompt: "Review the authentication flow", + }); + + expect(result.promptAccepted).toBe(true); + expect(result.chat).toMatchObject({ + title: "Review the authentication flow", + projectId, + state: "running", + modelSelection, + runtimeMode: "full-access", + }); + expect(result.chat.threadId).not.toBe(currentThreadId); + expect(harness.commands.map((command) => command.type)).toEqual([ + "thread.create", + "thread.turn.start", + ]); + expect(harness.commands[0]).toMatchObject({ + branch: currentShell.branch, + worktreePath: currentShell.worktreePath, + }); + expect(harness.commands[1]).toMatchObject({ + message: { text: "Review the authentication flow", role: "user", attachments: [] }, + }); + }), + ); +}); + +it.effect("prevents self-send and self-wait deadlocks", () => { + const harness = makeHarness(); + return harness.provide( + Effect.gen(function* () { + const manager = yield* ChatManager; + const sendError = yield* manager + .send(invocation, { threadId: currentThreadId, prompt: "Loop" }) + .pipe(Effect.flip); + expect(sendError.code).toBe("invalid_target"); + + const waitError = yield* manager + .wait(invocation, { threadIds: [currentThreadId], timeoutSeconds: 1 }) + .pipe(Effect.flip); + expect(waitError.code).toBe("invalid_target"); + expect(harness.commands).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/mcp/toolkits/chat/ChatManager.ts b/apps/server/src/mcp/toolkits/chat/ChatManager.ts new file mode 100644 index 000000000..6baddd72b --- /dev/null +++ b/apps/server/src/mcp/toolkits/chat/ChatManager.ts @@ -0,0 +1,585 @@ +import { + CommandId, + MessageId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, + ThreadId, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { OrchestrationEngineService } from "../../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { McpInvocationScope } from "../../McpInvocationContext.ts"; +import { + type ChatListInput, + type ChatListResult, + type ChatManageInput, + type ChatManageResult, + type ChatReadInput, + type ChatReadResult, + type ChatSendInput, + type ChatSendResult, + type ChatSpawnInput, + type ChatSpawnResult, + type ChatState, + type ChatSummary, + ChatToolError, + type ChatToolOperation, + type ChatWaitInput, + type ChatWaitResult, +} from "./schemas.ts"; + +const WAIT_POLL_INTERVAL = "250 millis"; + +const stateFromThread = (thread: { + readonly hasPendingApprovals: boolean; + readonly hasPendingUserInput: boolean; + readonly latestTurn: OrchestrationThreadShell["latestTurn"]; + readonly session: OrchestrationThreadShell["session"]; +}): ChatState => { + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return "waiting"; + if (thread.latestTurn?.state === "running" || thread.session?.status === "running") { + return "running"; + } + if (thread.session?.status === "starting") return "starting"; + if (thread.latestTurn?.state === "error" || thread.session?.status === "error") return "error"; + if (thread.latestTurn?.state === "interrupted") return "interrupted"; + if (thread.latestTurn?.state === "completed") return "completed"; + if (thread.session?.status === "stopped") return "stopped"; + return "idle"; +}; + +export const summarizeChat = (input: { + readonly thread: OrchestrationThreadShell; + readonly project: OrchestrationProjectShell; + readonly currentThreadId: ThreadId; +}): ChatSummary => ({ + threadId: input.thread.id, + projectId: input.thread.projectId, + projectTitle: input.project.title, + title: input.thread.title, + isCurrent: input.thread.id === input.currentThreadId, + archived: input.thread.archivedAt !== null, + state: stateFromThread(input.thread), + latestTurnState: input.thread.latestTurn?.state ?? null, + sessionStatus: input.thread.session?.status ?? null, + hasPendingApprovals: input.thread.hasPendingApprovals, + hasPendingUserInput: input.thread.hasPendingUserInput, + modelSelection: input.thread.modelSelection, + runtimeMode: input.thread.runtimeMode, + interactionMode: input.thread.interactionMode, + createdAt: input.thread.createdAt, + updatedAt: input.thread.updatedAt, +}); + +export const deriveSpawnedChatTitle = (prompt: string): string => { + const firstLine = prompt.split(/\r?\n/, 1)[0]?.trim().replaceAll(/\s+/g, " "); + if (!firstLine) return "Delegated chat"; + return firstLine.length <= 72 ? firstLine : `${firstLine.slice(0, 69).trimEnd()}...`; +}; + +const isChatSettled = (chat: ChatSummary): boolean => + chat.state !== "starting" && chat.state !== "running" && chat.state !== "waiting"; + +export interface ChatManagerShape { + readonly list: ( + scope: McpInvocationScope, + input: ChatListInput, + ) => Effect.Effect; + readonly read: ( + scope: McpInvocationScope, + input: ChatReadInput, + ) => Effect.Effect; + readonly spawn: ( + scope: McpInvocationScope, + input: ChatSpawnInput, + ) => Effect.Effect; + readonly send: ( + scope: McpInvocationScope, + input: ChatSendInput, + ) => Effect.Effect; + readonly wait: ( + scope: McpInvocationScope, + input: ChatWaitInput, + ) => Effect.Effect; + readonly manage: ( + scope: McpInvocationScope, + input: ChatManageInput, + ) => Effect.Effect; +} + +export class ChatManager extends Context.Service()( + "t3/mcp/toolkits/chat/ChatManager", +) {} + +const make = Effect.gen(function* () { + const query = yield* ProjectionSnapshotQuery; + const engine = yield* OrchestrationEngineService; + const crypto = yield* Crypto.Crypto; + + const failure = ( + scope: McpInvocationScope, + operation: ChatToolOperation, + code: ChatToolError["code"], + detail: string, + targetThreadId?: ThreadId, + ) => + new ChatToolError({ + operation, + code, + detail, + environmentId: scope.environmentId, + currentThreadId: scope.threadId, + ...(targetThreadId === undefined ? {} : { targetThreadId }), + }); + + const queryFailure = (scope: McpInvocationScope, operation: ChatToolOperation) => + failure(scope, operation, "query_failed", "T3 could not read the current chat state."); + const dispatchFailure = ( + scope: McpInvocationScope, + operation: ChatToolOperation, + targetThreadId?: ThreadId, + ) => + failure( + scope, + operation, + "dispatch_failed", + "T3 could not apply the requested chat operation.", + targetThreadId, + ); + + const getActiveShellSnapshot = (scope: McpInvocationScope, operation: ChatToolOperation) => + query.getShellSnapshot().pipe(Effect.mapError(() => queryFailure(scope, operation))); + + const getAllShellSnapshots = Effect.fn("ChatManager.getAllShellSnapshots")(function* ( + scope: McpInvocationScope, + operation: ChatToolOperation, + includeArchived: boolean, + ) { + const active = yield* getActiveShellSnapshot(scope, operation); + if (!includeArchived) return active; + const archived = yield* query + .getArchivedShellSnapshot() + .pipe(Effect.mapError(() => queryFailure(scope, operation))); + return { + ...active, + projects: active.projects, + threads: [...active.threads, ...archived.threads], + }; + }); + + const summarizeFromSnapshot = ( + scope: McpInvocationScope, + operation: ChatToolOperation, + snapshot: OrchestrationShellSnapshot, + thread: OrchestrationThreadShell, + ): Effect.Effect => { + const project = snapshot.projects.find((entry) => entry.id === thread.projectId); + return project + ? Effect.succeed(summarizeChat({ thread, project, currentThreadId: scope.threadId })) + : Effect.fail( + failure( + scope, + operation, + "project_not_found", + `Project '${thread.projectId}' for chat '${thread.id}' was not found.`, + thread.id, + ), + ); + }; + + const requireActiveThreadShell = Effect.fn("ChatManager.requireActiveThreadShell")(function* ( + scope: McpInvocationScope, + operation: ChatToolOperation, + threadId: ThreadId, + ) { + const snapshot = yield* getActiveShellSnapshot(scope, operation); + const thread = snapshot.threads.find((entry) => entry.id === threadId); + if (!thread) { + return yield* failure( + scope, + operation, + "chat_not_found", + `Active T3 chat '${threadId}' was not found.`, + threadId, + ); + } + return { snapshot, thread }; + }); + + const requireCurrentThread = Effect.fn("ChatManager.requireCurrentThread")(function* ( + scope: McpInvocationScope, + operation: ChatToolOperation, + ) { + const current = yield* query + .getThreadDetailById(scope.threadId) + .pipe(Effect.mapError(() => queryFailure(scope, operation))); + if (Option.isNone(current)) { + return yield* failure( + scope, + operation, + "current_chat_not_found", + `The calling T3 chat '${scope.threadId}' is no longer active.`, + ); + } + return current.value; + }); + + const makeCommandId = (operation: string) => + crypto.randomUUIDv4.pipe( + Effect.orDie, + Effect.map((id) => CommandId.make(`mcp:chat:${operation}:${id}`)), + ); + const makeThreadId = crypto.randomUUIDv4.pipe(Effect.orDie, Effect.map(ThreadId.make)); + const makeMessageId = crypto.randomUUIDv4.pipe(Effect.orDie, Effect.map(MessageId.make)); + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + const dispatch = ( + scope: McpInvocationScope, + operation: ChatToolOperation, + command: OrchestrationCommand, + targetThreadId?: ThreadId, + ) => + engine + .dispatch(command) + .pipe(Effect.mapError(() => dispatchFailure(scope, operation, targetThreadId))); + + const list: ChatManagerShape["list"] = Effect.fn("ChatManager.list")(function* (scope, input) { + const snapshot = yield* getAllShellSnapshots(scope, "list", input.includeArchived ?? false); + const chats: ChatSummary[] = []; + for (const thread of snapshot.threads) { + if (input.projectId !== undefined && thread.projectId !== input.projectId) continue; + chats.push(yield* summarizeFromSnapshot(scope, "list", snapshot, thread)); + } + chats.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + return { + environmentId: scope.environmentId, + currentThreadId: scope.threadId, + chats, + }; + }); + + const read: ChatManagerShape["read"] = Effect.fn("ChatManager.read")(function* (scope, input) { + const { snapshot, thread: shell } = yield* requireActiveThreadShell( + scope, + "read", + input.threadId, + ); + const detail = yield* query + .getThreadDetailById(input.threadId) + .pipe(Effect.mapError(() => queryFailure(scope, "read"))); + if (Option.isNone(detail)) { + return yield* failure( + scope, + "read", + "chat_not_found", + `Active T3 chat '${input.threadId}' was not found.`, + input.threadId, + ); + } + + const messageLimit = input.messageLimit ?? 20; + const activityLimit = input.activityLimit ?? 10; + const maxChars = input.maxCharsPerMessage ?? 4_000; + const messages = detail.value.messages.slice(-messageLimit).map((message) => ({ + id: message.id, + role: message.role, + text: + message.text.length <= maxChars + ? message.text + : `${message.text.slice(0, Math.max(0, maxChars - 1))}…`, + truncated: message.text.length > maxChars, + streaming: message.streaming, + turnId: message.turnId, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + })); + const activities = detail.value.activities.slice(-activityLimit).map((activity) => ({ + id: activity.id, + tone: activity.tone, + kind: activity.kind, + summary: activity.summary, + turnId: activity.turnId, + createdAt: activity.createdAt, + })); + + return { + chat: yield* summarizeFromSnapshot(scope, "read", snapshot, shell), + messages, + activities, + }; + }); + + const spawn: ChatManagerShape["spawn"] = Effect.fn("ChatManager.spawn")(function* (scope, input) { + const current = yield* requireCurrentThread(scope, "spawn"); + const snapshot = yield* getActiveShellSnapshot(scope, "spawn"); + const projectId = input.projectId ?? current.projectId; + const project = snapshot.projects.find((entry) => entry.id === projectId); + if (!project) { + return yield* failure( + scope, + "spawn", + "project_not_found", + `Active T3 project '${projectId}' was not found.`, + ); + } + + const threadId = yield* makeThreadId; + const messageId = yield* makeMessageId; + const createdAt = yield* nowIso; + const title = input.title ?? deriveSpawnedChatTitle(input.prompt); + const sameProject = projectId === current.projectId; + const modelSelection = input.modelSelection ?? current.modelSelection; + const runtimeMode = input.runtimeMode ?? current.runtimeMode; + const interactionMode = input.interactionMode ?? current.interactionMode; + + yield* dispatch( + scope, + "spawn", + { + type: "thread.create", + commandId: yield* makeCommandId("spawn-create"), + threadId, + projectId, + title, + modelSelection, + runtimeMode, + interactionMode, + branch: sameProject ? current.branch : null, + worktreePath: sameProject ? current.worktreePath : null, + createdAt, + }, + threadId, + ); + yield* dispatch( + scope, + "spawn", + { + type: "thread.turn.start", + commandId: yield* makeCommandId("spawn-prompt"), + threadId, + message: { + messageId, + role: "user", + text: input.prompt, + attachments: [], + }, + modelSelection, + titleSeed: title, + runtimeMode, + interactionMode, + createdAt, + }, + threadId, + ); + + const updated = yield* getActiveShellSnapshot(scope, "spawn"); + const thread = updated.threads.find((entry) => entry.id === threadId); + if (!thread) { + return yield* failure( + scope, + "spawn", + "query_failed", + `T3 created chat '${threadId}' but could not read it back.`, + threadId, + ); + } + return { + chat: yield* summarizeFromSnapshot(scope, "spawn", updated, thread), + promptAccepted: true, + }; + }); + + const send: ChatManagerShape["send"] = Effect.fn("ChatManager.send")(function* (scope, input) { + if (input.threadId === scope.threadId) { + return yield* failure( + scope, + "send", + "invalid_target", + "A chat cannot send a prompt to itself. Continue the current response instead.", + input.threadId, + ); + } + const { thread } = yield* requireActiveThreadShell(scope, "send", input.threadId); + const createdAt = yield* nowIso; + yield* dispatch( + scope, + "send", + { + type: "thread.turn.start", + commandId: yield* makeCommandId("send"), + threadId: thread.id, + message: { + messageId: yield* makeMessageId, + role: "user", + text: input.prompt, + attachments: [], + }, + modelSelection: thread.modelSelection, + titleSeed: thread.title, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + createdAt, + }, + thread.id, + ); + const updated = yield* getActiveShellSnapshot(scope, "send"); + const next = updated.threads.find((entry) => entry.id === thread.id) ?? thread; + return { + chat: yield* summarizeFromSnapshot(scope, "send", updated, next), + promptAccepted: true, + }; + }); + + const wait: ChatManagerShape["wait"] = Effect.fn("ChatManager.wait")(function* (scope, input) { + const threadIds = Array.from(new Set(input.threadIds)); + if (threadIds.includes(scope.threadId)) { + return yield* failure( + scope, + "wait", + "invalid_target", + "A chat cannot wait for itself because its current tool call would prevent completion.", + scope.threadId, + ); + } + const timeoutMs = (input.timeoutSeconds ?? 30) * 1_000; + const deadline = (yield* Clock.currentTimeMillis) + timeoutMs; + + while (true) { + const snapshot = yield* getActiveShellSnapshot(scope, "wait"); + const chats: ChatSummary[] = []; + for (const threadId of threadIds) { + const thread = snapshot.threads.find((entry) => entry.id === threadId); + if (!thread) { + return yield* failure( + scope, + "wait", + "chat_not_found", + `Active T3 chat '${threadId}' was not found.`, + threadId, + ); + } + chats.push(yield* summarizeFromSnapshot(scope, "wait", snapshot, thread)); + } + if (chats.every(isChatSettled)) return { timedOut: false, chats }; + if ((yield* Clock.currentTimeMillis) >= deadline) return { timedOut: true, chats }; + yield* Effect.sleep(WAIT_POLL_INTERVAL); + } + }); + + const manage: ChatManagerShape["manage"] = Effect.fn("ChatManager.manage")( + function* (scope, input) { + if ( + input.threadId === scope.threadId && + (input.action === "interrupt" || + input.action === "stop" || + input.action === "archive" || + input.action === "unarchive") + ) { + return yield* failure( + scope, + "manage", + "invalid_target", + `A chat cannot ${input.action} itself while handling a tool call.`, + input.threadId, + ); + } + if (input.action === "rename" && input.title === undefined) { + return yield* failure( + scope, + "manage", + "invalid_action", + "The rename action requires a title.", + input.threadId, + ); + } + + const snapshots = yield* getAllShellSnapshots(scope, "manage", true); + const existing = snapshots.threads.find((entry) => entry.id === input.threadId); + if (!existing) { + return yield* failure( + scope, + "manage", + "chat_not_found", + `T3 chat '${input.threadId}' was not found.`, + input.threadId, + ); + } + if (input.action !== "unarchive" && existing.archivedAt !== null) { + return yield* failure( + scope, + "manage", + "invalid_action", + `Archived chat '${input.threadId}' must be unarchived before '${input.action}'.`, + input.threadId, + ); + } + if (input.action === "unarchive" && existing.archivedAt === null) { + return yield* failure( + scope, + "manage", + "invalid_action", + `Chat '${input.threadId}' is not archived.`, + input.threadId, + ); + } + + const commandId = yield* makeCommandId(`manage-${input.action}`); + const command: OrchestrationCommand = + input.action === "interrupt" + ? { + type: "thread.turn.interrupt", + commandId, + threadId: input.threadId, + ...(existing.latestTurn?.turnId ? { turnId: existing.latestTurn.turnId } : {}), + createdAt: yield* nowIso, + } + : input.action === "stop" + ? { + type: "thread.session.stop", + commandId, + threadId: input.threadId, + createdAt: yield* nowIso, + } + : input.action === "archive" + ? { type: "thread.archive", commandId, threadId: input.threadId } + : input.action === "unarchive" + ? { type: "thread.unarchive", commandId, threadId: input.threadId } + : { + type: "thread.meta.update", + commandId, + threadId: input.threadId, + title: input.title!, + }; + yield* dispatch(scope, "manage", command, input.threadId); + + const updated = yield* getAllShellSnapshots(scope, "manage", true); + const next = updated.threads.find((entry) => entry.id === input.threadId); + if (!next) { + return yield* failure( + scope, + "manage", + "query_failed", + `T3 updated chat '${input.threadId}' but could not read it back.`, + input.threadId, + ); + } + return { + action: input.action, + chat: yield* summarizeFromSnapshot(scope, "manage", updated, next), + }; + }, + ); + + return ChatManager.of({ list, read, spawn, send, wait, manage }); +}); + +export const layer = Layer.effect(ChatManager, make); diff --git a/apps/server/src/mcp/toolkits/chat/handlers.ts b/apps/server/src/mcp/toolkits/chat/handlers.ts new file mode 100644 index 000000000..f4da1502e --- /dev/null +++ b/apps/server/src/mcp/toolkits/chat/handlers.ts @@ -0,0 +1,36 @@ +import * as Effect from "effect/Effect"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as ChatManager from "./ChatManager.ts"; +import { ChatToolError, type ChatToolOperation } from "./schemas.ts"; +import { ChatToolkit } from "./tools.ts"; + +const invoke = Effect.fn("ChatToolkit.invoke")(function* ( + operation: ChatToolOperation, + run: ( + manager: ChatManager.ChatManagerShape, + scope: McpInvocationContext.McpInvocationScope, + ) => Effect.Effect, +) { + const scope = yield* McpInvocationContext.McpInvocationContext; + if (!scope.capabilities.has("chat")) { + return yield* new ChatToolError({ + operation, + code: "capability_unavailable", + detail: "This provider session is not allowed to manage T3 chats.", + environmentId: scope.environmentId, + currentThreadId: scope.threadId, + }); + } + const manager = yield* ChatManager.ChatManager; + return yield* run(manager, scope); +}); + +export const ChatToolkitHandlersLive = ChatToolkit.toLayer({ + chat_list: (input) => invoke("list", (manager, scope) => manager.list(scope, input ?? {})), + chat_read: (input) => invoke("read", (manager, scope) => manager.read(scope, input)), + chat_spawn: (input) => invoke("spawn", (manager, scope) => manager.spawn(scope, input)), + chat_send: (input) => invoke("send", (manager, scope) => manager.send(scope, input)), + chat_wait: (input) => invoke("wait", (manager, scope) => manager.wait(scope, input)), + chat_manage: (input) => invoke("manage", (manager, scope) => manager.manage(scope, input)), +}); diff --git a/apps/server/src/mcp/toolkits/chat/schemas.ts b/apps/server/src/mcp/toolkits/chat/schemas.ts new file mode 100644 index 000000000..acce056d1 --- /dev/null +++ b/apps/server/src/mcp/toolkits/chat/schemas.ts @@ -0,0 +1,241 @@ +import { + EnvironmentId, + ModelSelection, + OrchestrationLatestTurn, + OrchestrationMessageRole, + OrchestrationSessionStatus, + ProjectId, + ProviderInteractionMode, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + RuntimeMode, + ThreadId, + TrimmedNonEmptyString, + TurnId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const BoundedPrompt = TrimmedNonEmptyString.check( + Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), +); +const BoundedTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(200)); +const OptionalThreadId = Schema.optional( + ThreadId.annotate({ description: "Target T3 chat id returned by chat_list or chat_spawn." }), +); + +export const ChatToolOperation = Schema.Literals([ + "list", + "read", + "spawn", + "send", + "wait", + "manage", +]); +export type ChatToolOperation = typeof ChatToolOperation.Type; + +export const ChatToolErrorCode = Schema.Literals([ + "capability_unavailable", + "current_chat_not_found", + "chat_not_found", + "project_not_found", + "invalid_target", + "invalid_action", + "dispatch_failed", + "query_failed", +]); +export type ChatToolErrorCode = typeof ChatToolErrorCode.Type; + +export class ChatToolError extends Schema.TaggedErrorClass()("ChatToolError", { + operation: ChatToolOperation, + code: ChatToolErrorCode, + detail: TrimmedNonEmptyString, + environmentId: EnvironmentId, + currentThreadId: ThreadId, + targetThreadId: OptionalThreadId, +}) { + override get message(): string { + return this.detail; + } +} + +export const ChatState = Schema.Literals([ + "idle", + "starting", + "running", + "waiting", + "completed", + "interrupted", + "error", + "stopped", +]); +export type ChatState = typeof ChatState.Type; + +export const ChatSummary = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + isCurrent: Schema.Boolean, + archived: Schema.Boolean, + state: ChatState, + latestTurnState: Schema.NullOr(OrchestrationLatestTurn.fields.state), + sessionStatus: Schema.NullOr(OrchestrationSessionStatus), + hasPendingApprovals: Schema.Boolean, + hasPendingUserInput: Schema.Boolean, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + createdAt: Schema.String, + updatedAt: Schema.String, +}); +export type ChatSummary = typeof ChatSummary.Type; + +export const ChatListInput = Schema.Struct({ + projectId: Schema.optional( + ProjectId.annotate({ description: "Only return chats from this project." }), + ), + includeArchived: Schema.optional( + Schema.Boolean.annotate({ description: "Include archived chats. Defaults to false." }), + ), +}); +export type ChatListInput = typeof ChatListInput.Type; + +export const ChatListResult = Schema.Struct({ + environmentId: EnvironmentId, + currentThreadId: ThreadId, + chats: Schema.Array(ChatSummary), +}); +export type ChatListResult = typeof ChatListResult.Type; + +export const ChatReadInput = Schema.Struct({ + threadId: ThreadId, + messageLimit: Schema.optional( + Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 })).annotate({ + description: "Number of recent messages to return. Defaults to 20; maximum 50.", + }), + ), + activityLimit: Schema.optional( + Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 50 })).annotate({ + description: "Number of recent activity summaries to return. Defaults to 10; maximum 50.", + }), + ), + maxCharsPerMessage: Schema.optional( + Schema.Int.check(Schema.isBetween({ minimum: 200, maximum: 20_000 })).annotate({ + description: "Maximum characters returned per message. Defaults to 4000.", + }), + ), +}); +export type ChatReadInput = typeof ChatReadInput.Type; + +export const ChatMessage = Schema.Struct({ + id: Schema.String, + role: OrchestrationMessageRole, + text: Schema.String, + truncated: Schema.Boolean, + streaming: Schema.Boolean, + turnId: Schema.NullOr(TurnId), + createdAt: Schema.String, + updatedAt: Schema.String, +}); + +export const ChatActivity = Schema.Struct({ + id: Schema.String, + tone: Schema.String, + kind: Schema.String, + summary: Schema.String, + turnId: Schema.NullOr(TurnId), + createdAt: Schema.String, +}); + +export const ChatReadResult = Schema.Struct({ + chat: ChatSummary, + messages: Schema.Array(ChatMessage), + activities: Schema.Array(ChatActivity), +}); +export type ChatReadResult = typeof ChatReadResult.Type; + +export const ChatSpawnInput = Schema.Struct({ + prompt: BoundedPrompt.annotate({ description: "Initial prompt for the new T3 chat." }), + title: Schema.optional( + BoundedTitle.annotate({ + description: "Visible chat title. Defaults to a short title derived from the prompt.", + }), + ), + projectId: Schema.optional( + ProjectId.annotate({ + description: + "Target project. Defaults to the current chat's project. Cross-project chats use the target project root.", + }), + ), + modelSelection: Schema.optional( + ModelSelection.annotate({ description: "Defaults to the current chat's model selection." }), + ), + runtimeMode: Schema.optional( + RuntimeMode.annotate({ description: "Defaults to the current chat's runtime mode." }), + ), + interactionMode: Schema.optional( + ProviderInteractionMode.annotate({ + description: "Defaults to the current chat's interaction mode.", + }), + ), +}); +export type ChatSpawnInput = typeof ChatSpawnInput.Type; + +export const ChatSpawnResult = Schema.Struct({ + chat: ChatSummary, + promptAccepted: Schema.Boolean, +}); +export type ChatSpawnResult = typeof ChatSpawnResult.Type; + +export const ChatSendInput = Schema.Struct({ + threadId: ThreadId, + prompt: BoundedPrompt.annotate({ + description: "Prompt or steering message to send to the target T3 chat.", + }), +}); +export type ChatSendInput = typeof ChatSendInput.Type; + +export const ChatSendResult = Schema.Struct({ + chat: ChatSummary, + promptAccepted: Schema.Boolean, +}); +export type ChatSendResult = typeof ChatSendResult.Type; + +export const ChatWaitInput = Schema.Struct({ + threadIds: Schema.Array(ThreadId) + .check(Schema.isMinLength(1), Schema.isMaxLength(10)) + .annotate({ description: "One to ten T3 chat ids to wait for." }), + timeoutSeconds: Schema.optional( + Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 60 })).annotate({ + description: "Maximum wait. Defaults to 30 seconds; maximum 60.", + }), + ), +}); +export type ChatWaitInput = typeof ChatWaitInput.Type; + +export const ChatWaitResult = Schema.Struct({ + timedOut: Schema.Boolean, + chats: Schema.Array(ChatSummary), +}); +export type ChatWaitResult = typeof ChatWaitResult.Type; + +export const ChatManageAction = Schema.Literals([ + "interrupt", + "stop", + "archive", + "unarchive", + "rename", +]); +export type ChatManageAction = typeof ChatManageAction.Type; + +export const ChatManageInput = Schema.Struct({ + threadId: ThreadId, + action: ChatManageAction, + title: Schema.optional(BoundedTitle.annotate({ description: "Required when action is rename." })), +}); +export type ChatManageInput = typeof ChatManageInput.Type; + +export const ChatManageResult = Schema.Struct({ + action: ChatManageAction, + chat: ChatSummary, +}); +export type ChatManageResult = typeof ChatManageResult.Type; diff --git a/apps/server/src/mcp/toolkits/chat/tools.ts b/apps/server/src/mcp/toolkits/chat/tools.ts new file mode 100644 index 000000000..d053d4a57 --- /dev/null +++ b/apps/server/src/mcp/toolkits/chat/tools.ts @@ -0,0 +1,106 @@ +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as ChatManager from "./ChatManager.ts"; +import { + ChatListInput, + ChatListResult, + ChatManageInput, + ChatManageResult, + ChatReadInput, + ChatReadResult, + ChatSendInput, + ChatSendResult, + ChatSpawnInput, + ChatSpawnResult, + ChatToolError, + ChatWaitInput, + ChatWaitResult, +} from "./schemas.ts"; + +const dependencies = [McpInvocationContext.McpInvocationContext, ChatManager.ChatManager]; + +const readonlyTool = (tool: T): T => + tool + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) as T; + +const mutatingTool = (tool: T): T => + tool.annotate(Tool.Readonly, false).annotate(Tool.Destructive, true) as T; + +export const ChatListTool = readonlyTool( + Tool.make("chat_list", { + description: + "List first-class T3 chats visible in this environment, including their project, model, run state, pending requests, and ids for other chat_* tools. The calling chat is marked isCurrent.", + parameters: ChatListInput, + success: ChatListResult, + failure: ChatToolError, + dependencies, + }).annotate(Tool.Title, "List T3 chats"), +); + +export const ChatReadTool = readonlyTool( + Tool.make("chat_read", { + description: + "Read a T3 chat's recent transcript, activity summaries, and current state. Use chat_list first when the target id is unknown.", + parameters: ChatReadInput, + success: ChatReadResult, + failure: ChatToolError, + dependencies, + }).annotate(Tool.Title, "Read T3 chat"), +); + +export const ChatSpawnTool = mutatingTool( + Tool.make("chat_spawn", { + description: + "Create exactly one new first-class T3 chat and submit its initial prompt. The chat appears in the normal sidebar and inherits this chat's project, model, runtime mode, interaction mode, branch, and worktree unless overridden. A cross-project spawn uses the target project's root without inheriting the caller's branch or worktree. This can incur model usage and edit a shared workspace, so use it deliberately for work that should be independently visible and persistent.", + parameters: ChatSpawnInput, + success: ChatSpawnResult, + failure: ChatToolError, + dependencies, + }).annotate(Tool.Title, "Spawn T3 chat"), +); + +export const ChatSendTool = mutatingTool( + Tool.make("chat_send", { + description: + "Send a new prompt or steering message to another active T3 chat. The target keeps its own model and runtime settings. A chat cannot send to itself.", + parameters: ChatSendInput, + success: ChatSendResult, + failure: ChatToolError, + dependencies, + }).annotate(Tool.Title, "Send prompt to T3 chat"), +); + +export const ChatWaitTool = Tool.make("chat_wait", { + description: + "Wait until all selected T3 chats finish, stop, error, or are interrupted, then return their latest states. Pending approvals or user input remain unsettled. A chat cannot wait for itself.", + parameters: ChatWaitInput, + success: ChatWaitResult, + failure: ChatToolError, + dependencies, +}) + .annotate(Tool.Title, "Wait for T3 chats") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); + +export const ChatManageTool = mutatingTool( + Tool.make("chat_manage", { + description: + "Manage another T3 chat: interrupt its active turn, stop its provider session, archive or unarchive it, or rename it. This intentionally does not expose permanent deletion.", + parameters: ChatManageInput, + success: ChatManageResult, + failure: ChatToolError, + dependencies, + }).annotate(Tool.Title, "Manage T3 chat"), +); + +export const ChatToolkit = Toolkit.make( + ChatListTool, + ChatReadTool, + ChatSpawnTool, + ChatSendTool, + ChatWaitTool, + ChatManageTool, +); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 35ffd1a47..fc46388a9 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -11,6 +11,17 @@ For browser work, first call \`preview_status\`. If no automation-capable previe Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable. `; +const T3_CODE_CHAT_TOOL_INSTRUCTIONS = ` + +## T3 Code chat orchestration + +The \`t3-code\` MCP server may expose \`chat_*\` tools for managing first-class T3 chats. These chats are persistent, visible to the user in the normal sidebar, and may continue working independently. They are different from ephemeral built-in subagents. + +Use \`chat_spawn\` only when the user explicitly asks to create or delegate work to T3 chats. Use \`chat_list\` before targeting an existing chat whose id is unknown, \`chat_read\` to inspect its work, \`chat_send\` for follow-up prompts, \`chat_wait\` to await completion, and \`chat_manage\` to interrupt, stop, rename, archive, or unarchive it. + +Do not create recursive chains of T3 chats unless the user explicitly requests them. Same-project spawned chats share the caller's branch and worktree by default, so coordinate concurrent edits carefully. +`; + export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -132,6 +143,7 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. ${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} +${T3_CODE_CHAT_TOOL_INSTRUCTIONS} `; export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default @@ -146,6 +158,7 @@ The \`request_user_input\` tool is unavailable in Default mode. If you call it w In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. ${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} +${T3_CODE_CHAT_TOOL_INSTRUCTIONS} `; export interface CodexRuntimeInfo { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 1527072da..92d18a8af 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -272,6 +272,7 @@ describe("T3 browser developer instructions", () => { ]) { NodeAssert.match(instructions, /t3-code/); NodeAssert.match(instructions, /preview_status/); + NodeAssert.match(instructions, /chat_spawn/); NodeAssert.match(instructions, /preview_open/); NodeAssert.match(instructions, /Do not switch to global browser skills/); } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 587ec65cb..07ec0dc90 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -4,18 +4,10 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import { cn, isMacPlatform } from "../lib/utils"; +import { isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import ThreadSidebar from "./Sidebar"; -import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; -import { - Sidebar, - SidebarProvider, - SidebarRail, - SidebarTrigger, - useSidebar, - useSidebarVisibility, -} from "./ui/sidebar"; +import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; @@ -26,8 +18,6 @@ const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); - const isSidebarVisible = useSidebarVisibility(); - const stageBackdropVariant = useSidebarStageBackdropVariant(); const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle"); useEffect(() => { @@ -52,15 +42,7 @@ function SidebarControl() { + } /> diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f175a21a1..83232febc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -202,6 +202,7 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { QueuedPromptList } from "./chat/QueuedPromptList"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -259,6 +260,11 @@ import { resolveServerConfigVersionMismatch, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; +import { + findNextQueuedPrompt, + type QueuedPrompt, + useQueuedPromptStore, +} from "../queuedPromptStore"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; @@ -266,6 +272,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const EMPTY_QUEUED_PROMPTS: ReadonlyArray = []; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); const composerAnchorRef = useRef(null); @@ -1421,6 +1428,16 @@ function ChatViewContent(props: ChatViewProps) { [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const queuedPrompts = useQueuedPromptStore((state) => + activeThreadKey + ? (state.promptsByThreadKey[activeThreadKey] ?? EMPTY_QUEUED_PROMPTS) + : EMPTY_QUEUED_PROMPTS, + ); + const enqueueQueuedPrompt = useQueuedPromptStore((state) => state.enqueue); + const removeQueuedPrompt = useQueuedPromptStore((state) => state.remove); + const markQueuedPromptSending = useQueuedPromptStore((state) => state.markSending); + const markQueuedPromptFailed = useQueuedPromptStore((state) => state.markFailed); + const retryQueuedPrompt = useQueuedPromptStore((state) => state.retry); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; readonly messageId: MessageId | null; @@ -4034,7 +4051,10 @@ function ChatViewContent(props: ChatViewProps) { ], ); - const onSend = async (e?: { preventDefault: () => void }) => { + const onSend = async ( + e?: { preventDefault: () => void }, + options?: { queuedPrompt?: QueuedPrompt; forceSteer?: boolean }, + ): Promise => { e?.preventDefault(); if ( !activeThread || @@ -4043,13 +4063,14 @@ function ChatViewContent(props: ChatViewProps) { activeEnvironmentUnavailable || sendInFlightRef.current ) - return; + return false; if (activePendingProgress) { onAdvanceActivePendingUserInput(); - return; + return true; } - const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) return; + const queuedPrompt = options?.queuedPrompt; + const sendCtx = queuedPrompt ?? composerRef.current?.getSendContext(); + if (!sendCtx) return false; const { images: composerImages, terminalContexts: composerTerminalContexts, @@ -4062,7 +4083,9 @@ function ChatViewContent(props: ChatViewProps) { selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; - const promptForSend = promptRef.current; + const promptForSend = queuedPrompt?.prompt ?? promptRef.current; + const runtimeModeForSend = queuedPrompt?.runtimeMode ?? runtimeMode; + const interactionModeForSend = queuedPrompt?.interactionMode ?? interactionMode; const { trimmedPrompt: trimmed, sendableTerminalContexts: sendableComposerTerminalContexts, @@ -4089,7 +4112,7 @@ function ChatViewContent(props: ChatViewProps) { text: followUp.text, interactionMode: followUp.interactionMode, }); - return; + return true; } const standaloneSlashCommand = composerImages.length === 0 && @@ -4104,7 +4127,7 @@ function ChatViewContent(props: ChatViewProps) { promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); - return; + return true; } if (!hasSendableContent) { if (expiredTerminalContextCount > 0) { @@ -4120,7 +4143,7 @@ function ChatViewContent(props: ChatViewProps) { }), ); } - return; + return false; } if (!activeProject) { toastManager.add( @@ -4130,7 +4153,52 @@ function ChatViewContent(props: ChatViewProps) { description: "This draft no longer points to an available project.", }), ); - return; + return false; + } + if ( + queuedPrompt === undefined && + options?.forceSteer !== true && + phase === "running" && + isServerThread && + activeThreadKey + ) { + enqueueQueuedPrompt({ + id: `queued-prompt:${randomHex(8)}`, + threadKey: activeThreadKey, + createdAt: new Date().toISOString(), + status: "queued", + prompt: promptForSend, + images: [...composerImages], + terminalContexts: [...sendableComposerTerminalContexts], + elementContexts: [...composerElementContexts], + previewAnnotations: [...composerPreviewAnnotations], + reviewComments: [...composerReviewComments], + selectedProvider: ctxSelectedProvider, + selectedModel: ctxSelectedModel, + selectedProviderModels: [...ctxSelectedProviderModels], + selectedPromptEffort: ctxSelectedPromptEffort, + selectedModelSelection: ctxSelectedModelSelection, + runtimeMode: runtimeModeForSend, + interactionMode: interactionModeForSend, + }); + if (expiredTerminalContextCount > 0) { + const toastCopy = buildExpiredTerminalContextToastCopy( + expiredTerminalContextCount, + "omitted", + ); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }), + ); + } + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + scheduleComposerFocus(); + return true; } const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; @@ -4145,7 +4213,7 @@ function ChatViewContent(props: ChatViewProps) { isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; if (shouldCreateWorktree && !activeThreadBranch) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); - return; + return false; } sendInFlightRef.current = true; @@ -4250,9 +4318,11 @@ function ChatViewContent(props: ChatViewProps) { }), ); } - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); + if (!queuedPrompt) { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + } let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { @@ -4300,8 +4370,8 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, createdAt: messageCreatedAt, ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), - runtimeMode, - interactionMode, + runtimeMode: runtimeModeForSend, + interactionMode: interactionModeForSend, }); if (settingsResult._tag === "Failure") { failure = settingsResult; @@ -4324,8 +4394,8 @@ function ChatViewContent(props: ChatViewProps) { projectId: activeProject.id, title, modelSelection: threadCreateModelSelection, - runtimeMode, - interactionMode, + runtimeMode: runtimeModeForSend, + interactionMode: interactionModeForSend, branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, @@ -4358,8 +4428,8 @@ function ChatViewContent(props: ChatViewProps) { }, modelSelection: ctxSelectedModelSelection, titleSeed: title, - runtimeMode, - interactionMode, + runtimeMode: runtimeModeForSend, + interactionMode: interactionModeForSend, ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }, @@ -4372,7 +4442,18 @@ function ChatViewContent(props: ChatViewProps) { } if (failure !== null) { + setOptimisticUserMessages((existing) => { + const removed = existing.filter((message) => message.id === messageIdForSend); + if (!queuedPrompt) { + for (const message of removed) { + revokeUserMessagePreviewUrls(message); + } + } + const next = existing.filter((message) => message.id !== messageIdForSend); + return next.length === existing.length ? existing : next; + }); if ( + !queuedPrompt && promptRef.current.length === 0 && composerImagesRef.current.length === 0 && composerTerminalContextsRef.current.length === 0 && @@ -4382,14 +4463,6 @@ function ChatViewContent(props: ChatViewProps) { (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments .length ?? 0) === 0 ) { - setOptimisticUserMessages((existing) => { - const removed = existing.filter((message) => message.id === messageIdForSend); - for (const message of removed) { - revokeUserMessagePreviewUrls(message); - } - const next = existing.filter((message) => message.id !== messageIdForSend); - return next.length === existing.length ? existing : next; - }); promptRef.current = promptForSend; const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); composerImagesRef.current = retryComposerImages; @@ -4422,8 +4495,84 @@ function ChatViewContent(props: ChatViewProps) { ); resetLocalDispatch(); } + return turnStartSucceeded; }; + const onSendRef = useRef(onSend); + onSendRef.current = onSend; + const queuedPromptDispatchIdRef = useRef(null); + const dispatchQueuedPrompt = useCallback( + async (prompt: QueuedPrompt, forceSteer: boolean) => { + if (queuedPromptDispatchIdRef.current !== null) return; + queuedPromptDispatchIdRef.current = prompt.id; + markQueuedPromptSending(prompt.threadKey, prompt.id); + try { + const sent = await onSendRef.current(undefined, { + queuedPrompt: prompt, + forceSteer, + }); + if (sent) { + removeQueuedPrompt(prompt.threadKey, prompt.id); + } else { + markQueuedPromptFailed(prompt.threadKey, prompt.id); + } + } catch { + markQueuedPromptFailed(prompt.threadKey, prompt.id); + } finally { + queuedPromptDispatchIdRef.current = null; + } + }, + [markQueuedPromptFailed, markQueuedPromptSending, removeQueuedPrompt], + ); + + useEffect(() => { + if ( + !activeThreadKey || + phase === "running" || + isSendBusy || + isConnecting || + activeEnvironmentUnavailable || + queuedPromptDispatchIdRef.current !== null + ) { + return; + } + const nextPrompt = findNextQueuedPrompt(queuedPrompts); + if (!nextPrompt) return; + void dispatchQueuedPrompt(nextPrompt, false); + }, [ + activeEnvironmentUnavailable, + activeThreadKey, + dispatchQueuedPrompt, + isConnecting, + isSendBusy, + phase, + queuedPrompts, + ]); + + const handleSteerQueuedPrompt = useCallback( + (prompt: QueuedPrompt) => { + void dispatchQueuedPrompt(prompt, true); + }, + [dispatchQueuedPrompt], + ); + + const handleRemoveQueuedPrompt = useCallback( + (prompt: QueuedPrompt) => { + removeQueuedPrompt(prompt.threadKey, prompt.id); + for (const image of prompt.images) { + revokeBlobPreviewUrl(image.previewUrl); + } + }, + [removeQueuedPrompt], + ); + + const handleRetryQueuedPrompt = useCallback( + (prompt: QueuedPrompt) => { + retryQueuedPrompt(prompt.threadKey, prompt.id); + }, + [retryQueuedPrompt], + ); + const onInterrupt = async () => { if (!activeThread) return; const result = await interruptThreadTurn({ @@ -5363,6 +5512,15 @@ function ChatViewContent(props: ChatViewProps) { ) : ( )} + {!isDraftHeroState ? ( + prompt.status === "sending")} + onSteer={handleSteerQueuedPrompt} + onRetry={handleRetryQueuedPrompt} + onRemove={handleRemoveQueuedPrompt} + /> + ) : null}
({ + displayAccentColor: settings.displayAccentColor, + displayBackgroundColor: settings.displayBackgroundColor, + displayFontScale: settings.displayFontScale, + displayTextColor: settings.displayTextColor, + })); + + useEffect(() => { + const rootStyle = document.documentElement.style; + const properties = resolveDisplayStyleProperties(preferences); + + for (const propertyName of DISPLAY_STYLE_PROPERTY_NAMES) { + rootStyle.removeProperty(propertyName); + } + for (const [propertyName, value] of Object.entries(properties)) { + rootStyle.setProperty(propertyName, value); + } + + return () => { + for (const propertyName of DISPLAY_STYLE_PROPERTY_NAMES) { + rootStyle.removeProperty(propertyName); + } + }; + }, [preferences]); + + return null; +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5f6c234e5..7c3965d95 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -14,7 +14,6 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, - resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, @@ -38,44 +37,6 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); -describe("resolveSidebarStageBadgeLabel", () => { - it("returns Nightly for nightly primary server versions", () => { - expect( - resolveSidebarStageBadgeLabel({ - primaryServerVersion: "0.0.28-nightly.20260616.12", - fallbackStageLabel: "Alpha", - }), - ).toBe("Nightly"); - }); - - it("returns the fallback label for stable primary server versions", () => { - expect( - resolveSidebarStageBadgeLabel({ - primaryServerVersion: "0.0.27", - fallbackStageLabel: "Alpha", - }), - ).toBe("Alpha"); - }); - - it("returns the fallback label when the primary server version is missing", () => { - expect( - resolveSidebarStageBadgeLabel({ - primaryServerVersion: null, - fallbackStageLabel: "Dev", - }), - ).toBe("Dev"); - }); - - it("returns the fallback label for malformed nightly prerelease versions", () => { - expect( - resolveSidebarStageBadgeLabel({ - primaryServerVersion: "0.0.28-nightly.20260616", - fallbackStageLabel: "Alpha", - }), - ).toBe("Alpha"); - }); -}); - function makeLatestTurn(overrides?: { completedAt?: string | null; startedAt?: string | null; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 91a61cc15..b6283eb6d 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -9,7 +9,6 @@ import { import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; -import { resolveServerBackedAppStageLabel } from "../branding.logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; @@ -75,13 +74,6 @@ export interface ThreadJumpHintVisibilityController { dispose: () => void; } -export function resolveSidebarStageBadgeLabel(input: { - primaryServerVersion: string | null | undefined; - fallbackStageLabel: string; -}): string { - return resolveServerBackedAppStageLabel(input); -} - export function createThreadJumpHintVisibilityController(input: { delayMs: number; onVisibilityChange: (visible: boolean) => void; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 715a94e96..bad3f13df 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -63,7 +63,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -74,7 +74,6 @@ import { import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; -import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; import { cn, isMacPlatform } from "../lib/utils"; @@ -126,7 +125,6 @@ import { import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, @@ -192,7 +190,6 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, - resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -207,7 +204,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -2745,9 +2742,6 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { - const stageLabel = useSidebarStageLabel(); - const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel); - return ( - {backdropVariant ? : null} - - + ); }); -function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { - return ( - - - - Code - - - ); -} - -function useSidebarStageLabel() { - const primaryServerVersion = - useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - - return resolveSidebarStageBadgeLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }); -} - -function T3Wordmark() { - return ( - - - - ); -} - const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); const { isMobile, setOpenMobile } = useSidebar(); diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index 6f0953a97..c6cbcd504 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -1,9 +1,3 @@ -import { useAtomValue } from "@effect/atom-react"; - -import { APP_STAGE_LABEL } from "../branding"; -import { resolveServerBackedAppStageLabel } from "../branding.logic"; -import { primaryServerConfigAtom } from "../state/server"; - export type SidebarStageBackdropVariant = "nightly" | "dev"; // A wide viewBox keeps the 96-unit art height at a fixed scale while sidebar resizing reveals @@ -19,30 +13,6 @@ export function resolveSidebarStageBackdropVariant( return null; } -export function useSidebarStageBackdropVariant(): SidebarStageBackdropVariant | null { - const primaryServerVersion = - useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - - return resolveSidebarStageBackdropVariant( - resolveServerBackedAppStageLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }), - ); -} - -/** Stage-channel header art; palettes mirror the per-channel app icons in `assets/`. */ -export function SidebarStageBackdrop({ variant }: { variant: SidebarStageBackdropVariant }) { - return ( -
- -
- ); -} - export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVariant }) { return variant === "nightly" ? : ; } diff --git a/apps/web/src/components/chat/QueuedPromptList.tsx b/apps/web/src/components/chat/QueuedPromptList.tsx new file mode 100644 index 000000000..ab3c9b5bc --- /dev/null +++ b/apps/web/src/components/chat/QueuedPromptList.tsx @@ -0,0 +1,103 @@ +import { CornerUpRightIcon, RotateCcwIcon, XIcon } from "lucide-react"; + +import type { QueuedPrompt } from "~/queuedPromptStore"; +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { Spinner } from "../ui/spinner"; + +export function QueuedPromptList(props: { + readonly prompts: ReadonlyArray; + readonly dispatchBusy: boolean; + readonly onSteer: (prompt: QueuedPrompt) => void; + readonly onRetry: (prompt: QueuedPrompt) => void; + readonly onRemove: (prompt: QueuedPrompt) => void; +}) { + if (props.prompts.length === 0) return null; + + return ( +
+ {props.prompts.map((prompt, index) => { + const isSending = prompt.status === "sending"; + const isFailed = prompt.status === "failed"; + const attachmentCount = + prompt.images.length + + prompt.terminalContexts.length + + prompt.elementContexts.length + + prompt.previewAnnotations.length + + prompt.reviewComments.length; + return ( +
+
+
+ {isSending ? "Sending next" : isFailed ? "Send failed" : "Queued"} + {!isSending && !isFailed ? #{index + 1} : null} + {attachmentCount > 0 ? ( + + · {attachmentCount} attachment{attachmentCount === 1 ? "" : "s"} + + ) : null} +
+

+ {prompt.prompt.trim() || "Prompt with attachments"} +

+
+ + {isFailed ? ( + + ) : ( + + )} + + +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/settings/DisplaySettings.tsx b/apps/web/src/components/settings/DisplaySettings.tsx new file mode 100644 index 000000000..fe3b8c79e --- /dev/null +++ b/apps/web/src/components/settings/DisplaySettings.tsx @@ -0,0 +1,290 @@ +import { CheckIcon, ChevronDownIcon, PaletteIcon, RotateCcwIcon } from "lucide-react"; +import { + DEFAULT_CLIENT_SETTINGS, + DisplayHexColor, + MAX_DISPLAY_FONT_SCALE, + MIN_DISPLAY_FONT_SCALE, +} from "@t3tools/contracts/settings"; + +import { useTheme } from "../../hooks/useTheme"; +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { DISPLAY_COLOR_PRESETS, isDisplayColorPresetActive } from "../../displayPreferences.logic"; +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; +import { + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "./settingsLayout"; + +const TEXT_COLOR_PRESETS = ["#f5f5f5", "#dbeafe", "#fde68a", "#1f2937"] as const; +const BACKGROUND_COLOR_PRESETS = ["#0f1115", "#111827", "#1e1b4b", "#f8fafc"] as const; +const ACCENT_COLOR_PRESETS = ["#3b82f6", "#8b5cf6", "#10b981", "#f97316", "#ec4899"] as const; + +function clampFontScale(value: number): number { + return Math.min(MAX_DISPLAY_FONT_SCALE, Math.max(MIN_DISPLAY_FONT_SCALE, Math.round(value))); +} + +function DisplayColorControl(props: { + readonly label: string; + readonly value: string | null; + readonly fallback: string; + readonly presets: ReadonlyArray; + readonly onChange: (value: string) => void; +}) { + const pickerValue = props.value ?? props.fallback; + + return ( +
+ props.onChange(event.currentTarget.value)} + aria-label={props.label} + className="h-8 w-10 cursor-pointer rounded-lg border border-input bg-background p-0.5" + /> +
+ {props.presets.map((color) => { + const isSelected = props.value?.toLowerCase() === color; + return ( +
+ + {props.value ?? "Theme"} + +
+ ); +} + +export function DisplaySettingsPanel() { + const { resolvedTheme } = useTheme(); + const settings = useClientSettings(); + const updateSettings = useUpdateClientSettings(); + const themeDefaults = + resolvedTheme === "dark" + ? { text: "#f5f5f5", background: "#101010", accent: "#6366f1" } + : { text: "#262626", background: "#ffffff", accent: "#4f46e5" }; + const hasCustomDisplay = + settings.displayFontScale !== DEFAULT_CLIENT_SETTINGS.displayFontScale || + settings.displayTextColor !== DEFAULT_CLIENT_SETTINGS.displayTextColor || + settings.displayBackgroundColor !== DEFAULT_CLIENT_SETTINGS.displayBackgroundColor || + settings.displayAccentColor !== DEFAULT_CLIENT_SETTINGS.displayAccentColor; + const resetDisplay = () => { + updateSettings({ + displayFontScale: DEFAULT_CLIENT_SETTINGS.displayFontScale, + displayTextColor: DEFAULT_CLIENT_SETTINGS.displayTextColor, + displayBackgroundColor: DEFAULT_CLIENT_SETTINGS.displayBackgroundColor, + displayAccentColor: DEFAULT_CLIENT_SETTINGS.displayAccentColor, + }); + }; + const selectedColorPreset = DISPLAY_COLOR_PRESETS.find((preset) => + isDisplayColorPresetActive(preset, settings), + ); + + return ( + + + + Reset display + + ) : null + } + > + + updateSettings({ displayFontScale: DEFAULT_CLIENT_SETTINGS.displayFontScale }) + } + /> + ) : null + } + control={ +
+ + updateSettings({ displayFontScale: clampFontScale(value ?? 100) }) + } + > + + + + + + + % +
+ } + /> +
+ + + } + > + + Presets + + + + {DISPLAY_COLOR_PRESETS.map((preset) => { + const isSelected = preset.id === selectedColorPreset?.id; + return ( + updateSettings(preset.colors)} + > + + {[ + preset.colors.displayBackgroundColor, + preset.colors.displayTextColor, + preset.colors.displayAccentColor, + ].map((color) => ( + + ))} + + + {preset.label} + + {preset.description} + + + {isSelected ? : null} + + ); + })} + + + } + > + updateSettings({ displayTextColor: null })} + /> + ) : null + } + control={ + + updateSettings({ displayTextColor: DisplayHexColor.make(value) }) + } + /> + } + /> + updateSettings({ displayBackgroundColor: null })} + /> + ) : null + } + control={ + + updateSettings({ displayBackgroundColor: DisplayHexColor.make(value) }) + } + /> + } + /> + updateSettings({ displayAccentColor: null })} + /> + ) : null + } + control={ + + updateSettings({ displayAccentColor: DisplayHexColor.make(value) }) + } + /> + } + /> + + + +
+
+

The quick brown fox

+

+ Display changes apply immediately and persist on this device. +

+ + const display = "comfortable"; + +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f33..141a9a5e3 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -6,6 +6,7 @@ import { GitBranchIcon, KeyboardIcon, Link2Icon, + MonitorCogIcon, Settings2Icon, } from "lucide-react"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; @@ -24,6 +25,7 @@ import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3Conne export type SettingsSectionPath = | "/settings/general" + | "/settings/display" | "/settings/keybindings" | "/settings/providers" | "/settings/source-control" @@ -36,6 +38,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ icon: ComponentType<{ className?: string }>; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, + { label: "Display", to: "/settings/display", icon: MonitorCogIcon }, { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, diff --git a/apps/web/src/displayPreferences.logic.test.ts b/apps/web/src/displayPreferences.logic.test.ts new file mode 100644 index 000000000..78b266e10 --- /dev/null +++ b/apps/web/src/displayPreferences.logic.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + DISPLAY_COLOR_PRESETS, + isDisplayColorPresetActive, + resolveDisplayStyleProperties, +} from "./displayPreferences.logic"; + +describe("display color presets", () => { + it("keeps the official Codex and Claude palettes available", () => { + expect(DISPLAY_COLOR_PRESETS).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "codex-dark", + colors: { + displayAccentColor: "#339CFF", + displayBackgroundColor: "#181818", + displayTextColor: "#FFFFFF", + }, + }), + expect.objectContaining({ + id: "claude-dark", + colors: { + displayAccentColor: "#D97757", + displayBackgroundColor: "#30302E", + displayTextColor: "#FAF9F5", + }, + }), + ]), + ); + }); + + it("matches a preset regardless of saved hex casing", () => { + const codexDark = DISPLAY_COLOR_PRESETS.find((preset) => preset.id === "codex-dark"); + expect(codexDark).toBeDefined(); + if (!codexDark) return; + + expect( + isDisplayColorPresetActive(codexDark, { + displayAccentColor: "#339cff", + displayBackgroundColor: "#181818", + displayTextColor: "#ffffff", + }), + ).toBe(true); + }); +}); + +describe("resolveDisplayStyleProperties", () => { + it("keeps theme colors untouched when only the default scale is selected", () => { + expect( + resolveDisplayStyleProperties({ + displayAccentColor: null, + displayBackgroundColor: null, + displayFontScale: 100, + displayTextColor: null, + }), + ).toEqual({ "--display-font-size": "100%" }); + }); + + it("maps custom display preferences to global design tokens", () => { + const properties = resolveDisplayStyleProperties({ + displayAccentColor: "#facc15", + displayBackgroundColor: "#10131a", + displayFontScale: 115, + displayTextColor: "#f5f5f5", + }); + + expect(properties).toMatchObject({ + "--display-font-size": "115%", + "--background": "#10131a", + "--app-chrome-background": "#10131a", + "--foreground": "#f5f5f5", + "--primary": "#facc15", + "--primary-foreground": "#171717", + "--ring": "#facc15", + }); + expect(properties["--card"]).toContain("#10131a"); + }); + + it("uses light button text for dark accent colors", () => { + const properties = resolveDisplayStyleProperties({ + displayAccentColor: "#312e81", + displayBackgroundColor: null, + displayFontScale: 100, + displayTextColor: null, + }); + + expect(properties["--primary-foreground"]).toBe("#ffffff"); + }); +}); diff --git a/apps/web/src/displayPreferences.logic.ts b/apps/web/src/displayPreferences.logic.ts new file mode 100644 index 000000000..93f0f246d --- /dev/null +++ b/apps/web/src/displayPreferences.logic.ts @@ -0,0 +1,175 @@ +import type { ClientSettings } from "@t3tools/contracts/settings"; + +export type DisplayPreferences = Pick< + ClientSettings, + "displayAccentColor" | "displayBackgroundColor" | "displayFontScale" | "displayTextColor" +>; + +export type DisplayColorPreferences = Pick< + DisplayPreferences, + "displayAccentColor" | "displayBackgroundColor" | "displayTextColor" +>; + +export interface DisplayPresetColors { + readonly displayAccentColor: NonNullable; + readonly displayBackgroundColor: NonNullable; + readonly displayTextColor: NonNullable; +} + +export interface DisplayColorPreset { + readonly id: + | "claude-dark" + | "claude-light" + | "codex-dark" + | "codex-light" + | "midnight" + | "warm-paper"; + readonly label: string; + readonly description: string; + readonly colors: DisplayPresetColors; +} + +export const DISPLAY_COLOR_PRESETS: ReadonlyArray = [ + // Exact values shown for the built-in Codex themes in the official Appearance docs. + // https://learn.chatgpt.com/images/codex/app/theme-selection-dark.webp + { + id: "codex-dark", + label: "Codex Dark", + description: "Official Codex palette", + colors: { + displayAccentColor: "#339CFF", + displayBackgroundColor: "#181818", + displayTextColor: "#FFFFFF", + }, + }, + { + id: "codex-light", + label: "Codex Light", + description: "Official Codex palette", + colors: { + displayAccentColor: "#0285FF", + displayBackgroundColor: "#FFFFFF", + displayTextColor: "#0D0D0D", + }, + }, + // Claude host surface/text tokens plus Anthropic's published Claude orange. + // https://claude.com/docs/connectors/building/mcp-apps/design-guidelines + // https://claude.com/resources/use-cases/package-your-brand-guidelines-in-a-skill + { + id: "claude-dark", + label: "Claude Dark", + description: "Official Claude colors", + colors: { + displayAccentColor: "#D97757", + displayBackgroundColor: "#30302E", + displayTextColor: "#FAF9F5", + }, + }, + { + id: "claude-light", + label: "Claude Light", + description: "Official Claude colors", + colors: { + displayAccentColor: "#D97757", + displayBackgroundColor: "#FFFFFF", + displayTextColor: "#141413", + }, + }, + { + id: "midnight", + label: "Midnight", + description: "Deep blue and violet", + colors: { + displayAccentColor: "#8B5CF6", + displayBackgroundColor: "#0F172A", + displayTextColor: "#E2E8F0", + }, + }, + { + id: "warm-paper", + label: "Warm Paper", + description: "Soft, low-glare light", + colors: { + displayAccentColor: "#B45309", + displayBackgroundColor: "#FAF7F0", + displayTextColor: "#292524", + }, + }, +]; + +export function isDisplayColorPresetActive( + preset: DisplayColorPreset, + preferences: DisplayColorPreferences, +): boolean { + return ( + preset.colors.displayAccentColor.toLowerCase() === + preferences.displayAccentColor?.toLowerCase() && + preset.colors.displayBackgroundColor.toLowerCase() === + preferences.displayBackgroundColor?.toLowerCase() && + preset.colors.displayTextColor.toLowerCase() === preferences.displayTextColor?.toLowerCase() + ); +} + +export const DISPLAY_STYLE_PROPERTY_NAMES = [ + "--display-font-size", + "--background", + "--app-chrome-background", + "--card", + "--popover", + "--foreground", + "--card-foreground", + "--popover-foreground", + "--secondary-foreground", + "--accent-foreground", + "--muted-foreground", + "--primary", + "--primary-foreground", + "--ring", + "--info", +] as const; + +function readableForeground(background: string): "#171717" | "#ffffff" { + const numeric = Number.parseInt(background.slice(1), 16); + const red = (numeric >> 16) & 255; + const green = (numeric >> 8) & 255; + const blue = numeric & 255; + const perceivedBrightness = (red * 299 + green * 587 + blue * 114) / 255_000; + return perceivedBrightness > 0.58 ? "#171717" : "#ffffff"; +} + +export function resolveDisplayStyleProperties( + preferences: DisplayPreferences, +): Readonly> { + const properties: Record = { + "--display-font-size": `${preferences.displayFontScale}%`, + }; + + if (preferences.displayBackgroundColor) { + const background = preferences.displayBackgroundColor; + const surfaceMixColor = preferences.displayTextColor ?? "var(--foreground)"; + properties["--background"] = background; + properties["--app-chrome-background"] = background; + properties["--card"] = `color-mix(in srgb, ${background} 96%, ${surfaceMixColor})`; + properties["--popover"] = `color-mix(in srgb, ${background} 94%, ${surfaceMixColor})`; + } + + if (preferences.displayTextColor) { + const text = preferences.displayTextColor; + properties["--foreground"] = text; + properties["--card-foreground"] = text; + properties["--popover-foreground"] = text; + properties["--secondary-foreground"] = text; + properties["--accent-foreground"] = text; + properties["--muted-foreground"] = `color-mix(in srgb, ${text} 68%, transparent)`; + } + + if (preferences.displayAccentColor) { + const accent = preferences.displayAccentColor; + properties["--primary"] = accent; + properties["--primary-foreground"] = readableForeground(accent); + properties["--ring"] = accent; + properties["--info"] = accent; + } + + return properties; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 77a5f027c..829aeeb75 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -76,6 +76,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } :root { + --display-font-size: 100%; --app-scrollbar-width: 6px; --desktop-window-right-resize-inset: 0px; --workspace-topbar-height: 52px; @@ -200,6 +201,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil @apply outline-none ring-0; } html { + font-size: var(--display-font-size); background-color: var(--app-chrome-background); } body { @@ -209,52 +211,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } @layer components { - .sidebar-brand { - display: none; - } - - .sidebar-brand-stage { - display: none; - } - - @media (min-width: 48rem) { - @container sidebar-header (min-width: 13.5rem) { - .sidebar-brand { - display: flex; - } - } - - @container sidebar-header (min-width: 15.75rem) { - .sidebar-brand-stage { - display: inline-flex; - } - } - } - - /* Stage-channel sidebar art; ::after ramps to the sidebar bg color and the - mask lets the surface grain show through at the boundary. */ - .sidebar-stage-backdrop { - mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); - -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); - } - - .sidebar-stage-backdrop::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient( - to bottom, - transparent 0%, - transparent 28%, - color-mix(in srgb, var(--app-chrome-background) 10%, transparent) 40%, - color-mix(in srgb, var(--app-chrome-background) 30%, transparent) 52%, - color-mix(in srgb, var(--app-chrome-background) 58%, transparent) 64%, - color-mix(in srgb, var(--app-chrome-background) 82%, transparent) 75%, - color-mix(in srgb, var(--app-chrome-background) 96%, transparent) 85%, - var(--app-chrome-background) 93% - ); - } - .stage-blueprint { --stage-bp-top: #67c2ff; --stage-bp-mid: #347ff8; diff --git a/apps/web/src/queuedPromptStore.test.ts b/apps/web/src/queuedPromptStore.test.ts new file mode 100644 index 000000000..f5f472e67 --- /dev/null +++ b/apps/web/src/queuedPromptStore.test.ts @@ -0,0 +1,62 @@ +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { findNextQueuedPrompt, type QueuedPrompt, useQueuedPromptStore } from "./queuedPromptStore"; + +const queuedPrompt = (id: string): QueuedPrompt => ({ + id, + threadKey: "environment:thread", + createdAt: "2026-07-20T12:00:00.000Z", + status: "queued", + prompt: `Prompt ${id}`, + images: [], + terminalContexts: [], + elementContexts: [], + previewAnnotations: [], + reviewComments: [], + selectedProvider: ProviderDriverKind.make("codex"), + selectedModel: "gpt-5.4", + selectedProviderModels: [], + selectedPromptEffort: null, + selectedModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", +}); + +describe("queuedPromptStore", () => { + beforeEach(() => { + useQueuedPromptStore.setState({ promptsByThreadKey: {} }); + }); + + it("keeps prompts in FIFO order and removes an empty thread queue", () => { + const store = useQueuedPromptStore.getState(); + store.enqueue(queuedPrompt("first")); + store.enqueue(queuedPrompt("second")); + + const prompts = useQueuedPromptStore.getState().promptsByThreadKey["environment:thread"]!; + expect(prompts.map((prompt) => prompt.id)).toEqual(["first", "second"]); + expect(findNextQueuedPrompt(prompts)?.id).toBe("first"); + + useQueuedPromptStore.getState().remove("environment:thread", "first"); + useQueuedPromptStore.getState().remove("environment:thread", "second"); + expect(useQueuedPromptStore.getState().promptsByThreadKey).toEqual({}); + }); + + it("does not automatically select sending or failed prompts", () => { + const store = useQueuedPromptStore.getState(); + store.enqueue(queuedPrompt("first")); + store.enqueue(queuedPrompt("second")); + store.markSending("environment:thread", "first"); + store.markFailed("environment:thread", "second"); + + const prompts = useQueuedPromptStore.getState().promptsByThreadKey["environment:thread"]!; + expect(findNextQueuedPrompt(prompts)).toBeNull(); + + useQueuedPromptStore.getState().retry("environment:thread", "second"); + expect( + findNextQueuedPrompt( + useQueuedPromptStore.getState().promptsByThreadKey["environment:thread"]!, + )?.id, + ).toBe("second"); + }); +}); diff --git a/apps/web/src/queuedPromptStore.ts b/apps/web/src/queuedPromptStore.ts new file mode 100644 index 000000000..26d369ca5 --- /dev/null +++ b/apps/web/src/queuedPromptStore.ts @@ -0,0 +1,112 @@ +import type { + ModelSelection, + PreviewAnnotationPayload, + ProviderDriverKind, + ProviderInteractionMode, + RuntimeMode, + ServerProvider, +} from "@t3tools/contracts"; +import { create } from "zustand"; + +import type { ComposerImageAttachment } from "./composerDraftStore"; +import type { ElementContextDraft } from "./lib/elementContext"; +import type { TerminalContextDraft } from "./lib/terminalContext"; +import type { ReviewCommentContext } from "./reviewCommentContext"; + +export type QueuedPromptStatus = "queued" | "sending" | "failed"; + +export interface QueuedPrompt { + readonly id: string; + readonly threadKey: string; + readonly createdAt: string; + readonly status: QueuedPromptStatus; + readonly prompt: string; + readonly images: ReadonlyArray; + readonly terminalContexts: ReadonlyArray; + readonly elementContexts: ReadonlyArray; + readonly previewAnnotations: ReadonlyArray; + readonly reviewComments: ReadonlyArray; + readonly selectedProvider: ProviderDriverKind; + readonly selectedModel: string; + readonly selectedProviderModels: ReadonlyArray; + readonly selectedPromptEffort: string | null; + readonly selectedModelSelection: ModelSelection; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +interface QueuedPromptState { + readonly promptsByThreadKey: Readonly>>; + readonly enqueue: (prompt: QueuedPrompt) => void; + readonly remove: (threadKey: string, promptId: string) => void; + readonly markSending: (threadKey: string, promptId: string) => void; + readonly markFailed: (threadKey: string, promptId: string) => void; + readonly retry: (threadKey: string, promptId: string) => void; +} + +const updatePromptStatus = ( + prompts: ReadonlyArray, + promptId: string, + status: QueuedPromptStatus, +): ReadonlyArray => + prompts.map((prompt) => (prompt.id === promptId ? { ...prompt, status } : prompt)); + +export const findNextQueuedPrompt = (prompts: ReadonlyArray): QueuedPrompt | null => + prompts.find((prompt) => prompt.status === "queued") ?? null; + +export const useQueuedPromptStore = create()((set) => ({ + promptsByThreadKey: {}, + enqueue: (prompt) => + set((state) => ({ + promptsByThreadKey: { + ...state.promptsByThreadKey, + [prompt.threadKey]: [...(state.promptsByThreadKey[prompt.threadKey] ?? []), prompt], + }, + })), + remove: (threadKey, promptId) => + set((state) => { + const current = state.promptsByThreadKey[threadKey] ?? []; + const next = current.filter((prompt) => prompt.id !== promptId); + if (next.length === current.length) return state; + const promptsByThreadKey = { ...state.promptsByThreadKey }; + if (next.length === 0) { + delete promptsByThreadKey[threadKey]; + } else { + promptsByThreadKey[threadKey] = next; + } + return { promptsByThreadKey }; + }), + markSending: (threadKey, promptId) => + set((state) => ({ + promptsByThreadKey: { + ...state.promptsByThreadKey, + [threadKey]: updatePromptStatus( + state.promptsByThreadKey[threadKey] ?? [], + promptId, + "sending", + ), + }, + })), + markFailed: (threadKey, promptId) => + set((state) => ({ + promptsByThreadKey: { + ...state.promptsByThreadKey, + [threadKey]: updatePromptStatus( + state.promptsByThreadKey[threadKey] ?? [], + promptId, + "failed", + ), + }, + })), + retry: (threadKey, promptId) => + set((state) => ({ + promptsByThreadKey: { + ...state.promptsByThreadKey, + [threadKey]: updatePromptStatus( + state.promptsByThreadKey[threadKey] ?? [], + promptId, + "queued", + ), + }, + })), +})); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index b524fe018..5a95ede09 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as SettingsSourceControlRouteImport } from './routes/settings.sou import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' +import { Route as SettingsDisplayRouteImport } from './routes/settings.display' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' @@ -69,6 +70,11 @@ const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ path: '/general', getParentRoute: () => SettingsRoute, } as any) +const SettingsDisplayRoute = SettingsDisplayRouteImport.update({ + id: '/display', + path: '/display', + getParentRoute: () => SettingsRoute, +} as any) const SettingsDiagnosticsRoute = SettingsDiagnosticsRouteImport.update({ id: '/diagnostics', path: '/diagnostics', @@ -110,6 +116,7 @@ export interface FileRoutesByFullPath { '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute + '/settings/display': typeof SettingsDisplayRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/providers': typeof SettingsProvidersRoute @@ -125,6 +132,7 @@ export interface FileRoutesByTo { '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute + '/settings/display': typeof SettingsDisplayRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/providers': typeof SettingsProvidersRoute @@ -143,6 +151,7 @@ export interface FileRoutesById { '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute + '/settings/display': typeof SettingsDisplayRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/providers': typeof SettingsProvidersRoute @@ -162,6 +171,7 @@ export interface FileRouteTypes { | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' + | '/settings/display' | '/settings/general' | '/settings/keybindings' | '/settings/providers' @@ -177,6 +187,7 @@ export interface FileRouteTypes { | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' + | '/settings/display' | '/settings/general' | '/settings/keybindings' | '/settings/providers' @@ -194,6 +205,7 @@ export interface FileRouteTypes { | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' + | '/settings/display' | '/settings/general' | '/settings/keybindings' | '/settings/providers' @@ -276,6 +288,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsGeneralRouteImport parentRoute: typeof SettingsRoute } + '/settings/display': { + id: '/settings/display' + path: '/display' + fullPath: '/settings/display' + preLoaderRoute: typeof SettingsDisplayRouteImport + parentRoute: typeof SettingsRoute + } '/settings/diagnostics': { id: '/settings/diagnostics' path: '/diagnostics' @@ -339,6 +358,7 @@ interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute + SettingsDisplayRoute: typeof SettingsDisplayRoute SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute @@ -349,6 +369,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, + SettingsDisplayRoute: SettingsDisplayRoute, SettingsGeneralRoute: SettingsGeneralRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, SettingsProvidersRoute: SettingsProvidersRoute, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ff6bc5b39..fd94687b5 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -15,6 +15,7 @@ import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; +import { DisplayPreferencesSync } from "../components/DisplayPreferencesSync"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; @@ -100,6 +101,7 @@ function RootRouteView() { if (pathname === "/pair" || pathname === "/connect" || pathname.startsWith("/connect/")) { return ( <> + @@ -109,6 +111,7 @@ function RootRouteView() { if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { return ( <> + @@ -126,6 +129,7 @@ function RootRouteView() { return ( + {primaryEnvironmentAuthenticated ? : null} diff --git a/apps/web/src/routes/settings.display.tsx b/apps/web/src/routes/settings.display.tsx new file mode 100644 index 000000000..df6f3b05d --- /dev/null +++ b/apps/web/src/routes/settings.display.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { DisplaySettingsPanel } from "../components/settings/DisplaySettings"; + +export const Route = createFileRoute("/settings/display")({ + component: DisplaySettingsPanel, +}); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ac2d47ca3..04021f3c4 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -31,6 +31,36 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings display preferences", () => { + it("defaults to the theme palette and 100% interface text", () => { + const decoded = decodeClientSettings({}); + + expect(decoded.displayFontScale).toBe(100); + expect(decoded.displayTextColor).toBeNull(); + expect(decoded.displayBackgroundColor).toBeNull(); + expect(decoded.displayAccentColor).toBeNull(); + }); + + it("accepts a bounded font scale and six-digit hex colors", () => { + const decoded = decodeClientSettings({ + displayFontScale: 115, + displayTextColor: "#f5f5f5", + displayBackgroundColor: "#10131a", + displayAccentColor: "#8b5cf6", + }); + + expect(decoded.displayFontScale).toBe(115); + expect(decoded.displayTextColor).toBe("#f5f5f5"); + expect(decoded.displayBackgroundColor).toBe("#10131a"); + expect(decoded.displayAccentColor).toBe("#8b5cf6"); + }); + + it("rejects unsafe scales and malformed colors", () => { + expect(() => decodeClientSettings({ displayFontScale: 70 })).toThrow(); + expect(() => decodeClientSettings({ displayAccentColor: "blue" })).toThrow(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b05f397bf..1d7db50b4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -39,6 +39,17 @@ export const SidebarThreadPreviewCount = Schema.Int.check( export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; +export const MIN_DISPLAY_FONT_SCALE = 80; +export const MAX_DISPLAY_FONT_SCALE = 130; +export const DisplayFontScale = Schema.Int.check( + Schema.isBetween({ minimum: MIN_DISPLAY_FONT_SCALE, maximum: MAX_DISPLAY_FONT_SCALE }), +); +export type DisplayFontScale = typeof DisplayFontScale.Type; +export const DEFAULT_DISPLAY_FONT_SCALE: DisplayFontScale = 100; + +export const DisplayHexColor = Schema.String.check(Schema.isPattern(/^#[\da-f]{6}$/i)); +export type DisplayHexColor = typeof DisplayHexColor.Type; + export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -47,6 +58,18 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + displayAccentColor: Schema.NullOr(DisplayHexColor).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + displayBackgroundColor: Schema.NullOr(DisplayHexColor).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + displayFontScale: DisplayFontScale.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_DISPLAY_FONT_SCALE)), + ), + displayTextColor: Schema.NullOr(DisplayHexColor).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -538,6 +561,10 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), + displayAccentColor: Schema.optionalKey(Schema.NullOr(DisplayHexColor)), + displayBackgroundColor: Schema.optionalKey(Schema.NullOr(DisplayHexColor)), + displayFontScale: Schema.optionalKey(DisplayFontScale), + displayTextColor: Schema.optionalKey(Schema.NullOr(DisplayHexColor)), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({