diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index c3dc1c103d4..45524f6fcd3 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -1,6 +1,6 @@ --- name: test-t3-app -description: Launch and test the T3 Code web app in isolated development environments, including first-try browser authentication with one-time pairing URLs, pairing-token recovery, worktree-safe state directories, dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, test UI behavior in a browser, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite. +description: Launch, retain, and test the T3 Code web app in isolated development environments, including first-try browser authentication with one-time pairing URLs, pairing-token recovery, worktree-safe state directories, cross-turn dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, iteratively test UI behavior with a human, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite. --- # Test T3 App @@ -13,13 +13,35 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi 2. Choose a base directory that belongs only to the current worktree or test: - Use the repository's ignored `.t3` directory for reusable worktree-local state. - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path. -3. Start the full web stack with `vp run dev --home-dir `. +3. Start the full web stack with `vp run dev`. Add `--share` when the user needs to open it from another tailnet device. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir ` only when the test needs a different isolated directory. 4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output. Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership. +The worktree-local default deliberately outranks an ambient `T3CODE_HOME`; do not pass the shared home through to a worktree dev server. + +Ports are derived from the worktree path but can shift when occupied. Always read the actual values from the `[dev-runner]` line. + +Shared browser dev is single-origin: Vite proxies the backend paths, so never set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. + The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it. +### Verify a shared environment before human handoff + +When another person will use the printed pairing URL, first open the shared origin without the pairing path or fragment in the controlled browser and confirm the T3 Code app loads. This browser navigation is required even when curl succeeds because browsers block some otherwise reachable ports before making a network request. + +Do not open the other person's complete pairing URL during this reachability check; doing so consumes its one-time token. If the agent also needs an authenticated browser, create and consume a separate pairing token, then leave a fresh token for the other person. + +## Preserve the environment while iterating + +Treat the overall testing or implementation loop—not an assistant turn or one verification pass—as the environment lifecycle boundary. + +- Keep the dev process, base directory, selected ports, authenticated browser tab, registered projects, and seeded fixtures alive while the user may inspect the result or request follow-up changes. +- Do not stop the server merely because one verification pass completed or because you are yielding a response to the user. +- Before starting another environment, check whether the existing process and browser tab still serve the task. Reuse them when healthy instead of discarding useful state. +- On a later turn, verify that the existing process is alive and reuse its printed ports and base directory. If it exited, restart with the same base directory; create a new pairing token only when the browser session is no longer valid. +- Tell the user when a test environment remains available, including its non-secret web URL when useful. Never include a pairing token. + ## Authenticate the browser on the first navigation 1. Wait for the server log that says authentication is required and includes a URL ending in `/pair#token=...`. @@ -45,7 +67,7 @@ T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ Use the `Pair URL` from this command once. Derive `` and `` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port. -Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `/userdata`; the `/dev` fallback is only used by an implicit dev home. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. +Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `/userdata`; the `/dev` fallback is only used by an implicit dev home. A worktree-local `.t3` counts as explicit, so its state lives in `/.t3/userdata`. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. ## Inspect or seed SQLite state @@ -58,9 +80,17 @@ Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) before chang The helper refuses to write to the shared `~/.t3` directory by default and creates a database backup before each mutation. -## Finish the test +## Tear down only when the testing loop is finished + +Tear down when the user explicitly asks, confirms the iteration is finished, or the overall task is genuinely complete with no pending human review. Do not infer completion from the end of an assistant turn. + +When teardown is appropriate: + +1. Stop the dev process with its terminal interrupt. +2. Preserve the isolated base directory when it contains useful reproduction evidence or state for a likely follow-up. +3. Otherwise remove only a path created for this test after resolving and verifying the exact target. -Stop the dev process with its terminal interrupt. Preserve the isolated base directory when it contains useful reproduction evidence; otherwise remove only a path that was created for this test after resolving and verifying the exact target. A fresh isolated base directory is the safest reset when authentication, migrations, or fixture state becomes ambiguous. +If completion is uncertain, keep the environment alive and mention that it is retained for further iteration. A fresh isolated base directory remains the safest reset when authentication, migrations, or fixture state becomes ambiguous. ## Troubleshoot predictably diff --git a/.agents/skills/test-t3-app/agents/openai.yaml b/.agents/skills/test-t3-app/agents/openai.yaml index a3b89c95e60..0445ee4cb9e 100644 --- a/.agents/skills/test-t3-app/agents/openai.yaml +++ b/.agents/skills/test-t3-app/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "T3 App Testing" - short_description: "Launch and seed isolated T3 test environments" - default_prompt: "Use $test-t3-app to launch an isolated T3 development environment and test it in the browser." + short_description: "Launch and retain isolated T3 test environments" + default_prompt: "Use $test-t3-app to launch an isolated T3 environment and iteratively test it in the browser while preserving state." diff --git a/AGENTS.md b/AGENTS.md index d891ef1a6fe..29a4dd78269 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,14 @@ - When preparing fork PRs, branch from `origin/main` and target `tarik02/t3code:main`. - If a fork PR branch accidentally includes upstream history, rebuild it from `origin/main` and replay only the intended diff. +## Dev Servers + +- In a linked git worktree, dev state defaults to that worktree's gitignored `.t3`. This deliberately outranks an ambient `T3CODE_HOME`, which could otherwise select the installed app's live `~/.t3/userdata` database. An explicit `--home-dir` still wins. +- Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet. +- Browser dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. +- Worktree paths supply stable preferred port offsets. Read the actual server and web ports from the `[dev-runner]` line because occupied ports can still shift them. +- Before handing off a `--share` URL, open its origin in a controlled browser and confirm the app loads. A successful curl is insufficient because browsers reject some otherwise reachable ports. + ## Package Roles - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 902a4ee8b5d..0896335ea96 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -108,7 +108,8 @@ export function ThreadWorkLog(props: { {rows.map((row) => { const expanded = props.expandedRows[row.id] ?? false; - const canExpand = row.fullDetail !== null; + const canExpand = row.canExpand; + const fullDetail = expanded ? row.getFullDetail() : null; const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary; const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; @@ -133,7 +134,7 @@ export function ThreadWorkLog(props: { props.onToggleRow(row.id); } }} - onLongPress={() => props.onCopyRow(row.id, row.copyText)} + onLongPress={() => props.onCopyRow(row.id, row.getCopyText())} style={({ pressed }) => ({ backgroundColor: pressed ? pressedBackground : "transparent", })} @@ -204,7 +205,7 @@ export function ThreadWorkLog(props: { - {expanded && row.fullDetail ? ( + {fullDetail ? ( - {row.fullDetail} + {fullDetail} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 40d424ef8ed..61ade87cf15 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -162,20 +162,22 @@ describe("buildThreadFeed", () => { return; } - expect(group.activities).toEqual([ - { - id: "tool-completed", - createdAt: "2026-04-01T00:00:02.000Z", - turnId: "turn-1", - summary: "Run tests", - detail: "bun run test", - fullDetail: "/bin/zsh -lc 'bun run test'", - copyText: "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'", - icon: "command", - toolLike: true, - status: "success", - }, - ]); + expect(group.activities).toHaveLength(1); + expect(group.activities[0]).toMatchObject({ + id: "tool-completed", + createdAt: "2026-04-01T00:00:02.000Z", + turnId: "turn-1", + summary: "Run tests", + detail: "bun run test", + canExpand: true, + icon: "command", + toolLike: true, + status: "success", + }); + expect(group.activities[0]?.getFullDetail()).toBe("/bin/zsh -lc 'bun run test'"); + expect(group.activities[0]?.getCopyText()).toBe( + "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'", + ); }); it("keeps MCP inputs available to expanded mobile work rows", () => { @@ -224,8 +226,55 @@ describe("buildThreadFeed", () => { } expect(group.activities[0]?.icon).toBe("wrench"); - expect(group.activities[0]?.fullDetail).toContain('"query": "work log"'); - expect(group.activities[0]?.fullDetail).toContain("repository.search"); + expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"'); + expect(group.activities[0]?.getFullDetail()).toContain("repository.search"); + }); + + it("defers large tool output expansion until a work row is opened or copied", () => { + let serializedToolOutputs = 0; + const activities = Array.from({ length: 5_000 }, (_, index) => + makeActivity({ + id: EventId.make(`large-tool-${index}`), + kind: "tool.completed", + tone: "tool", + summary: `Tool ${index}`, + createdAt: new Date(Date.UTC(2026, 3, 1, 0, 0, index)).toISOString(), + payload: { + title: `Tool ${index}`, + itemType: "mcp_tool_call", + status: "completed", + data: { + item: { + toJSON: () => { + serializedToolOutputs += 1; + return { output: "x".repeat(32_768) }; + }, + }, + }, + }, + }), + ); + const thread = makeThread({ + id: ThreadId.make("thread-large-tools"), + projectId: ProjectId.make("project-1"), + title: "Large tools", + activities, + }); + + const feed = buildThreadFeed(thread); + expect(serializedToolOutputs).toBe(0); + + const group = feed[0]; + expect(group).toMatchObject({ type: "activity-group" }); + if (!group || group.type !== "activity-group") { + return; + } + + expect(group.activities).toHaveLength(5_000); + expect(group.activities[0]?.getFullDetail()).toContain('"output"'); + expect(serializedToolOutputs).toBe(1); + expect(group.activities[0]?.getCopyText()).toContain('"output"'); + expect(serializedToolOutputs).toBe(1); }); it("folds settled turn work while leaving the terminal answer visible", () => { @@ -440,8 +489,9 @@ describe("buildThreadFeed", () => { turnId: null, summary: `Tool ${id}`, detail: null, - fullDetail: null, - copyText: id, + canExpand: false, + getFullDetail: () => null, + getCopyText: () => id, icon: "command", toolLike: true, status, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 6278247dc69..0c79217502d 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -36,8 +36,9 @@ export interface ThreadFeedActivity { readonly turnId: TurnId | null; readonly summary: string; readonly detail: string | null; - readonly fullDetail: string | null; - readonly copyText: string; + readonly canExpand: boolean; + readonly getFullDetail: () => string | null; + readonly getCopyText: () => string; readonly icon: | "agent" | "alert" @@ -554,6 +555,27 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { return blocks.length > 0 ? blocks.join("\n\n") : null; } +function workEntryHasExpandedBody(entry: WorkLogEntry): boolean { + return ( + (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) || + Boolean((entry.rawCommand ?? entry.command)?.trim()) || + Boolean(entry.detail?.trim()) || + (entry.changedFiles?.some((path) => path.trim().length > 0) ?? false) + ); +} + +function memoizeValue(build: () => T): () => T { + let value: T; + let initialized = false; + return () => { + if (!initialized) { + value = build(); + initialized = true; + } + return value; + }; +} + function workEntryPreview( workEntry: Pick, ): string | null { @@ -1353,7 +1375,14 @@ export function buildThreadFeed( .map((entry) => { const summary = workEntryHeading(entry); const detail = workEntryPreview(entry); - const fullDetail = buildWorkEntryExpandedBody(entry); + const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); + const getCopyText = memoizeValue(() => + [summary, detail, getFullDetail()] + .filter((value, index, values): value is string => { + return Boolean(value) && values.indexOf(value) === index; + }) + .join("\n"), + ); return { type: "activity", id: entry.id, @@ -1365,13 +1394,10 @@ export function buildThreadFeed( turnId: entry.turnId, summary, detail, - fullDetail, + canExpand: workEntryHasExpandedBody(entry), + getFullDetail, + getCopyText, icon: workEntryIcon(entry), - copyText: [summary, detail, fullDetail] - .filter((value, index, values): value is string => { - return Boolean(value) && values.indexOf(value) === index; - }) - .join("\n"), toolLike: workLogEntryIsToolLike(entry), status: workEntryStatus(entry), }, diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 335e0685197..8432f49695a 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -8,9 +8,13 @@ import * as ServerConfig from "../config.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import { resolveSessionCookieName } from "./utils.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +/** Pinned so dev-mode cookie tests can assert the port-scoped name. */ +const TEST_SERVER_PORT = 13_773; + const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( ServerConfig.ServerConfig, @@ -19,6 +23,12 @@ const makeServerConfigLayer = (overrides?: Partial while every request still sent t3_session_13773, + // and the tests would fail for a reason unrelated to what they assert. + port: TEST_SERVER_PORT, } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" }))); @@ -35,7 +45,11 @@ const makeCookieRequest = ( ): Parameters[0] => ({ cookies: { - t3_session: sessionToken, + // Derived, not hardcoded: the name is port-scoped so concurrent servers + // on one hostname don't share a cookie. Mode and devUrl mirror + // ServerConfig.layerTest, so this resolves to whatever the server reads. + [resolveSessionCookieName({ mode: "web", port: TEST_SERVER_PORT, devUrl: undefined })]: + sessionToken, }, headers: {}, }) as unknown as Parameters< diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index dd53a83ca95..eb056342140 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -438,6 +438,7 @@ export class EnvironmentAuth extends Context.Service< readonly scopes?: ReadonlyArray; readonly subject?: string; readonly proofKeyThumbprint?: string; + readonly purpose?: "startup"; }) => Effect.Effect; readonly issuePairingCredential: ( input?: AuthCreatePairingCredentialInput, @@ -746,11 +747,13 @@ export const make = Effect.gen(function* () { readonly scopes: ReadonlyArray; readonly subject: string; readonly label?: string; + readonly purpose?: "startup"; }) => createPairingLink({ scopes: input.scopes, subject: input.subject, ...(input.label ? { label: input.label } : {}), + ...(input.purpose ? { purpose: input.purpose } : {}), }).pipe( Effect.map( (issued) => @@ -774,6 +777,7 @@ export const make = Effect.gen(function* () { ...(input?.ttl ? { ttl: input.ttl } : {}), ...(input?.label ? { label: input.label } : {}), ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), + ...(input?.purpose ? { purpose: input.purpose } : {}), }); return { id: issued.id, @@ -872,6 +876,7 @@ export const make = Effect.gen(function* () { issuePairingCredentialForSubject({ scopes: AuthAdministrativeScopes, subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, + purpose: "startup", }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 95269fb6c37..0e21ef19c90 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -34,6 +34,9 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("desktop-managed-local"); expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); + // Packaged desktop has no devUrl, but still needs the port scope: it + // scans upward from 3773 for a free port and binds 127.0.0.1, so a second + // instance shares this one's hostname on a different port. expect(descriptor.sessionCookieName).toBe("t3_session_3773"); }).pipe( Effect.provide( @@ -45,6 +48,22 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { ), ); + it.effect("keeps desktop cookies port-scoped on the port a second instance lands on", () => + Effect.gen(function* () { + const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.sessionCookieName).toBe("t3_session_3774"); + }).pipe( + Effect.provide( + makeEnvironmentAuthPolicyLayer({ + mode: "desktop", + port: 3774, + }), + ), + ), + ); + it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () => Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; @@ -75,6 +94,24 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { makeEnvironmentAuthPolicyLayer({ mode: "web", host: "127.0.0.1", + port: 13773, + }), + ), + ), + ); + + it.effect("scopes web session cookies by port only in development", () => + Effect.gen(function* () { + const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.sessionCookieName).toBe("t3_session_13773"); + }).pipe( + Effect.provide( + makeEnvironmentAuthPolicyLayer({ + mode: "web", + port: 13773, + devUrl: new URL("http://127.0.0.1:5733"), }), ), ), diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 7ffef0ff0a5..28e41576769 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -41,6 +41,7 @@ export const make = Effect.gen(function* () { sessionCookieName: resolveSessionCookieName({ mode: config.mode, port: config.port, + devUrl: config.devUrl, }), }; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 588d5e3775f..057a257ba66 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -202,6 +202,11 @@ export class PairingGrantStore extends Context.Service< readonly subject?: string; readonly label?: string; readonly proofKeyThumbprint?: string; + /** + * "startup" marks the credential the server mints for itself at boot, + * which gets the long dev TTL when a dev URL is configured. + */ + readonly purpose?: "startup"; }) => Effect.Effect; readonly listActive: () => Effect.Effect< ReadonlyArray, @@ -243,6 +248,15 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); // window can still recover by re-bootstrapping rather than locking // the user out of the backend. const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24); +// A dev server's startup token is read off a log by whoever (or whatever) is +// driving the session, often minutes later — after a `node --watch` restart, a +// detour into another task, or a hand-off to the person actually doing the +// testing. Five minutes turns that into a restart-the-server loop for no +// security benefit: the token only unlocks a local dev backend, and its holder +// could read the log anyway. Same reasoning (and duration) as the desktop +// bootstrap grant above. Only applies when a dev URL is configured; user-issued +// pairing links and real servers keep the 5-minute default. +const DEV_STARTUP_TTL_HOURS = Duration.hours(24); const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = @@ -371,7 +385,10 @@ export const make = Effect.gen(function* () { ), ); const credential = yield* generatePairingToken; - const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; + const isDevStartupToken = config.devUrl !== undefined && input?.purpose === "startup"; + const ttl = + input?.ttl ?? + (isDevStartupToken ? DEV_STARTUP_TTL_HOURS : DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES); const now = yield* DateTime.now; const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); const issued: IssuedBootstrapCredential = { diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 12ecb7dba4d..efa811302dc 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -470,6 +470,7 @@ export const make = Effect.gen(function* () { const cookieName = resolveSessionCookieName({ mode: serverConfig.mode, port: serverConfig.port, + devUrl: serverConfig.devUrl, }); const emitUpsert = (clientSession: AuthClientSession) => diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 39f04988ac5..81ef9bffc49 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -10,15 +10,29 @@ import * as Result from "effect/Result"; const SESSION_COOKIE_NAME = "t3_session"; +/** + * Cookies are scoped by host but *not* by port, so any two servers that can be + * live on one hostname at once need separate names — otherwise the second + * clobbers the first's session and both sides see "Invalid session token + * signature" until someone clears cookies by hand. + * + * Two populations qualify, for the same reason but from different causes: + * + * - **Dev servers** (`devUrl` set), which run several at a time across worktrees. + * - **Desktop**, which scans upward from 3773 for a free port and binds + * 127.0.0.1, so a second instance lands on a different port and the same host. + * + * Hosted deployments keep the stable production name: their public port can + * change between releases, and scoping it would log every user out. + */ export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; + readonly devUrl: URL | undefined; }): string { - if (input.mode !== "desktop") { - return SESSION_COOKIE_NAME; - } - - return `${SESSION_COOKIE_NAME}_${input.port}`; + return input.devUrl === undefined && input.mode !== "desktop" + ? SESSION_COOKIE_NAME + : `${SESSION_COOKIE_NAME}_${input.port}`; } export function base64UrlEncode(input: string | Uint8Array): string { diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index f310b0fdbc4..d3e6263455c 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -85,6 +85,7 @@ const makeCliTestServerConfig = (baseDir: string) => ...derivedPaths, staticDir: undefined, devUrl: undefined, + devAllowedOrigins: [], noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index a836b004ef9..8e630ba20c1 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -49,6 +49,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + devAllowedOrigins: [], } as const; const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { const fs = yield* FileSystem.FileSystem; @@ -96,6 +97,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_HOST: "0.0.0.0", T3CODE_HOME: baseDir, VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + T3CODE_DEV_ALLOWED_ORIGINS: + "https://host.example.ts.net, https://phone.example.ts.net ", T3CODE_NO_BROWSER: "true", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "true", @@ -118,6 +121,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { host: "0.0.0.0", staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), + devAllowedOrigins: ["https://host.example.ts.net", "https://phone.example.ts.net"], noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 4574331daf1..99ba05d5b5e 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -115,6 +115,15 @@ const EnvServerConfig = Config.all({ ), t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), + devAllowedOrigins: Config.string("T3CODE_DEV_ALLOWED_ORIGINS").pipe( + Config.withDefault(""), + Config.map((value) => + value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ), + ), noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -381,6 +390,7 @@ export const resolveServerConfig = ( host, staticDir, devUrl, + devAllowedOrigins: env.devAllowedOrigins, noBrowser, startupPresentation, desktopBootstrapToken, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index bf91a236dda..45a56294fde 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -76,6 +76,7 @@ export class ServerConfig extends Context.Service< readonly baseDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; + readonly devAllowedOrigins: ReadonlyArray; readonly noBrowser: boolean; readonly startupPresentation: StartupPresentation; readonly desktopBootstrapToken: string | undefined; @@ -194,6 +195,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( desktopBootstrapToken: undefined, staticDir: undefined, devUrl, + devAllowedOrigins: [], noBrowser: false, startupPresentation: "browser", }); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index cd0036aece0..6a4d8dd83c1 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -45,6 +45,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { desktopBootstrapToken: undefined, staticDir: undefined, devUrl: undefined, + devAllowedOrigins: [], noBrowser: false, startupPresentation: "browser", } satisfies ServerConfig.ServerConfig["Service"]; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index c6ae6b3010a..6d9fd96707c 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -5,6 +5,7 @@ import { AuthOrchestrationReadScope, EnvironmentHttpApi, } from "@t3tools/contracts"; +import { isDevProxiedPath } from "@t3tools/shared/devProxy"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -50,9 +51,17 @@ export const browserApiCorsLayer = Layer.unwrap( const devOrigin = config.devUrl?.origin; // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin. + // + // T3CODE_DEV_ALLOWED_ORIGINS covers dev servers reached from a second + // origin — a tailnet name, a LAN IP, a phone. Browser dev normally proxies + // through Vite and is same-origin (no preflight at all), so this is a + // safety net for the desktop renderer and any direct-to-backend caller. return HttpRouter.cors({ ...(devOrigin - ? { allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS], credentials: true } + ? { + allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins], + credentials: true, + } : {}), allowedMethods: browserApiCorsAllowedMethods, allowedHeaders: browserApiCorsAllowedHeaders, @@ -249,6 +258,10 @@ export const staticAndDevRouteLayer = Layer.unwrap( return HttpServerResponse.text("Bad Request", { status: 400 }); } + if (config.devUrl && isDevProxiedPath(url.value.pathname)) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + if (config.devUrl && isLoopbackHostname(url.value.hostname)) { return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { status: 302, @@ -277,6 +290,10 @@ export const staticAndDevRouteLayer = Layer.unwrap( return HttpServerResponse.text("Bad Request", { status: 400 }); } + if (config.devUrl && isDevProxiedPath(url.value.pathname)) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + if (config.devUrl && isLoopbackHostname(url.value.hostname)) { return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { status: 302, diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts new file mode 100644 index 00000000000..5fd0cc8984b --- /dev/null +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -0,0 +1,227 @@ +import type { + OrchestrationEvent, + OrchestrationThreadActivity, + OrchestrationThreadDetailSnapshot, +} from "@t3tools/contracts"; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asTrimmedString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function pushChangedFile(target: string[], seen: Set, value: unknown): void { + const normalized = asTrimmedString(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + target.push(normalized); +} + +function collectChangedFiles( + value: unknown, + target: string[], + seen: Set, + depth: number, +): void { + if (depth > 4 || target.length >= 12) { + return; + } + if (Array.isArray(value)) { + for (const entry of value) { + collectChangedFiles(entry, target, seen, depth + 1); + if (target.length >= 12) { + return; + } + } + return; + } + + const record = asRecord(value); + if (!record) { + return; + } + + pushChangedFile(target, seen, record.path); + pushChangedFile(target, seen, record.filePath); + pushChangedFile(target, seen, record.relativePath); + pushChangedFile(target, seen, record.filename); + pushChangedFile(target, seen, record.newPath); + pushChangedFile(target, seen, record.oldPath); + + for (const nestedKey of [ + "item", + "result", + "input", + "data", + "changes", + "files", + "edits", + "patch", + "patches", + "operations", + ]) { + if (!(nestedKey in record)) { + continue; + } + collectChangedFiles(record[nestedKey], target, seen, depth + 1); + if (target.length >= 12) { + return; + } + } +} + +function projectCommandData(data: Record): Record | undefined { + const item = asRecord(data.item); + if (!item) { + return undefined; + } + + const projectedItem: Record = {}; + if ("command" in item) { + projectedItem.command = item.command; + } + + const input = asRecord(item.input); + if (input && "command" in input) { + projectedItem.input = { command: input.command }; + } + + const result = asRecord(item.result); + if (result && "command" in result) { + projectedItem.result = { command: result.command }; + } + + return Object.keys(projectedItem).length > 0 ? projectedItem : undefined; +} + +function summarizeToolTextOutput(value: string): string | null { + const lines: string[] = []; + for (const rawLine of value.split(/\r?\n/u)) { + const line = rawLine.replace(/\s+/g, " ").trim(); + if (line.length > 0) { + lines.push(line); + } + } + + const firstLine = lines.find((line) => line !== "```"); + if (firstLine) { + return firstLine.length <= 84 ? firstLine : `${firstLine.slice(0, 83).trimEnd()}…`; + } + if (lines.length > 1) { + return `${lines.length.toLocaleString()} lines`; + } + return null; +} + +function projectRawOutput(value: unknown): Record | undefined { + const rawOutput = asRecord(value); + if (!rawOutput) { + return undefined; + } + + if (typeof rawOutput.totalFiles === "number" && Number.isFinite(rawOutput.totalFiles)) { + return { + totalFiles: rawOutput.totalFiles, + ...(rawOutput.truncated === true ? { truncated: true } : {}), + }; + } + + const content = asTrimmedString(rawOutput.content); + if (content) { + const summary = summarizeToolTextOutput(content); + return summary ? { content: summary } : undefined; + } + + const stdout = asTrimmedString(rawOutput.stdout); + if (stdout) { + const summary = summarizeToolTextOutput(stdout); + return summary ? { content: summary } : undefined; + } + + return undefined; +} + +/** + * Removes activity payload fields that no current client reads while retaining + * the full payload in persistence and the event store. + */ +export function projectActivityPayload( + activity: OrchestrationThreadActivity, +): OrchestrationThreadActivity { + const payload = asRecord(activity.payload); + const data = asRecord(payload?.data); + if (!payload || !data || payload.itemType === "mcp_tool_call") { + return activity; + } + + const projectedData: Record = {}; + const item = projectCommandData(data); + if (item) { + projectedData.item = item; + } + if ("command" in data) { + projectedData.command = data.command; + } + + const changedFiles: string[] = []; + collectChangedFiles(data, changedFiles, new Set(), 0); + if (changedFiles.length > 0) { + // Both clients discover file names by walking objects with path-like keys. + projectedData.files = changedFiles.map((path) => ({ path })); + } + + if ("toolCallId" in data) { + projectedData.toolCallId = data.toolCallId; + } + if ("kind" in data) { + projectedData.kind = data.kind; + } + + const rawOutput = projectRawOutput(data.rawOutput); + if (rawOutput) { + projectedData.rawOutput = rawOutput; + } + + return { + ...activity, + payload: { + ...payload, + data: projectedData, + }, + }; +} + +export function projectThreadDetailSnapshot( + snapshot: OrchestrationThreadDetailSnapshot, +): OrchestrationThreadDetailSnapshot { + return { + ...snapshot, + thread: { + ...snapshot.thread, + activities: snapshot.thread.activities.map(projectActivityPayload), + }, + }; +} + +export function projectActivityEvent(event: OrchestrationEvent): OrchestrationEvent { + if (event.type !== "thread.activity-appended") { + return event; + } + return { + ...event, + payload: { + ...event.payload, + activity: projectActivityPayload(event.payload.activity), + }, + }; +} diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 016c3d508ec..659665e47b5 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import { projectThreadDetailSnapshot } from "./ActivityPayloadProjection.ts"; import { normalizeDispatchCommand } from "./Normalizer.ts"; import { annotateEnvironmentRequest, @@ -69,7 +70,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( if (Option.isNone(snapshot)) { return yield* failEnvironmentNotFound("thread_not_found"); } - return snapshot.value; + return projectThreadDetailSnapshot(snapshot.value); }), ) .handle( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 9a7278ae45d..a5843544fd4 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -530,6 +530,29 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("preserves xhigh effort for Claude Opus 5", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-5", + [{ id: "effort", value: "xhigh" }], + ), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "xhigh"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 2ea8aa7b4e1..96202ecd952 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -51,6 +51,7 @@ const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, } as const; +const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; @@ -87,6 +88,42 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], }), }, + { + slug: "claude-opus-5", + name: "Claude Opus 5", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + { value: "ultracode", label: "Ultracode" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + // Claude Code selects the 1M variant explicitly (`claude-opus-5[1m]`). + options: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], + }), + ], + }), + }, { slug: "claude-opus-4-8", name: "Claude Opus 4.8", @@ -272,6 +309,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; +function supportsClaudeOpus5(version: string | null | undefined): boolean { + return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; +} + function supportsClaudeFable5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; } @@ -288,6 +329,9 @@ function getBuiltInClaudeModelsForVersion( version: string | null | undefined, ): ReadonlyArray { return BUILT_IN_MODELS.filter((model) => { + if (model.slug === "claude-opus-5") { + return supportsClaudeOpus5(version); + } if (model.slug === "claude-fable-5") { return supportsClaudeFable5(version); } @@ -301,6 +345,11 @@ function getBuiltInClaudeModelsForVersion( }); } +function formatClaudeOpus5UpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; +} + function formatClaudeFable5UpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; return `Claude Code ${versionLabel} is too old for Claude Fable 5. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_VERSION} or newer to access it.`; @@ -360,6 +409,7 @@ export function normalizeClaudeCliEffort( if ( effort === "xhigh" && model !== "claude-fable-5" && + model !== "claude-opus-5" && model !== "claude-opus-4-8" && model !== "claude-sonnet-5" ) { @@ -837,13 +887,15 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeFable5(parsedVersion) + const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) ? undefined - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + : supportsClaudeFable5(parsedVersion) + ? formatClaudeOpus5UpgradeMessage(parsedVersion) + : supportsClaudeOpus48(parsedVersion) + ? formatClaudeFable5UpgradeMessage(parsedVersion) + : supportsClaudeOpus47(parsedVersion) + ? formatClaudeOpus48UpgradeMessage(parsedVersion) + : formatClaudeOpus47UpgradeMessage(parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5efbb6f1c14..3f00d3cc662 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1842,6 +1842,62 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("includes Claude Opus 5 on supported Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + const opus5 = status.models.find((model) => model.slug === "claude-opus-5"); + assert.strictEqual(opus5?.name, "Claude Opus 5"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.219\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides Claude Opus 5 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-5"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.218 is too old for Claude Opus 5. Upgrade to v2.1.219 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.218\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("includes Claude Fable 5 on supported Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 9670703aab2..23db971357f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -383,6 +383,7 @@ const buildAppUnderTest = (options?: { ...derivedPaths, staticDir: undefined, devUrl, + devAllowedOrigins: [], noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: defaultDesktopBootstrapToken, @@ -1332,6 +1333,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "bearer-access-token", "dpop-access-token", ]); + // Desktop, so port-scoped: instances scan for a free port and share + // 127.0.0.1, and cookies are not scoped by port. assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -3250,6 +3253,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("allows configured development origins through ServerConfig", () => + Effect.gen(function* () { + const tailnetOrigin = "https://host.example.ts.net"; + yield* buildAppUnderTest({ + config: { + devUrl: new URL(crossOriginClientOrigin), + devAllowedOrigins: [tailnetOrigin], + }, + }); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + method: "OPTIONS", + headers: { + origin: tailnetOrigin, + "access-control-request-method": "GET", + "access-control-request-headers": "content-type", + }, + }); + + assert.equal(response.status, 204); + assertBrowserApiCorsPreflightHeaders(response.headers, { + origin: tailnetOrigin, + credentials: true, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + for (const desktopOrigin of ["t3code://app", "t3code-dev://app"]) { it.effect(`allows credentialed preflights from ${desktopOrigin} in development`, () => Effect.gen(function* () { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76e..b8f4b07124d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -68,6 +68,10 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; +import { + projectActivityEvent, + projectThreadDetailSnapshot, +} from "./orchestration/ActivityPayloadProjection.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -1213,6 +1217,7 @@ const makeWsRpcLayer = ( ).pipe( Effect.map((events) => Array.from(events)), Effect.flatMap(enrichOrchestrationEvents), + Effect.map((events) => events.map(projectActivityEvent)), Effect.mapError( (cause) => new OrchestrationReplayEventsError({ @@ -1360,7 +1365,7 @@ const makeWsRpcLayer = ( Stream.filter(isThisThreadDetailEvent), Stream.map((event) => ({ kind: "event" as const, - event, + event: projectActivityEvent(event), })), ); @@ -1395,7 +1400,10 @@ const makeWsRpcLayer = ( .readEvents(afterSequence, Number.MAX_SAFE_INTEGER) .pipe( Stream.filter(isThisThreadDetailEvent), - Stream.map((event) => ({ kind: "event" as const, event })), + Stream.map((event) => ({ + kind: "event" as const, + event: projectActivityEvent(event), + })), Stream.mapError( (cause) => new OrchestrationGetSnapshotError({ @@ -1447,7 +1455,7 @@ const makeWsRpcLayer = ( return Stream.concat( Stream.make({ kind: "snapshot" as const, - snapshot: snapshot.value, + snapshot: projectThreadDetailSnapshot(snapshot.value), }), afterSnapshot, ); diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts new file mode 100644 index 00000000000..6552dfb140c --- /dev/null +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -0,0 +1,233 @@ +import { + EventId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationEvent, + type OrchestrationThread, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildThreadFeed, type ThreadFeedActivity } from "../../mobile/src/lib/threadActivity.ts"; +import { deriveWorkLogEntries } from "../../web/src/session-logic.ts"; +import { + projectActivityEvent, + projectActivityPayload, + projectThreadDetailSnapshot, +} from "../src/orchestration/ActivityPayloadProjection.ts"; + +function makeActivity( + id: string, + itemType: string, + data: Record, +): OrchestrationThreadActivity { + return { + id: EventId.make(id), + tone: "tool", + kind: "tool.completed", + summary: `Completed ${itemType}`, + payload: { + itemType, + title: itemType, + detail: `${itemType} detail`, + status: "completed", + requestKind: "command", + data, + }, + turnId: TurnId.make(`turn-${id}`), + createdAt: "2026-07-27T00:00:00.000Z", + }; +} + +function makeThread(activities: ReadonlyArray): OrchestrationThread { + return { + id: ThreadId.make("thread-projection"), + projectId: ProjectId.make("project-projection"), + title: "Activity projection", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-07-27T00:00:00.000Z", + updatedAt: "2026-07-27T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities, + checkpoints: [], + session: null, + }; +} + +const fixtures = [ + makeActivity("command", "command_execution", { + item: { + command: ["bash", "-lc", "pnpm test"], + input: { command: "fallback input", ignored: "input bulk" }, + result: { command: "fallback result", aggregatedOutput: "x".repeat(10_000) }, + commandActions: [{ type: "unknown", output: "y".repeat(5_000) }], + }, + command: "fallback data", + kind: "execute", + toolCallId: "tool-command", + rawOutput: { + content: "\n```\nfirst useful line\nsecond line", + stdout: "unused stdout", + ignored: "raw bulk", + }, + ignored: "top-level bulk", + }), + makeActivity("file-change", "file_change", { + item: { + changes: [ + { oldPath: "src/old.ts", newPath: "src/new.ts", patch: "large patch".repeat(1_000) }, + { filePath: "src/second.ts" }, + ], + }, + ignored: "top-level bulk", + }), + makeActivity("dynamic", "dynamic_tool_call", { + toolCallId: "tool-dynamic", + rawOutput: { + stdout: "dynamic summary\nlong output".repeat(1_000), + }, + ignored: "top-level bulk", + }), + makeActivity("collab", "collab_agent_tool_call", { + kind: "delegate", + rawOutput: { + content: "``` \n```", + stdout: "must not be used when content is present", + }, + ignored: "top-level bulk", + }), + makeActivity("mcp", "mcp_tool_call", { + item: { + server: "repository", + tool: "search", + arguments: { query: "activity projection" }, + aggregatedOutput: "mcp payload remains available", + }, + ignored: "MCP data is rendered verbatim", + }), + makeActivity("search", "web_search", { + rawOutput: { + totalFiles: 42, + truncated: true, + content: "ignored because totalFiles wins", + }, + ignored: "top-level bulk", + }), + makeActivity("image", "image_view", { + ignored: "top-level bulk", + }), +] satisfies ReadonlyArray; + +describe("projectActivityPayload", () => { + function comparableActivity(activity: ThreadFeedActivity) { + return { + ...activity, + fullDetail: activity.getFullDetail(), + copyText: activity.getCopyText(), + getFullDetail: undefined, + getCopyText: undefined, + }; + } + + function comparableThreadFeed(activities: ReadonlyArray) { + return buildThreadFeed(makeThread(activities)).map((entry) => + entry.type === "activity-group" + ? { + ...entry, + activities: entry.activities.map(comparableActivity), + } + : entry, + ); + } + + it("drops unread bulk while retaining command, file, tool, and summary inputs", () => { + const projected = projectActivityPayload(fixtures[0]!); + expect(projected.payload).toEqual({ + itemType: "command_execution", + title: "command_execution", + detail: "command_execution detail", + status: "completed", + requestKind: "command", + data: { + item: { + command: ["bash", "-lc", "pnpm test"], + input: { command: "fallback input" }, + result: { command: "fallback result" }, + }, + command: "fallback data", + toolCallId: "tool-command", + kind: "execute", + rawOutput: { content: "first useful line" }, + }, + }); + + expect(projectActivityPayload(fixtures[1]!).payload).toMatchObject({ + data: { + files: [{ path: "src/new.ts" }, { path: "src/old.ts" }, { path: "src/second.ts" }], + }, + }); + }); + + it("passes MCP tool data through unchanged", () => { + expect(projectActivityPayload(fixtures[4]!)).toBe(fixtures[4]); + }); + + it("keeps current web and mobile derived output identical for every tool item type", () => { + for (const activity of fixtures) { + const projected = projectActivityPayload(activity); + expect(deriveWorkLogEntries([projected])).toEqual(deriveWorkLogEntries([activity])); + expect(comparableThreadFeed([projected])).toEqual(comparableThreadFeed([activity])); + } + }); + + it("projects snapshot and event transports without mutating their sources", () => { + const activity = fixtures[0]!; + const thread = makeThread([activity]); + const snapshot = { snapshotSequence: 7, thread }; + const projectedSnapshot = projectThreadDetailSnapshot(snapshot); + + expect(projectedSnapshot.thread.activities[0]).not.toBe(activity); + expect(snapshot.thread.activities[0]).toBe(activity); + + const event = { + sequence: 8, + eventId: EventId.make("event-activity"), + aggregateKind: "thread", + aggregateId: thread.id, + occurredAt: "2026-07-27T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { + threadId: thread.id, + activity, + }, + } satisfies Extract; + + const projectedEvent = projectActivityEvent(event); + expect(projectedEvent).not.toBe(event); + expect( + projectedEvent.type === "thread.activity-appended" + ? projectedEvent.payload.activity + : undefined, + ).toEqual(projectActivityPayload(activity)); + expect(event.payload.activity).toBe(activity); + }); +}); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 04d86f81eeb..3a30fbb2c7e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -11,6 +11,8 @@ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, + ChevronsDownUpIcon, + ChevronsUpDownIcon, Columns2Icon, PilcrowIcon, Rows3Icon, @@ -32,6 +34,7 @@ import { resolveDiffThemeName, resolveFileDiffPath, } from "../lib/diffRendering"; +import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useProject, useThread } from "../state/entities"; import { resolveThreadRouteRef } from "../threadRoutes"; @@ -39,6 +42,7 @@ import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; +import { Button } from "./ui/button"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; import { @@ -446,6 +450,8 @@ export default function DiffPanel({ }), [collapsedDiffFileKeys, renderableFiles], ); + const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]); + const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); useEffect(() => { if (!selectedFilePath) return; @@ -496,6 +502,18 @@ export default function DiffPanel({ [collapseScopeKey], ); + const toggleDiffFileCollapse = useCallback(() => { + setCollapsedDiffFiles((current) => { + const currentKeys = + current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS; + + return { + scopeKey: collapseScopeKey, + fileKeys: toggleAllDiffFiles(diffFileKeys, currentKeys), + }; + }); + }, [collapseScopeKey, diffFileKeys]); + const selectTurn = (turnId: TurnId) => { if (!routeThreadRef) return; useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId); @@ -695,6 +713,30 @@ export default function DiffPanel({ )}
+ {codeViewFiles.length > 0 && ( + + + } + > + {allDiffFilesCollapsed ? ( + + ) : ( + + )} + + + {allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"} + + + )}
{thread.title}
@@ -498,6 +505,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; // Report the PR state up: the parent partitions rows with effectiveSettled, // and a merged/closed PR auto-settles a thread — data only rows have. const prState = pr?.state ?? null; @@ -647,7 +655,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // content; surface is reserved for interaction (hover, multi-select, route). const rowSurfaceClassName = cn( "group/v2-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", - variant === "card" && "backdrop-blur-[16px]", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" : isSelected @@ -714,7 +721,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { variant === "slim" && variantAction === "unsettle" ? props.isActive ? "text-muted-foreground/70" - : "text-muted-foreground/35 transition-colors group-hover/v2-row:text-muted-foreground/65" + : cn("text-muted-foreground/35 transition-colors", settledPrHoverClass) : prStatus.colorClass, )} aria-label={prStatus.tooltip} diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 24ccc6ef33f..9fb4535f266 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,7 +1,11 @@ import type { VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; +import { + prStatusIndicator, + resolveThreadPr, + settledPrHoverColorClass, +} from "./ThreadStatusIndicators"; function status(overrides: Partial = {}): VcsStatusResult { return { @@ -70,4 +74,23 @@ describe("prStatusIndicator", () => { tooltipTitle: "PR branch", }); }); + + it("uses red for closed pull requests", () => { + const closedPr = status().pr; + if (!closedPr) throw new Error("Expected pull request fixture"); + + expect(prStatusIndicator({ ...closedPr, state: "closed" }, undefined)?.colorClass).toContain( + "text-red-600", + ); + }); +}); + +describe("settledPrHoverColorClass", () => { + it.each([ + ["open", "text-emerald-600"], + ["merged", "text-violet-600"], + ["closed", "text-red-600"], + ] as const)("restores the %s pull request color on row hover", (state, colorClass) => { + expect(settledPrHoverColorClass(state)).toContain(`group-hover/v2-row:${colorClass}`); + }); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 0c27e47cad6..bafeaf70974 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -42,6 +42,17 @@ export interface TerminalStatusIndicator { export type ThreadPr = VcsStatusResult["pr"]; +export function settledPrHoverColorClass(state: NonNullable["state"]): string { + switch (state) { + case "open": + return "group-hover/v2-row:text-emerald-600 dark:group-hover/v2-row:text-emerald-300/90"; + case "merged": + return "group-hover/v2-row:text-violet-600 dark:group-hover/v2-row:text-violet-300/90"; + case "closed": + return "group-hover/v2-row:text-red-600 dark:group-hover/v2-row:text-red-300/90"; + } +} + export function prStatusIndicator( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined, @@ -72,7 +83,7 @@ export function prStatusIndicator( if (pr.state === "closed") { return { label: `${presentation.shortName} closed`, - colorClass: "text-zinc-500 dark:text-zinc-400/80", + colorClass: "text-red-600 dark:text-red-300/90", tooltip, tooltipLead, tooltipTitle: pr.title, diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index db8b8ea8ce4..2121b789c54 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -434,6 +434,7 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("Show full message"); expect(markup).toContain('data-user-message-collapsible="false"'); + expect(markup).toContain("rounded-2xl bg-accent p-3"); }); it("renders inline terminal labels with the composer chip UI", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 0ea0a031b76..493522d4f49 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -895,7 +895,7 @@ function UserTimelineRow({ row }: { row: Extract -
+
{regularImages.length > 0 && (
{regularImages.map((image: NonNullable[number]) => ( diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index c62e0639db8..2ec4be70994 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -51,7 +51,7 @@ export const ModelListRow = memo(function ModelListRow(props: { contentClassName="flex w-full items-center gap-3" className={cn( "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2.5 transition-[background-color,box-shadow,color]", - "data-highlighted:bg-muted/56 data-selected:bg-foreground/[0.08] data-selected:text-foreground data-selected:ring-0", + "hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-highlighted:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-selected:bg-foreground/[0.08] data-selected:text-foreground data-selected:ring-0 [&[data-highlighted][data-selected]]:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))]", props.disabledReason && "data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent", )} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 8af23b530e5..bbdbd8bd9d9 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -521,7 +521,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
{/* Sidebar */} @@ -568,18 +568,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { handleModelSelect(slug, instanceId); }} > -
+
{/* Search bar */}
-
+
+ } value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index fe2b840ca8f..36a608888b2 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -97,7 +97,10 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { }, [props.instanceEntries, props.selectedInstanceId, showFavorites]); return ( -
+
+
handleSelect("favorites")} type="button" @@ -169,7 +172,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {