diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 399b948d..302c90bc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.9.10", + "version": "0.10.0", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 57daf9b6..4665dbe0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -2553,25 +2553,17 @@ function getIconOption(): { icon: string } | Record { return iconPath ? { icon: iconPath } : {}; } -// macOS backs the translucent shell with window vibrancy, so the window is created -// transparent (`#00000000`) over the vibrancy material. Windows/Linux have no vibrancy: -// a transparent window there leaves backdrop-filter surfaces bleeding through and, on -// fractional DPI, rendering blurry. So off macOS we create an opaque window and skip the -// macOS-only options. The background is a fixed dark tone purely to avoid a flash before -// the renderer paints — the app is dark-only, and the window is shown only after first -// paint (`show: false`), so this color need not match the in-app surface exactly. +// The window is opaque on every platform. macOS used to create it transparent +// (`#00000000`) over an "under-window" vibrancy material so the renderer's glass +// surfaces could blur the desktop; that material is gone from the UI, and keeping +// the vibrancy layer would leave WindowServer compositing a blur under a window +// that paints over it completely. +// +// The background colour only avoids a flash before the renderer paints. The window +// is shown after first paint (`show: false`), so it need not track the active theme +// exactly — it is the dark canvas, which is the default. function getWindowMaterialOptions(): BrowserWindowConstructorOptions { - if (process.platform !== "darwin") { - return { backgroundColor: "#181818" }; - } - return { - vibrancy: "under-window", - // "followWindow" lets macOS drop vibrancy blending to inactive when the - // window is backgrounded, so WindowServer stops continuously recompositing - // it. "active" forced full-cost blending even when the app was unfocused. - visualEffectState: "followWindow", - backgroundColor: "#00000000", - }; + return { backgroundColor: "#090909" }; } // macOS keeps native traffic lights inset into the renderer's top chrome. Windows diff --git a/apps/server/package.json b/apps/server/package.json index 880249e7..223a0a4b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.9.10", + "version": "0.10.0", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/automation/Layers/AutomationService.test.ts b/apps/server/src/automation/Layers/AutomationService.test.ts index 8c229e09..40188da8 100644 --- a/apps/server/src/automation/Layers/AutomationService.test.ts +++ b/apps/server/src/automation/Layers/AutomationService.test.ts @@ -146,6 +146,7 @@ function makeTaskShell(overrides: { workerId: projectId, requesterWorkerId: null, requesterTaskId: null, + requesterThreadId: null, title: "Existing user Task", brief: "User-owned work must remain under user control.", status: "in_progress", diff --git a/apps/server/src/devServerManager.ts b/apps/server/src/devServerManager.ts index 417ee88d..9c000afe 100644 --- a/apps/server/src/devServerManager.ts +++ b/apps/server/src/devServerManager.ts @@ -140,10 +140,13 @@ export const DevServerManagerLive = Layer.effect( ...(input.env ? { env: input.env } : {}), }); + // A one-shot command exits its shell the moment it returns, so the PTY + // emits `exited` and the reaper drops the registry entry. Without this a + // finished setup script would be reported as running until the app quits. yield* terminalManager.write({ threadId, terminalId: DEFAULT_TERMINAL_ID, - data: `${input.command}\r`, + data: input.oneShot ? `${input.command}; exit\r` : `${input.command}\r`, }); const server: ProjectDevServer = { diff --git a/apps/server/src/effectServer.ts b/apps/server/src/effectServer.ts index 4ec5fcc1..e5d8fc3b 100644 --- a/apps/server/src/effectServer.ts +++ b/apps/server/src/effectServer.ts @@ -6,6 +6,7 @@ import { Effect, Exit, FileSystem, Layer, Path, Schema, Scope, ServiceMap } from import { HttpRouter } from "effect/unstable/http"; import { AutomationRunReactor } from "./automation/Services/AutomationRunReactor"; +import { WorkerInboxReactor } from "./orchestration/Services/WorkerInboxReactor"; import { AutomationScheduler } from "./automation/Services/AutomationScheduler"; import { AutomationService } from "./automation/Services/AutomationService"; import { @@ -41,6 +42,7 @@ export interface ServerShape { | Path.Path | Keybindings | AutomationRunReactor + | WorkerInboxReactor | AutomationScheduler | AutomationService | ServerLifecycleEvents @@ -69,6 +71,7 @@ export class ServerLifecycleError extends Schema.TaggedErrorClass ({ + threadId: ThreadId.makeUnsafe(`worker-inbox:${taskId}`), + messageId: MessageId.makeUnsafe(`worker-inbox:${taskId}:request`), + threadCreate: CommandId.makeUnsafe(`worker-inbox:${taskId}:thread-create`), + taskInProgress: CommandId.makeUnsafe(`worker-inbox:${taskId}:in-progress`), + turnStart: CommandId.makeUnsafe(`worker-inbox:${taskId}:turn-start`), +}); + +const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const spawnResponder = (taskId: TaskId) => + Effect.gen(function* () { + const snapshot = yield* snapshotQuery.getShellSnapshot(); + const task = snapshot.tasks.find((candidate) => candidate.id === taskId); + if (!task || task.origin !== "delegation") return; + // Re-check under the fresh snapshot: the live event and startup recovery can + // both target the same Task, and a Task owns exactly one canonical Thread. + if (responderThreadIdFor({ taskId: task.id, threads: snapshot.threads })) return; + + const worker = snapshot.projects.find( + (candidate) => candidate.id === task.workerId && candidate.kind === "project", + ); + if (!worker) return; + const requester = task.requesterWorkerId + ? snapshot.projects.find((candidate) => candidate.id === task.requesterWorkerId) + : undefined; + const requesterThread = task.requesterThreadId + ? snapshot.threads.find((candidate) => candidate.id === task.requesterThreadId) + : undefined; + + // The receiving Worker's own default wins; otherwise mirror the requester's + // model so a repository without a configured default still answers. + const modelSelection = worker.defaultModelSelection ?? requesterThread?.modelSelection; + if (!modelSelection) { + yield* Effect.logWarning("worker inbox reactor cannot resolve a model for the request", { + taskId: task.id, + workerId: task.workerId, + }); + return; + } + // Inherit the requester's runtime mode, falling back to the same default a + // hand-created Thread gets. An approval-gated session would stall waiting on + // a user click, which is exactly what auto-answering exists to avoid. + const runtimeMode = requesterThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const ids = spawnCommandIds(task.id); + const now = new Date().toISOString(); + + yield* engine.dispatch({ + type: "thread.create", + commandId: ids.threadCreate, + threadId: ids.threadId, + projectId: task.workerId, + taskId: task.id, + title: task.title, + modelSelection, + runtimeMode, + envMode: "local", + branch: null, + worktreePath: null, + createdAt: now, + }); + yield* engine.dispatch({ + type: "task.update", + commandId: ids.taskInProgress, + taskId: task.id, + status: "in_progress", + }); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: ids.turnStart, + threadId: ids.threadId, + message: { + messageId: ids.messageId, + role: "user", + text: buildDelegationRequestPrompt({ + task, + requesterWorkerTitle: requester?.title ?? "requesting", + }), + attachments: [], + }, + modelSelection, + dispatchMode: "queue", + // Reuses the automation origin rather than adding a Worker-specific one: + // it already means "dispatched by the system, not typed by the user", and + // a new enum value would break decoding of persisted messages on downgrade. + dispatchOrigin: "automation", + runtimeMode, + createdAt: now, + }); + }); + + const spawnSafely = (taskId: TaskId) => + spawnResponder(taskId).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause); + return Effect.logWarning("worker inbox reactor failed to answer a request", { + taskId, + cause: Cause.pretty(cause), + }); + }), + ); + + const queuedTaskIds = new Set(); + const worker = yield* makeDrainableWorker((taskId: TaskId) => + spawnSafely(taskId).pipe(Effect.ensuring(Effect.sync(() => queuedTaskIds.delete(taskId)))), + ); + const enqueueSpawn = (taskId: TaskId) => + Effect.sync(() => { + if (queuedTaskIds.has(taskId)) return false; + queuedTaskIds.add(taskId); + return true; + }).pipe(Effect.flatMap((fresh) => (fresh ? worker.enqueue(taskId) : Effect.void))); + + const start: WorkerInboxReactorShape["start"] = Effect.fn(function* () { + // Pick up requests that landed while the server was down before watching the + // live stream. Spawning is idempotent, so overlap with live events is harmless. + const pending = yield* snapshotQuery.getShellSnapshot().pipe( + Effect.map(delegationTasksAwaitingResponder), + Effect.catchCause((cause) => + Effect.logWarning("worker inbox reactor recovery failed", { + cause: Cause.pretty(cause), + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ); + for (const task of pending) { + yield* enqueueSpawn(task.id); + } + yield* Effect.forkScoped( + Stream.runForEach(engine.streamDomainEvents, (event) => + event.type === "task.created" && event.payload.origin === "delegation" + ? enqueueSpawn(event.payload.taskId) + : Effect.void, + ), + ); + }); + + return { start, drain: worker.drain } satisfies WorkerInboxReactorShape; +}); + +export const WorkerInboxReactorLive = Layer.effect(WorkerInboxReactor, make); diff --git a/apps/server/src/orchestration/Services/WorkerInboxReactor.ts b/apps/server/src/orchestration/Services/WorkerInboxReactor.ts new file mode 100644 index 00000000..245a715e --- /dev/null +++ b/apps/server/src/orchestration/Services/WorkerInboxReactor.ts @@ -0,0 +1,15 @@ +// FILE: WorkerInboxReactor.ts +// Purpose: Service contract for the reactor that answers cross-Worker inbox requests. +// Layer: Server orchestration service + +import { Effect, Scope, ServiceMap } from "effect"; + +export interface WorkerInboxReactorShape { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; +} + +export class WorkerInboxReactor extends ServiceMap.Service< + WorkerInboxReactor, + WorkerInboxReactorShape +>()("t3/orchestration/Services/WorkerInboxReactor") {} diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 979d3bdf..e8123616 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -450,6 +450,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" workerId: command.workerId, requesterWorkerId: command.requesterWorkerId ?? null, requesterTaskId: command.requesterTaskId ?? null, + requesterThreadId: command.requesterThreadId ?? null, title: command.title, brief: command.brief, status: "open", diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 605666c2..583ab98b 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -301,6 +301,7 @@ export function projectEvent( workerId: payload.workerId, requesterWorkerId: payload.requesterWorkerId, requesterTaskId: payload.requesterTaskId, + requesterThreadId: payload.requesterThreadId ?? null, title: payload.title, brief: payload.brief, status: payload.status, diff --git a/apps/server/src/orchestration/workerInboxChannel.test.ts b/apps/server/src/orchestration/workerInboxChannel.test.ts new file mode 100644 index 00000000..58d673ef --- /dev/null +++ b/apps/server/src/orchestration/workerInboxChannel.test.ts @@ -0,0 +1,170 @@ +import { + ProjectId, + TaskId, + ThreadId, + type OrchestrationShellSnapshot, + type OrchestrationTaskShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + buildDelegationReplyPrompt, + buildDelegationRequestPrompt, + delegationTasksAwaitingResponder, + peerThreadFor, + responderThreadIdFor, +} from "./workerInboxChannel.ts"; + +const requesterWorker = ProjectId.makeUnsafe("worker-a"); +const recipientWorker = ProjectId.makeUnsafe("worker-b"); +const requesterThread = ThreadId.makeUnsafe("thread-a"); +const responderThread = ThreadId.makeUnsafe("thread-b"); +const requestId = TaskId.makeUnsafe("task-request"); + +function delegationTask(overrides: Partial = {}): OrchestrationTaskShell { + return { + id: requestId, + workerId: recipientWorker, + requesterWorkerId: requesterWorker, + requesterTaskId: null, + requesterThreadId: requesterThread, + title: "Share the design system", + brief: "Send tokens and component states.", + status: "open", + origin: "delegation", + artifacts: [], + completionSummary: null, + createdAt: "2026-07-22T00:00:00.000Z", + updatedAt: "2026-07-22T00:00:00.000Z", + completedAt: null, + ...overrides, + }; +} + +function thread(id: ThreadId, taskId: TaskId | null) { + return { id, taskId } as OrchestrationShellSnapshot["threads"][number]; +} + +function snapshot(input: { + tasks: ReadonlyArray; + threads: ReadonlyArray; +}) { + return input as unknown as OrchestrationShellSnapshot; +} + +describe("responderThreadIdFor", () => { + it("resolves the Task's canonical Thread as the responder end", () => { + expect( + responderThreadIdFor({ + taskId: requestId, + threads: [thread(requesterThread, null), thread(responderThread, requestId)], + }), + ).toBe(responderThread); + }); + + it("is null before the reactor has spawned a Thread", () => { + expect( + responderThreadIdFor({ taskId: requestId, threads: [thread(requesterThread, null)] }), + ).toBeNull(); + }); +}); + +describe("delegationTasksAwaitingResponder", () => { + it("selects only open delegation Tasks with no Thread yet", () => { + const answered = delegationTask({ id: TaskId.makeUnsafe("task-answered") }); + const closed = delegationTask({ id: TaskId.makeUnsafe("task-closed"), status: "completed" }); + const ordinary = delegationTask({ id: TaskId.makeUnsafe("task-plain"), origin: "user" }); + + const pending = delegationTasksAwaitingResponder( + snapshot({ + tasks: [delegationTask(), answered, closed, ordinary], + threads: [thread(responderThread, answered.id)], + }), + ); + + expect(pending.map((task) => task.id)).toEqual([requestId]); + }); + + // The reactor runs this on every startup, so a Task that already has its Thread + // must never be selected again — that would breach one Task, one canonical Thread. + it("does not reselect a Task once its Thread exists", () => { + expect( + delegationTasksAwaitingResponder( + snapshot({ + tasks: [delegationTask()], + threads: [thread(responderThread, requestId)], + }), + ), + ).toEqual([]); + }); +}); + +describe("peerThreadFor", () => { + const threads = [thread(requesterThread, null), thread(responderThread, requestId)]; + + it("routes a requester's message to the responder Thread", () => { + expect( + peerThreadFor({ task: delegationTask(), threads, callerThreadId: requesterThread }), + ).toEqual({ peerThreadId: responderThread, callerSide: "requester" }); + }); + + it("routes a responder's reply back to the requester Thread", () => { + expect( + peerThreadFor({ task: delegationTask(), threads, callerThreadId: responderThread }), + ).toEqual({ peerThreadId: requesterThread, callerSide: "responder" }); + }); + + it("refuses a Thread that is not on the channel", () => { + expect( + peerThreadFor({ + task: delegationTask(), + threads, + callerThreadId: ThreadId.makeUnsafe("thread-outsider"), + }), + ).toBeNull(); + }); + + it("returns null while the responder Thread has not spawned yet", () => { + expect( + peerThreadFor({ + task: delegationTask(), + threads: [thread(requesterThread, null)], + callerThreadId: requesterThread, + }), + ).toBeNull(); + }); +}); + +describe("channel prompts", () => { + it("tells the responder how to reply and on which channel", () => { + const prompt = buildDelegationRequestPrompt({ + task: delegationTask(), + requesterWorkerTitle: "BonsAI", + }); + expect(prompt).toContain("BonsAI"); + expect(prompt).toContain("Share the design system"); + expect(prompt).toContain("Send tokens and component states."); + expect(prompt).toContain(`inbox_reply tool (request_id: ${requestId})`); + }); + + it("tells the requester to resume, and whether the channel is still open", () => { + const open = buildDelegationReplyPrompt({ + task: delegationTask(), + fromWorkerTitle: "BonsAI", + body: "Here are the tokens.", + closed: false, + }); + expect(open).toContain("replied on your request"); + expect(open).toContain("Continue the work this answer was blocking."); + expect(open).toContain(`inbox_reply with request_id: ${requestId}`); + + const closed = buildDelegationReplyPrompt({ + task: delegationTask(), + fromWorkerTitle: "BonsAI", + body: "Here are the tokens.", + closed: true, + }); + expect(closed).toContain("closed the channel"); + expect(closed).toContain("The channel is closed"); + }); +}); diff --git a/apps/server/src/orchestration/workerInboxChannel.ts b/apps/server/src/orchestration/workerInboxChannel.ts new file mode 100644 index 00000000..90a2f738 --- /dev/null +++ b/apps/server/src/orchestration/workerInboxChannel.ts @@ -0,0 +1,121 @@ +// FILE: workerInboxChannel.ts +// Purpose: Pure rules for the two-Thread channel behind a cross-Worker inbox request. +// Layer: Server orchestration logic +// Exports: delegationTasksAwaitingResponder, responderThreadIdFor, peerThreadFor, +// buildDelegationRequestPrompt, buildDelegationReplyPrompt, CHANNEL_CLOSED_STATUSES + +import type { + OrchestrationShellSnapshot, + OrchestrationTaskShell, + ThreadId, +} from "@t3tools/contracts"; + +/** + * A delegation Task is a live channel between two Threads: the Thread that sent + * the request (`task.requesterThreadId`) and the Thread spawned to fulfil it + * (the Task's canonical Thread — `thread.taskId`, unique per Task). + * + * The channel stays open, and either side may keep talking, until the Task + * reaches a closed status. Closing is deliberate: neither side should have to + * guess whether a follow-up is still welcome. + */ +export const CHANNEL_CLOSED_STATUSES: ReadonlySet = new Set([ + "completed", + "cancelled", +]); + +type ThreadShell = OrchestrationShellSnapshot["threads"][number]; + +/** The Thread spawned to work the request, or null before the reactor has spawned it. */ +export function responderThreadIdFor(input: { + readonly taskId: OrchestrationTaskShell["id"]; + readonly threads: ReadonlyArray; +}): ThreadId | null { + return input.threads.find((thread) => thread.taskId === input.taskId)?.id ?? null; +} + +/** + * Given a Thread on one end of a channel, resolve the Thread on the other end. + * Returns null when the caller is not on this channel, so a Worker can never + * push a message into a conversation it is not part of. + */ +export function peerThreadFor(input: { + readonly task: OrchestrationTaskShell; + readonly threads: ReadonlyArray; + readonly callerThreadId: ThreadId; +}): { readonly peerThreadId: ThreadId; readonly callerSide: "requester" | "responder" } | null { + const responderThreadId = responderThreadIdFor({ taskId: input.task.id, threads: input.threads }); + if (input.callerThreadId === input.task.requesterThreadId) { + return responderThreadId ? { peerThreadId: responderThreadId, callerSide: "requester" } : null; + } + if (responderThreadId && input.callerThreadId === responderThreadId) { + return input.task.requesterThreadId + ? { peerThreadId: input.task.requesterThreadId, callerSide: "responder" } + : null; + } + return null; +} + +/** + * Delegation Tasks that still need a Thread spawned to work them. + * + * Used both on the live `task.created` path and on startup recovery, so a + * request that arrived while the server was down is not stranded. Selection is + * idempotent: once a canonical Thread exists the Task drops out of the set. + */ +export function delegationTasksAwaitingResponder( + snapshot: OrchestrationShellSnapshot, +): ReadonlyArray { + return snapshot.tasks.filter( + (task) => + task.origin === "delegation" && + !CHANNEL_CLOSED_STATUSES.has(task.status) && + responderThreadIdFor({ taskId: task.id, threads: snapshot.threads }) === null, + ); +} + +function channelProtocol(taskId: OrchestrationTaskShell["id"]): string { + return [ + `This conversation is a channel with the requesting Worker. The channel id is ${taskId}.`, + `Reply with the inbox_reply tool (request_id: ${taskId}) — that is the only way your answer reaches them.`, + "You may exchange several messages; the channel stays open until one side closes it.", + "Pass close: true on your final reply when the request is fully answered, or use tasks_close.", + "Never edit the requesting Worker's repository. Answer from your own repository only.", + ].join("\n"); +} + +/** The opening prompt for the auto-spawned Thread that will answer a request. */ +export function buildDelegationRequestPrompt(input: { + readonly task: OrchestrationTaskShell; + readonly requesterWorkerTitle: string; +}): string { + return [ + `The ${input.requesterWorkerTitle} Worker sent this repository a request.`, + "", + `Subject: ${input.task.title}`, + "", + input.task.brief.trim(), + "", + channelProtocol(input.task.id), + ].join("\n"); +} + +/** The prompt delivered to the far end of an open channel when a reply arrives. */ +export function buildDelegationReplyPrompt(input: { + readonly task: OrchestrationTaskShell; + readonly fromWorkerTitle: string; + readonly body: string; + readonly closed: boolean; +}): string { + return [ + input.closed + ? `The ${input.fromWorkerTitle} Worker answered your request "${input.task.title}" and closed the channel.` + : `The ${input.fromWorkerTitle} Worker replied on your request "${input.task.title}".`, + "", + input.body.trim(), + "", + input.closed + ? "The channel is closed; send a new request with inbox_send if you need more. Continue the work this answer was blocking." + : `Continue the work this answer was blocking. To follow up, use inbox_reply with request_id: ${input.task.id}.`, + ].join("\n"); +} diff --git a/apps/server/src/orchestration/workerMentionContext.test.ts b/apps/server/src/orchestration/workerMentionContext.test.ts index 4198a49e..44713c04 100644 --- a/apps/server/src/orchestration/workerMentionContext.test.ts +++ b/apps/server/src/orchestration/workerMentionContext.test.ts @@ -20,6 +20,7 @@ describe("buildWorkerMentionContext", () => { workerId, requesterWorkerId: null, requesterTaskId: null, + requesterThreadId: null, title: "Audit urgent fixes", brief: "Inspect the five urgent findings.", status: "open", diff --git a/apps/server/src/orchestration/workerTaskContext.test.ts b/apps/server/src/orchestration/workerTaskContext.test.ts index c5272b20..c69856cd 100644 --- a/apps/server/src/orchestration/workerTaskContext.test.ts +++ b/apps/server/src/orchestration/workerTaskContext.test.ts @@ -15,6 +15,7 @@ function task( workerId, requesterWorkerId: null, requesterTaskId: null, + requesterThreadId: null, title, brief: `${title} brief`, status, diff --git a/apps/server/src/orchestration/workerTaskContext.ts b/apps/server/src/orchestration/workerTaskContext.ts index 95c9cef7..6e26c06b 100644 --- a/apps/server/src/orchestration/workerTaskContext.ts +++ b/apps/server/src/orchestration/workerTaskContext.ts @@ -60,6 +60,8 @@ export function buildWorkerTaskContext(input: { : "Pending Tasks for this Worker: none.", "Provider plans and todo lists are execution progress, not TeaCode Tasks. Use tasks_list, tasks_create, tasks_update, tasks_close, and tasks_pull for durable Task work when the user asks.", "Use inbox_list and inbox_send for structured cross-repository Worker requests. Never edit another Worker's repository directly.", + "inbox_send is fully automatic: the receiving Worker starts its own session, answers, and its reply arrives back in this Thread. Do not ask the user to relay, approve, or check on it — send the request, say you sent it, and continue with work that does not depend on the answer.", + "Reply on an open request channel with inbox_reply (request_id is the id inbox_send returned, or the channel id given to you). Pass close: true on the final reply.", "When the user references or asks to pull a Task, use its exact id and brief in the current Thread unless the user explicitly starts another Thread. Do not invent a duplicate Task or edit another repository directly.", "", ].join("\n"); diff --git a/apps/server/src/orchestration/workerToolsMcp.test.ts b/apps/server/src/orchestration/workerToolsMcp.test.ts index 69d10149..9f792b04 100644 --- a/apps/server/src/orchestration/workerToolsMcp.test.ts +++ b/apps/server/src/orchestration/workerToolsMcp.test.ts @@ -33,7 +33,7 @@ async function createSystem() { } describe("Worker MCP tools", () => { - it("lets an agent manage Tasks and Inbox requests without implicitly creating Threads", async () => { + it("lets an agent manage Tasks, and binds an Inbox request to the Thread that sent it", async () => { const system = await createSystem(); const workerA = ProjectId.makeUnsafe("worker-a"); const workerB = ProjectId.makeUnsafe("worker-b"); @@ -117,17 +117,34 @@ describe("Worker MCP tools", () => { subject: "Provide the API contract", body: "Return the current contract for this integration.", related_task_id: created.id, - })) as { requestId: string; threadCreated: boolean }; - expect(request.threadCreated).toBe(false); + })) as { requestId: string; autoDispatched: boolean }; + expect(request.autoDispatched).toBe(true); + // The sending Thread is recorded on the request so the reply has somewhere to + // land. The responder Thread is spawned by WorkerInboxReactor, which is not + // mounted here, so the channel reads as open with no responder yet. const inbox = (await call(threadB, "inbox_list", {})) as Array<{ id: string; requesterWorkerId: string | null; + requesterThreadId: string | null; + responderThreadId: string | null; + channelOpen: boolean; }>; expect(inbox).toEqual([ - expect.objectContaining({ id: request.requestId, requesterWorkerId: workerA }), + expect.objectContaining({ + id: request.requestId, + requesterWorkerId: workerA, + requesterThreadId: threadA, + responderThreadId: null, + channelOpen: true, + }), ]); + // Replying needs both ends bound; without a responder Thread there is no peer. + await expect( + call(threadA, "inbox_reply", { request_id: request.requestId, body: "ping" }), + ).rejects.toThrow(/not part of request channel/); + await call(threadA, "tasks_close", { task_id: created.id, outcome: "completed", diff --git a/apps/server/src/orchestration/workerToolsMcp.ts b/apps/server/src/orchestration/workerToolsMcp.ts index 8372cc34..ed3553f6 100644 --- a/apps/server/src/orchestration/workerToolsMcp.ts +++ b/apps/server/src/orchestration/workerToolsMcp.ts @@ -3,6 +3,8 @@ import { CommandId, + DEFAULT_RUNTIME_MODE, + MessageId, ProjectId, TaskId, ThreadId, @@ -16,6 +18,12 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab import type { ServerConfigShape } from "../config.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import { + buildDelegationReplyPrompt, + peerThreadFor, + responderThreadIdFor, + CHANNEL_CLOSED_STATUSES, +} from "./workerInboxChannel.ts"; export const WORKER_TOOLS_MCP_PATH = "/api/worker-tools/mcp"; const WORKER_TOOLS_TOKEN = crypto.randomUUID(); @@ -121,7 +129,7 @@ const toolDefinitions = [ { name: "inbox_send", description: - "Send a structured work request to another repository Worker. The recipient receives a Task in its Inbox, but no Thread is created.", + "Send a structured work request to another repository Worker. The recipient automatically starts a session to answer it and replies on the same channel, so do not ask the user to relay anything. Returns a request_id identifying the channel.", inputSchema: { type: "object", properties: { @@ -134,6 +142,21 @@ const toolDefinitions = [ additionalProperties: false, }, }, + { + name: "inbox_reply", + description: + "Reply on an open cross-Worker request channel. The message is delivered to the Worker at the other end, which resumes automatically. Set close to true on the final reply to end the channel.", + inputSchema: { + type: "object", + properties: { + request_id: { type: "string" }, + body: { type: "string" }, + close: { type: "boolean" }, + }, + required: ["request_id", "body"], + additionalProperties: false, + }, + }, ] as const; function record(value: unknown): Record { @@ -166,7 +189,10 @@ function ownedTask(scope: WorkerScope, rawTaskId: string) { return task; } -function taskResult(task: OrchestrationShellSnapshot["tasks"][number]) { +function taskResult( + task: OrchestrationShellSnapshot["tasks"][number], + snapshot?: OrchestrationShellSnapshot, +) { return { id: task.id, workerId: task.workerId, @@ -177,6 +203,17 @@ function taskResult(task: OrchestrationShellSnapshot["tasks"][number]) { requesterWorkerId: task.requesterWorkerId, requesterTaskId: task.requesterTaskId, updatedAt: task.updatedAt, + // Delegation Tasks are channels; surfacing both ends lets the agent see whether + // a request is still answerable without a second tool call. + ...(task.origin === "delegation" + ? { + channelOpen: !CHANNEL_CLOSED_STATUSES.has(task.status), + requesterThreadId: task.requesterThreadId, + responderThreadId: snapshot + ? responderThreadIdFor({ taskId: task.id, threads: snapshot.threads }) + : null, + } + : {}), }; } @@ -217,7 +254,7 @@ export function runWorkerTool(input: { task.workerId === scope.workerId && (includeClosed || (task.status !== "completed" && task.status !== "cancelled")), ) - .map(taskResult); + .map((task) => taskResult(task)); } if (input.name === "tasks_create") { @@ -303,7 +340,7 @@ export function runWorkerTool(input: { if (input.name === "inbox_list") { return scope.snapshot.tasks .filter((task) => task.workerId === scope.workerId && task.origin === "delegation") - .map(taskResult); + .map((task) => taskResult(task, scope.snapshot)); } if (input.name === "inbox_send") { @@ -325,6 +362,7 @@ export function runWorkerTool(input: { workerId: recipient.id, requesterWorkerId: scope.workerId, ...(relatedTask ? { requesterTaskId: relatedTask.id } : {}), + requesterThreadId: scope.threadId, title: requiredString(args, "subject"), brief: requiredString(args, "body"), origin: "delegation", @@ -334,7 +372,71 @@ export function runWorkerTool(input: { requestId: taskId, recipientWorkerId: recipient.id, relatedTaskId: relatedTask?.id ?? null, - threadCreated: false, + // The recipient Worker spawns its own session and replies on this channel. + // Report progress and keep working; do not wait on the user to relay it. + autoDispatched: true, + }; + } + + if (input.name === "inbox_reply") { + const requestId = requiredString(args, "request_id"); + const task = scope.snapshot.tasks.find((candidate) => candidate.id === requestId); + if (!task || task.origin !== "delegation") { + throw new Error(`Request '${requestId}' is not a cross-Worker request channel.`); + } + if (CHANNEL_CLOSED_STATUSES.has(task.status)) { + throw new Error(`Request '${requestId}' is closed. Use inbox_send to open a new request.`); + } + const peer = peerThreadFor({ + task, + threads: scope.snapshot.threads, + callerThreadId: scope.threadId, + }); + if (!peer) { + throw new Error(`This Thread is not part of request channel '${requestId}'.`); + } + const body = requiredString(args, "body"); + const close = args.close === true; + const fromWorker = scope.snapshot.projects.find( + (candidate) => candidate.id === scope.workerId, + ); + + if (close) { + yield* engine.dispatch({ + type: "task.update", + commandId: commandId(), + taskId: task.id, + status: "completed", + ...(peer.callerSide === "responder" ? { completionSummary: body } : {}), + }); + } + const now = new Date().toISOString(); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: commandId(), + threadId: peer.peerThreadId, + message: { + messageId: MessageId.makeUnsafe(crypto.randomUUID()), + role: "user", + text: buildDelegationReplyPrompt({ + task, + fromWorkerTitle: fromWorker?.title ?? "peer", + body, + closed: close, + }), + attachments: [], + }, + dispatchMode: "queue", + // Same rationale as the inbox reactor: "automation" already means + // system-dispatched, and a new origin would break persisted message decoding. + dispatchOrigin: "automation", + runtimeMode: DEFAULT_RUNTIME_MODE, + createdAt: now, + }); + return { + requestId: task.id, + deliveredToThreadId: peer.peerThreadId, + channelOpen: !close, }; } diff --git a/apps/server/src/persistence/Layers/ProjectionTasks.ts b/apps/server/src/persistence/Layers/ProjectionTasks.ts index 386c918d..9a4b3c3b 100644 --- a/apps/server/src/persistence/Layers/ProjectionTasks.ts +++ b/apps/server/src/persistence/Layers/ProjectionTasks.ts @@ -27,11 +27,12 @@ const makeProjectionTaskRepository = Effect.gen(function* () { Request: ProjectionTask, execute: (row) => sql` INSERT INTO projection_tasks ( - task_id, worker_id, requester_worker_id, requester_task_id, + task_id, worker_id, requester_worker_id, requester_task_id, requester_thread_id, title, brief, status, origin, artifacts_json, completion_summary, created_at, updated_at, completed_at ) VALUES ( ${row.taskId}, ${row.workerId}, ${row.requesterWorkerId}, ${row.requesterTaskId}, + ${row.requesterThreadId}, ${row.title}, ${row.brief}, ${row.status}, ${row.origin}, ${JSON.stringify(row.artifacts)}, ${row.completionSummary}, ${row.createdAt}, ${row.updatedAt}, ${row.completedAt} ) @@ -39,6 +40,7 @@ const makeProjectionTaskRepository = Effect.gen(function* () { worker_id = excluded.worker_id, requester_worker_id = excluded.requester_worker_id, requester_task_id = excluded.requester_task_id, + requester_thread_id = excluded.requester_thread_id, title = excluded.title, brief = excluded.brief, status = excluded.status, @@ -58,6 +60,7 @@ const makeProjectionTaskRepository = Effect.gen(function* () { SELECT task_id AS "taskId", worker_id AS "workerId", requester_worker_id AS "requesterWorkerId", requester_task_id AS "requesterTaskId", + requester_thread_id AS "requesterThreadId", title, brief, status, origin, artifacts_json AS "artifacts", completion_summary AS "completionSummary", created_at AS "createdAt", updated_at AS "updatedAt", completed_at AS "completedAt" @@ -73,6 +76,7 @@ const makeProjectionTaskRepository = Effect.gen(function* () { SELECT task_id AS "taskId", worker_id AS "workerId", requester_worker_id AS "requesterWorkerId", requester_task_id AS "requesterTaskId", + requester_thread_id AS "requesterThreadId", title, brief, status, origin, artifacts_json AS "artifacts", completion_summary AS "completionSummary", created_at AS "createdAt", updated_at AS "updatedAt", completed_at AS "completedAt" @@ -88,6 +92,7 @@ const makeProjectionTaskRepository = Effect.gen(function* () { SELECT task_id AS "taskId", worker_id AS "workerId", requester_worker_id AS "requesterWorkerId", requester_task_id AS "requesterTaskId", + requester_thread_id AS "requesterThreadId", title, brief, status, origin, artifacts_json AS "artifacts", completion_summary AS "completionSummary", created_at AS "createdAt", updated_at AS "updatedAt", completed_at AS "completedAt" diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 7e2adca0..ecb457d5 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -79,6 +79,7 @@ import Migration0060 from "./Migrations/060_WorkersTasks.ts"; import Migration0061 from "./Migrations/061_TaskDelegationLinks.ts"; import Migration0062 from "./Migrations/062_TaskArtifacts.ts"; import Migration0063 from "./Migrations/063_TaskCanonicalThreads.ts"; +import Migration0064 from "./Migrations/064_TaskRequesterThread.ts"; /** * Migration loader with all migrations defined inline. @@ -154,6 +155,7 @@ export const migrationEntries = [ [61, "TaskDelegationLinks", Migration0061], [62, "TaskArtifacts", Migration0062], [63, "TaskCanonicalThreads", Migration0063], + [64, "TaskRequesterThread", Migration0064], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/064_TaskRequesterThread.ts b/apps/server/src/persistence/Migrations/064_TaskRequesterThread.ts new file mode 100644 index 00000000..b85d6005 --- /dev/null +++ b/apps/server/src/persistence/Migrations/064_TaskRequesterThread.ts @@ -0,0 +1,24 @@ +// FILE: 064_TaskRequesterThread.ts +// Purpose: Record which Thread sent a delegation Task, so replies can route back to it. +// Layer: Server persistence migration + +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { columnExists } from "./schemaHelpers.ts"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // A delegation Task is a channel between two Threads. The responder end is the + // Task's canonical Thread (`projection_threads.task_id`, unique per Task since + // migration 063); this column records the other end — the Thread that sent the + // request — so a reply reaches a live conversation instead of dead-ending. + if (!(yield* columnExists(sql, "projection_tasks", "requester_thread_id"))) { + yield* sql`ALTER TABLE projection_tasks ADD COLUMN requester_thread_id TEXT`; + } + yield* sql` + CREATE INDEX IF NOT EXISTS projection_tasks_requester_thread_idx + ON projection_tasks(requester_thread_id, updated_at DESC) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionTasks.ts b/apps/server/src/persistence/Services/ProjectionTasks.ts index 2194dfdd..4b5cebbe 100644 --- a/apps/server/src/persistence/Services/ProjectionTasks.ts +++ b/apps/server/src/persistence/Services/ProjectionTasks.ts @@ -9,6 +9,7 @@ import { TaskId, TaskOrigin, TaskStatus, + ThreadId, } from "@t3tools/contracts"; import { Option, Schema, ServiceMap } from "effect"; import type { Effect } from "effect"; @@ -20,6 +21,7 @@ export const ProjectionTask = Schema.Struct({ workerId: ProjectId, requesterWorkerId: Schema.NullOr(ProjectId), requesterTaskId: Schema.NullOr(TaskId), + requesterThreadId: Schema.NullOr(ThreadId), title: Schema.String, brief: Schema.String, status: TaskStatus, diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 083b2d7e..c21fb835 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { Layer } from "effect"; import { AutomationRunReactorLive } from "./automation/Layers/AutomationRunReactor"; +import { WorkerInboxReactorLive } from "./orchestration/Layers/WorkerInboxReactor"; import { AutomationSchedulerLive } from "./automation/Layers/AutomationScheduler"; import { AutomationServiceLive } from "./automation/Layers/AutomationService"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; @@ -119,11 +120,15 @@ export function makeServerRuntimeServicesLayer() { const automationRunReactorLayer = AutomationRunReactorLive.pipe( Layer.provideMerge(automationServiceLayer), ); + const workerInboxReactorLayer = WorkerInboxReactorLive.pipe( + Layer.provideMerge(OrchestrationLayerLive), + ); return Layer.mergeAll( automationServiceLayer, automationSchedulerLayer, automationRunReactorLayer, + workerInboxReactorLayer, AutomationRepositoryLive, orchestrationReactorLayer, threadDeletionReactorLayer, diff --git a/apps/server/src/wsRpc.shellEvents.test.ts b/apps/server/src/wsRpc.shellEvents.test.ts index b16cad91..ba93d1cf 100644 --- a/apps/server/src/wsRpc.shellEvents.test.ts +++ b/apps/server/src/wsRpc.shellEvents.test.ts @@ -32,6 +32,9 @@ describe("Worker Task shell events", () => { workerId: "worker-1", requesterWorkerId: "worker-2", requesterTaskId: "task-2", + // The source event predates Worker channels and carries no requester + // Thread; the shell projection must still emit the key, defaulted to null. + requesterThreadId: null, title: "Verify inbox delivery", brief: "Smoke test only.", status: "open", diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index 3203b62a..12610459 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -310,7 +310,7 @@ function isShellRelevantEvent(event: OrchestrationEvent): boolean { export function toTaskCreatedShellStreamEvent( event: Extract, ): OrchestrationShellStreamEvent { - const { taskId, artifacts, ...task } = event.payload; + const { taskId, artifacts, requesterThreadId, ...task } = event.payload; return { kind: "task-upserted", sequence: event.sequence, @@ -318,6 +318,8 @@ export function toTaskCreatedShellStreamEvent( id: taskId, ...task, artifacts: artifacts ?? [], + // Absent on task.created events recorded before Worker channels shipped. + requesterThreadId: requesterThreadId ?? null, } satisfies OrchestrationTaskShell, }; } diff --git a/apps/web/package.json b/apps/web/package.json index 42f8df95..d6f9c5c8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.9.10", + "version": "0.10.0", "private": true, "type": "module", "scripts": { @@ -20,6 +20,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@formkit/auto-animate": "^0.9.0", + "@heroicons/react": "^2.2.0", "@legendapp/list": "3.0.0-beta.44", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 076b5ce1..4e732939 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -45,9 +45,6 @@ const APP_SETTINGS_STORAGE_KEY = "teacode:app-settings:v1"; const SERVER_SETTINGS_MIGRATION_STORAGE_KEY = "t3code:server-settings-migrated:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; -export const MIN_WINDOW_TRANSPARENCY = 0; -export const MAX_WINDOW_TRANSPARENCY = 30; -export const DEFAULT_WINDOW_TRANSPARENCY = 20; export const MIN_CHAT_FONT_SIZE_PX = 11; export const MAX_CHAT_FONT_SIZE_PX = 18; export const DEFAULT_CHAT_FONT_SIZE_PX = 14; @@ -125,8 +122,6 @@ export const AppSettingsSchema = Schema.Struct({ // Default color applied when highlighting selected transcript text. highlightColor: ThreadMarkerColor.pipe(withDefaults(() => "yellow" as const)), chatFontSizePx: Schema.Number.pipe(withDefaults(() => DEFAULT_CHAT_FONT_SIZE_PX)), - /** How much of the desktop shows through the window canvas, as a percentage. */ - windowTransparency: Schema.Number.pipe(withDefaults(() => DEFAULT_WINDOW_TRANSPARENCY)), terminalFontSizePx: Schema.Number.pipe(withDefaults(() => DEFAULT_TERMINAL_FONT_SIZE_PX)), codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), @@ -313,14 +308,6 @@ export function normalizeCustomModelSlugs( return normalizedModels; } -export function normalizeWindowTransparency(value: number | null | undefined): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - return DEFAULT_WINDOW_TRANSPARENCY; - } - - return Math.min(MAX_WINDOW_TRANSPARENCY, Math.max(MIN_WINDOW_TRANSPARENCY, Math.round(value))); -} - export function normalizeChatFontSizePx(value: number | null | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_CHAT_FONT_SIZE_PX; @@ -365,7 +352,6 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { ), piBinaryPath: normalizeProviderBinaryPathOverride("pi", settings.piBinaryPath), chatFontSizePx: normalizeChatFontSizePx(settings.chatFontSizePx), - windowTransparency: normalizeWindowTransparency(settings.windowTransparency), terminalFontSizePx: normalizeTerminalFontSizePx(settings.terminalFontSizePx), customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), diff --git a/apps/web/src/assets/fonts/Inter-LICENSE.txt b/apps/web/src/assets/fonts/Inter-LICENSE.txt deleted file mode 100644 index 9b2ca37b..00000000 --- a/apps/web/src/assets/fonts/Inter-LICENSE.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION AND CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web/src/assets/fonts/InterVariable-Italic.woff2 b/apps/web/src/assets/fonts/InterVariable-Italic.woff2 deleted file mode 100644 index b3530f3f..00000000 Binary files a/apps/web/src/assets/fonts/InterVariable-Italic.woff2 and /dev/null differ diff --git a/apps/web/src/assets/fonts/InterVariable.woff2 b/apps/web/src/assets/fonts/InterVariable.woff2 deleted file mode 100644 index 5a8d3e72..00000000 Binary files a/apps/web/src/assets/fonts/InterVariable.woff2 and /dev/null differ diff --git a/apps/web/src/components/AppNavigationButtons.tsx b/apps/web/src/components/AppNavigationButtons.tsx index c39291cd..6e8aa76c 100644 --- a/apps/web/src/components/AppNavigationButtons.tsx +++ b/apps/web/src/components/AppNavigationButtons.tsx @@ -5,8 +5,8 @@ import { goBackInAppHistory, goForwardInAppHistory, useAppNavigationState } from "~/appNavigation"; import { isElectron } from "~/env"; +import { ArrowLeftIcon, ArrowRightIcon } from "~/lib/icons"; import { cn } from "~/lib/utils"; -import { IoIosArrowRoundBack, IoIosArrowRoundForward } from "react-icons/io"; import { Button } from "./ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -42,7 +42,7 @@ export function AppNavigationButtons({ className }: { className?: string }) { /> } > - + Back ({backShortcutLabel}) @@ -60,7 +60,7 @@ export function AppNavigationButtons({ className }: { className?: string }) { /> } > - + Forward ({forwardShortcutLabel}) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 03c68e71..1490fa6a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -305,7 +305,7 @@ export default function BranchToolbar({ <> {envGlyph("size-3.5")} {environmentPresentation.shortLabel} - + )} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index eda4e61d..7e78088c 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -784,12 +784,10 @@ export function BranchToolbarBranchSelector({
{itemValue} - {badge && ( - {badge} - )} + {badge && {badge}}
{currentBranchChangeSummary ? ( -
+
Uncommitted: {currentBranchChangeSummary.fileCount.toLocaleString()}{" "} {pluralize(currentBranchChangeSummary.fileCount, "file")} @@ -840,7 +838,7 @@ export function BranchToolbarBranchSelector({ <> {triggerLabel} - + )} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1146dde2..bafda42d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -821,7 +821,7 @@ function MarkdownCodeBlock({ size="icon-xs" variant="ghost" > - + - {copied ? : } + {copied ? : }
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0c17b37b..f7c0fc95 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -250,13 +250,14 @@ import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, + projectScriptCwd, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript, type ProjectScriptRunOptions, type ProjectScriptRunResult, } from "~/projectScripts"; -// projectTerminalRunner removed +import { launchProjectRun } from "~/projectRunLauncher"; import { newCommandId, newMessageId, newProjectId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; // terminalCloseConfirmation removed @@ -663,7 +664,7 @@ function ComposerControlSkeleton(props: { widthClassName: string }) {