From 4c9c7d0bb65765232bbb41f83d3fd17ec5e3af66 Mon Sep 17 00:00:00 2001 From: "t3-code-bot[bot]" <269035359+t3-code-bot[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:07:37 +0000 Subject: [PATCH 01/19] chore(mobile): bump app version to 2.2.0 Co-authored-by: codex --- apps/mobile/app.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 6ea260eae4d9e..ea885f819984f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -226,7 +226,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.1.0", + version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.2.0", runtimeVersion: { // Skip costly fingerprinting on dev launches; preview and production OTAs require matching native projects. policy: runtimeVersionPolicy, From c677d5fc45e6d585e52bca03244a0870c588a555 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 02:22:53 -0700 Subject: [PATCH 02/19] fix(web): scroll-to-end button no longer jumps up and down (#26375) Co-authored-by: Claude Opus 5.5 --- .../chat/MessagesTimeline.browser.test.tsx | 23 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 6 +++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 2c7aa332756cb..085fa23b2c49d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -205,6 +205,29 @@ describe("MessagesTimeline messages", () => { expect(maxNestedCommits).toBeLessThan(20); }); + it("keeps reporting overflow while the composer inset changes", async () => { + setup(); + const props = baseProps(); + const overflowReports: boolean[] = []; + const replies = Array.from({ length: 40 }, (_, index) => + message(`Reply ${index}\n\n${"line\n\n".repeat(8)}`, { role: "assistant" }, `r-${index}`), + ); + const timeline = (inset: number) => ( + overflowReports.push(overflows)} + /> + ); + await using screen = await renderElement(timeline(110)); + await vi.waitFor(() => expect(overflowReports.at(-1)).toBe(true)); + overflowReports.length = 0; + // The resting composer resizes with overflow; a false report here flips it back. + await screen.rerender(timeline(148)); + expect(overflowReports).not.toContain(false); + }); + it("keeps the first and last message headings reachable from either end", async () => { setup(); const props = baseProps(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c7a38de8fbed6..b74f9edd595c9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1144,7 +1144,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ const canvas = useChatCanvas(); const registerTimeline = canvas?.registerTimeline; - const setTimelineList = (list: LegendListRef | null) => { + // Stable so the list attaches once per mount. A re-attach reports no overflow for a frame, + // which flips the resting composer, resizes it, and re-renders this list again. + const setTimelineList = useStableCallback((list: LegendListRef | null) => { listRef.current = list; const scrollNode: HTMLElement | null = list?.getScrollableNode() ?? null; registerTimeline?.(scrollNode); @@ -1190,7 +1192,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ listRef.current = null; registerTimeline?.(null); }; - }; + }); useThreadFindPositionReader({ threadKey: listIdentityKey, From 07f7f976097b56450364fe136aac9253f4f7d8e1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 02:45:20 -0700 Subject: [PATCH 03/19] ci(release): releases stop depending on npm and nodejs.org when nothing changed (#26362) Co-authored-by: Claude Opus 5.5 --- .github/actions/setup-ci-deps/action.yml | 20 +++++++++ .github/workflows/release-desktop.yml | 53 ++++++++++++++++++++---- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e97365e2f3ec5..e9b7ae70a36ad 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -70,6 +70,9 @@ runs: INSTALL_ARGS: ${{ inputs.install-args }} run: | echo "path=$(vp pm cache dir)" >> "$GITHUB_OUTPUT" + metadata_dir="${pnpm_config_cache_dir:-$RUNNER_TEMP/pnpm-metadata}" + echo "pnpm_config_cache_dir=$metadata_dir" >> "$GITHUB_ENV" + echo "verified=$metadata_dir/lockfile-verified.jsonl" >> "$GITHUB_OUTPUT" if [[ -z "$INSTALL_ARGS" ]]; then echo "namespace=full" >> "$GITHUB_OUTPUT" else @@ -94,6 +97,16 @@ runs: pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}- pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}- + # pnpm accepts an earlier pass for the same lockfile and policy at any age, so + # a cached record skips fetching registry metadata for every package. + - name: Restore lockfile verification + id: pnpm_verified + if: inputs.install == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.verified }} + key: pnpm-verified-v1-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + - name: Install dependencies if: inputs.install == 'true' shell: bash @@ -102,6 +115,13 @@ runs: # Unquoted so each filter is its own argument. run: vp install $INSTALL_ARGS + - name: Save lockfile verification + if: inputs.install == 'true' && steps.pnpm_verified.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.verified }} + key: ${{ steps.pnpm_verified.outputs.cache-primary-key }} + # A filtered install that started from the current full store adds nothing # worth a second copy of it. - name: Save pnpm store diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 3383bb0e80bb8..359070e5482fb 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -254,21 +254,60 @@ jobs: if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client + - name: Resolve target Node.js for the CLI executable + id: target_node + if: inputs.cli_archive && inputs.platform == 'win' + shell: bash + run: echo "version=$(node -p 'require("./package.json").engines.node')" >> "$GITHUB_OUTPUT" + + # The download only changes with engines.node, so a cached copy keeps + # releases off nodejs.org, which stalls on the runners some days. + - name: Restore target Node.js for the CLI executable + id: target_node_cache + if: inputs.cli_archive && inputs.platform == 'win' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ runner.temp }}/node-download + key: node-download-v${{ steps.target_node.outputs.version }}-win-${{ inputs.arch }} + # tsdown downloads the target Node with one fetch and no retry, and # nodejs.org drops that download often enough to fail releases. Seed - # tsdown's cache with a retried, checksum-verified copy instead. + # tsdown's cache with a retried, checksum-verified copy instead. A transfer + # under 100 KB/s for 30s counts as failed, so a stall retries. - name: Download target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true' + shell: bash + env: + VERSION: ${{ steps.target_node.outputs.version }} + run: | + set -euo pipefail + name="node-v$VERSION-win-${{ inputs.arch }}" + dir="$RUNNER_TEMP/node-download" + mkdir -p "$dir" + fetch=(curl -fsSL --retry 5 --retry-all-errors --connect-timeout 10 --speed-limit 102400 --speed-time 30) + "${fetch[@]}" -o "$dir/$name.zip" "https://nodejs.org/dist/v$VERSION/$name.zip" + "${fetch[@]}" -o "$dir/SHASUMS256.txt" "https://nodejs.org/dist/v$VERSION/SHASUMS256.txt" + (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) + + # Saved right away so a later failure in this job still keeps the download. + - name: Save target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ runner.temp }}/node-download + key: ${{ steps.target_node_cache.outputs.cache-primary-key }} + + - name: Install target Node.js for the CLI executable if: inputs.cli_archive && inputs.platform == 'win' shell: bash + env: + VERSION: ${{ steps.target_node.outputs.version }} run: | set -euo pipefail - version="$(node -p 'require("./package.json").engines.node')" - name="node-v$version-win-${{ inputs.arch }}" - dest="${XDG_CACHE_HOME:-$HOME/.cache}/tsdown/node/v$version/win-${{ inputs.arch }}" + name="node-v$VERSION-win-${{ inputs.arch }}" dir="$RUNNER_TEMP/node-download" - mkdir -p "$dir" "$dest" - curl -fsSL --retry 5 --retry-all-errors -o "$dir/$name.zip" "https://nodejs.org/dist/v$version/$name.zip" - curl -fsSL --retry 5 --retry-all-errors -o "$dir/SHASUMS256.txt" "https://nodejs.org/dist/v$version/SHASUMS256.txt" + dest="${XDG_CACHE_HOME:-$HOME/.cache}/tsdown/node/v$VERSION/win-${{ inputs.arch }}" + mkdir -p "$dest" (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) unzip -p "$dir/$name.zip" "$name/node.exe" > "$dest/node.exe" From 001d16e9699ad22863f065e5d9a3bd36305901fc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 02:59:59 -0700 Subject: [PATCH 04/19] feat(web): boring sidebar hides projects with no active threads (#26387) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/Sidebar.logic.test.ts | 17 +++++++++-------- apps/web/src/components/Sidebar.logic.ts | 7 ++++--- apps/web/src/components/Sidebar.tsx | 8 ++++++-- docs/user/thread-sidebar.md | 3 ++- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 93f3fb342fb9e..e4fa68a65201b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1368,7 +1368,7 @@ describe("sidebarFolderExpansionKeys", () => { }); describe("groupSidebarThreadsByProject", () => { - it("puts each thread in its project's folder and keeps empty projects", () => { + it("puts each thread in its project's folder and drops empty projects unless kept", () => { const remoteEnvironmentId = EnvironmentId.make("environment-remote"); const t3code = ProjectId.make("t3code"); const t3codeRemote = ProjectId.make("t3code-remote"); @@ -1392,21 +1392,22 @@ describe("groupSidebarThreadsByProject", () => { }, ]; - const folders = groupSidebarThreadsByProject(groups, [ + const threads = [ thread("a", localEnvironmentId, t3code), thread("orphan", localEnvironmentId, ProjectId.make("deleted")), thread("b", remoteEnvironmentId, t3codeRemote), - ]); - - expect( - folders.map((folder) => ({ + ]; + const summarize = (keepEmpty: boolean) => + groupSidebarThreadsByProject(groups, threads, keepEmpty).map((folder) => ({ projectKey: folder.group.projectKey, threads: folder.threads.map((entry) => entry.id), - })), - ).toEqual([ + })); + + expect(summarize(true)).toEqual([ { projectKey: "t3code", threads: ["a", "b"] }, { projectKey: "fleet", threads: [] }, ]); + expect(summarize(false)).toEqual([{ projectKey: "t3code", threads: ["a", "b"] }]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7f7705573673c..efd8244f8c2b2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -918,8 +918,8 @@ export function sidebarFolderExpansionKeys(group: { } /** - * Boring sidebar folders. Folders follow `groups` order and include empty - * projects so each one can start a thread. Threads keep their input order. + * Boring sidebar folders. Folders follow `groups` order and threads keep their + * input order. A project with no threads gets a folder only with `keepEmpty`. * A thread whose project is in no group has no folder and is left out. */ export function groupSidebarThreadsByProject< @@ -933,6 +933,7 @@ export function groupSidebarThreadsByProject< >( groups: readonly TGroup[], threads: readonly TThread[], + keepEmpty: boolean, ): Array<{ group: TGroup; threads: TThread[] }> { const folders = groups.map((group) => ({ group, threads: [] as TThread[] })); const folderByProject = new Map(); @@ -944,7 +945,7 @@ export function groupSidebarThreadsByProject< for (const thread of threads) { folderByProject.get(`${thread.environmentId}:${thread.projectId}`)?.threads.push(thread); } - return folders; + return keepEmpty ? folders : folders.filter((folder) => folder.threads.length > 0); } export type SidebarV2TopStatusKind = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6bda36fc85919..ddc7ad1e7bd29 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -399,6 +399,8 @@ function useSidebarThreads(input: { readonly routeRowThreadKey: string | null; // Set in boring mode: the project groups to fold active threads into, in order. readonly folderGroups: readonly SidebarProjectSnapshot[] | null; + // Keeps folders with no active threads, so a project the user filtered to can start one. + readonly keepEmptyFolders: boolean; }) { const { projectScopeKey, @@ -409,6 +411,7 @@ function useSidebarThreads(input: { delegatedUnderParent, routeRowThreadKey, folderGroups, + keepEmptyFolders: keepEmpty, } = input; // Folders already separate the work, so boring mode has no Working shelf. const workingShelfEnabled = @@ -495,7 +498,7 @@ function useSidebarThreads(input: { const projectFolders = folderGroups === null ? null - : groupSidebarThreadsByProject(folderGroups, activeThreads).map((folder) => { + : groupSidebarThreadsByProject(folderGroups, activeThreads, keepEmpty).map((folder) => { const collapsed = !resolveProjectExpanded( projectExpandedById, sidebarFolderExpansionKeys(folder.group), @@ -1271,6 +1274,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ? projectGroups : scopedProjectGroups : null, + keepEmptyFolders: scopedProjectGroups.length > 0, }); // Ranked across environments as the palette ranks them, so one @@ -1841,7 +1845,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); return { kind: "thread", key, section }; }); - // Boring mode keeps empty project folders, so each can start a thread. + // Boring mode keeps a project the user filtered to, so it can start a thread. if ( !hasProjectFolders && pinnedThreads.length + diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index b5373749a4956..ce16b9df0374b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -142,7 +142,8 @@ cannot drag or move threads within it. Your saved order returns when you turn it ### Group threads by project (beta) Turn on **Settings → Labs → Boring sidebar (group by project)** on web and desktop to put active -threads in a folder for each project. Click a folder to open or close it. A closed folder shows how +threads in a folder for each project. A project with no active threads has no folder until you +pick it in the project filter. Click a folder to open or close it. A closed folder shows how many threads it holds, and a dot when one of them needs you. Use the pen button on a folder, or right-click it, to start a thread in that project. Pinned threads stay above the folders, and Snoozed and Settled stay below them. In this mode you cannot drag threads, and the Working section From 9f33a1c8361e18f553b387cbb5d3c2c62d56f8ba Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 03:04:40 -0700 Subject: [PATCH 05/19] feat(web): show T3 Connect tunnel usage and push full accounts to T3 Chat (#26369) Co-authored-by: Claude Opus 5.5 --- apps/web/src/cloud/managedRelayState.ts | 58 ++++++++- .../useCloudLinkController.browser.test.tsx | 42 ++++++- apps/web/src/cloud/useCloudLinkController.ts | 28 ++++- .../clerk/T3ChatUserProfilePage.tsx | 99 ++++----------- .../clerk/T3ConnectAccountPages.tsx | 8 +- .../clerk/T3ConnectUserProfilePage.tsx | 57 ++++++++- .../web/src/components/clerk/useT3ChatLink.ts | 76 +++++++++++ .../cloud/ConnectOnboardingDialog.tsx | 42 +++++++ apps/web/src/components/cloud/TunnelUsage.tsx | 118 ++++++++++++++++++ .../settings/SavedEnvironmentRows.tsx | 60 ++++++++- .../src/components/settings/settingsSearch.ts | 2 +- .../environments/ManagedTunnelLimits.test.ts | 6 +- .../src/environments/ManagedTunnelLimits.ts | 3 +- packages/contracts/src/relay.ts | 2 + 14 files changed, 506 insertions(+), 95 deletions(-) create mode 100644 apps/web/src/components/clerk/useT3ChatLink.ts create mode 100644 apps/web/src/components/cloud/TunnelUsage.tsx diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index f562373463e1b..c5f7536305337 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -12,10 +12,12 @@ import { createAtomCommandScheduler, createRuntimeCommand, } from "@t3tools/client-runtime/state/runtime"; -import type { - RelayClientDeviceRecord, - RelayClientEnvironmentRecord, - RelayT3ChatLinkResponse, +import { + DEFAULT_MANAGED_TUNNEL_LIMIT, + T3CHAT_PLAN_TUNNEL_LIMITS, + type RelayClientDeviceRecord, + type RelayClientEnvironmentRecord, + type RelayT3ChatLinkResponse, } from "@t3tools/contracts/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -144,3 +146,51 @@ export function useManagedRelayDevices() { refresh, }; } + +/** How this account can raise its tunnel cap: link T3 Chat, or upgrade the linked plan. */ +export type ManagedTunnelUpgrade = "link" | "pro" | "premier" | null; + +// Mirrors the relay's cap without manual overrides, which the relay does not expose. +function managedTunnelCap(t3Chat: RelayT3ChatLinkResponse): { + readonly limit: number; + readonly upgrade: ManagedTunnelUpgrade; +} { + if (!t3Chat.available) return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: null }; + switch (t3Chat.link?.plan) { + case "premier": + return { limit: T3CHAT_PLAN_TUNNEL_LIMITS.premier, upgrade: null }; + case "pro": + return { limit: T3CHAT_PLAN_TUNNEL_LIMITS.pro, upgrade: "premier" }; + case "free": + return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: "pro" }; + case undefined: + return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: "link" }; + } +} + +/** Refetches the account's environments and T3 Chat plan after a tunnel changes. */ +export function refreshManagedTunnelUsage() { + const accountId = appAtomRegistry.get(managedRelaySessionAtom)?.accountId; + if (!accountId) return; + managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); + managedRelayQueryManager.refreshT3ChatLink(appAtomRegistry, accountId); +} + +/** Managed tunnels on this account and its cap. Null fields are still loading or failed. */ +export function useManagedTunnelUsage() { + const environments = useManagedRelayEnvironments(); + const t3Chat = useManagedRelayT3ChatLink(); + const used = + environments.accountId === null + ? null + : (environments.data?.filter( + (environment) => environment.endpoint.providerKind === "cloudflare_tunnel", + ).length ?? null); + const cap = t3Chat.accountId !== null && t3Chat.data ? managedTunnelCap(t3Chat.data) : null; + return { + used, + limit: cap?.limit ?? null, + upgrade: cap?.upgrade ?? null, + refresh: refreshManagedTunnelUsage, + }; +} diff --git a/apps/web/src/cloud/useCloudLinkController.browser.test.tsx b/apps/web/src/cloud/useCloudLinkController.browser.test.tsx index 514d0bdd08039..5884ee5c19df4 100644 --- a/apps/web/src/cloud/useCloudLinkController.browser.test.tsx +++ b/apps/web/src/cloud/useCloudLinkController.browser.test.tsx @@ -4,7 +4,12 @@ import { EnvironmentId, type EnvironmentCloudLinkStateResult, } from "@t3tools/contracts"; -import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { + RelayEnvironmentLinkLimitExceededError, + type RelayManagedEndpointProviderKind, +} from "@t3tools/contracts/relay"; +import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { useLayoutEffect } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -88,6 +93,7 @@ vi.mock(import("./publicConfig"), async (original) => ({ resolveRelayClerkTokenOptions: () => ({}) as never, })); +import { CloudEnvironmentLinkError } from "./linkEnvironment"; import { useCloudLinkController, type CloudLinkDesiredState } from "./useCloudLinkController"; const target = { @@ -125,9 +131,8 @@ async function mountController() { />, ); return Object.assign(screen, { - get controller() { - return controller; - }, + // Object.assign would copy a getter's value once, so read the latest render through a function. + controller: () => controller, async reconcile(desired: CloudLinkDesiredState) { let succeeded = false; await act(async () => { @@ -225,4 +230,33 @@ describe("useCloudLinkController", () => { expect(testState.link).not.toHaveBeenCalled(); expect(testState.preferences).not.toHaveBeenCalled(); }); + + it("reports a relay tunnel cap refusal as tunnelLimit instead of an error", async () => { + using _setup = setup(); + testState.snapshot = { ...linkedState, linked: false, managedTunnelActive: false }; + testState.link.mockResolvedValueOnce( + AsyncResult.failure( + Cause.fail( + new CloudEnvironmentLinkError({ + message: "environment-links failed", + cause: new ManagedRelay.ManagedRelayRequestFailedError({ + action: "link relay environment", + cause: null, + relayError: new RelayEnvironmentLinkLimitExceededError({ + code: "environment_link_limit_exceeded", + maxTunnels: 3, + traceId: "trace-1", + }), + }), + }), + ), + ), + ); + await using screen = await mountController(); + + expect(await screen.reconcile({ managedTunnel: true, publish: false })).toBe(false); + expect(screen.controller()?.tunnelLimit).toBe(3); + expect(screen.controller()?.operationError).toBeNull(); + expect(testState.toast).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts index 69f3ac70a7515..1ae60b8c38382 100644 --- a/apps/web/src/cloud/useCloudLinkController.ts +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -7,18 +7,22 @@ import { AuthRelayWriteScope, type EnvironmentCloudLinkStateResult, } from "@t3tools/contracts"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay"; import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import * as Schema from "effect/Schema"; import { useState } from "react"; import { toastManager } from "../components/ui/toast"; import { relayEnvironmentDiscovery } from "../state/relay"; import { readEnvironmentScope } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; +import { CloudEnvironmentLinkError } from "./linkEnvironment"; +import { refreshManagedTunnelUsage } from "./managedRelayState"; import { linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, @@ -46,6 +50,18 @@ function isManagedTunnelActive( ); } +const isManagedRelayRequestFailedError = Schema.is(ManagedRelay.ManagedRelayRequestFailedError); + +/** The account's tunnel cap when the relay refused a new tunnel for being at it. */ +function tunnelLimitFromLinkFailure(cause: unknown): number | null { + if (!(cause instanceof CloudEnvironmentLinkError)) return null; + const relayCause = cause.cause; + if (!isManagedRelayRequestFailedError(relayCause)) return null; + return relayCause.relayError?._tag === "RelayEnvironmentLinkLimitExceededError" + ? relayCause.relayError.maxTunnels + : null; +} + export function useCloudLinkController() { const { getToken, isSignedIn } = useAuth(); const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { @@ -68,6 +84,7 @@ export function useCloudLinkController() { ? relayEnvironments.get(primaryEnvironmentId)?.environment.endpoint.providerKind : undefined; const [operationError, setOperationError] = useState(null); + const [tunnelLimit, setTunnelLimit] = useState(null); const reportUpdateFailure = (cause: unknown) => { const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access."; @@ -100,6 +117,7 @@ export function useCloudLinkController() { const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise => { setOperationError(null); + setTunnelLimit(null); const target = primaryCloudLinkState.target; if (!target) { reportUpdateFailure(new Error("Local environment is not ready yet.")); @@ -138,6 +156,7 @@ export function useCloudLinkController() { target, clerkToken: tokenResult._tag === "Success" ? (tokenResult.value ?? null) : null, }); + refreshManagedTunnelUsage(); if (unlinkResult._tag === "Failure") { if (!isAtomCommandInterrupted(unlinkResult)) { reportUpdateFailure(squashAtomCommandFailure(unlinkResult)); @@ -168,9 +187,15 @@ export function useCloudLinkController() { clerkToken, mode: desired.managedTunnel ? "managed" : "publish_only", }); + // Any link attempt can change the account's tunnel count. + refreshManagedTunnelUsage(); if (linkResult._tag === "Failure") { if (!isAtomCommandInterrupted(linkResult)) { - reportUpdateFailure(squashAtomCommandFailure(linkResult)); + const cause = squashAtomCommandFailure(linkResult); + // The caller shows an upgrade prompt instead of an error. + const limit = tunnelLimitFromLinkFailure(cause); + if (limit === null) reportUpdateFailure(cause); + else setTunnelLimit(limit); } primaryCloudLinkState.refresh(); return false; @@ -215,6 +240,7 @@ export function useCloudLinkController() { publishAgentActivity, holdWebhooksWhileOffline, operationError, + tunnelLimit, reconcileCloudState, }; } diff --git a/apps/web/src/components/clerk/T3ChatUserProfilePage.tsx b/apps/web/src/components/clerk/T3ChatUserProfilePage.tsx index 689e04661127c..0a1bbe57abbbd 100644 --- a/apps/web/src/components/clerk/T3ChatUserProfilePage.tsx +++ b/apps/web/src/components/clerk/T3ChatUserProfilePage.tsx @@ -1,95 +1,43 @@ -import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; import { T3CHAT_PLAN_TUNNEL_LIMITS, type RelayT3ChatLink } from "@t3tools/contracts/relay"; import { MessageSquareIcon } from "lucide-react"; -import { useState } from "react"; +import type { ReactNode } from "react"; -import { - createManagedRelayT3ChatLinkRequestCommand, - unlinkManagedRelayT3ChatCommand, - useManagedRelayT3ChatLink, -} from "../../cloud/managedRelayState"; -import { readLocalApi } from "../../localApi"; -import { useAtomCommand } from "../../state/use-atom-command"; +import { useManagedTunnelUsage } from "../../cloud/managedRelayState"; +import { T3CHAT_SUBSCRIPTION_URL, TunnelUsageMeter } from "../cloud/TunnelUsage"; import { Button } from "../ui/button"; -import { toastManager } from "../ui/toast"; import { ClerkUserProfilePage, ClerkUserProfileRefreshButton, ClerkUserProfileRow, } from "./ClerkUserProfilePage"; +import { useT3ChatLink } from "./useT3ChatLink"; -function planLabel(link: RelayT3ChatLink): string { +const upgradeLink = ( + + Upgrade on T3 Chat + +); + +function planLabel(link: RelayT3ChatLink): ReactNode { switch (link.plan) { case "premier": return `Premier · up to ${T3CHAT_PLAN_TUNNEL_LIMITS.premier} tunnels`; case "pro": - return `Pro · up to ${T3CHAT_PLAN_TUNNEL_LIMITS.pro} tunnels, or ${T3CHAT_PLAN_TUNNEL_LIMITS.premier} with Premier`; + return ( + <> + Pro · up to {T3CHAT_PLAN_TUNNEL_LIMITS.pro} tunnels. {upgradeLink} to Premier for{" "} + {T3CHAT_PLAN_TUNNEL_LIMITS.premier}. + + ); case "free": - return "Upgrade on T3 Chat for more tunnels"; + return <>{upgradeLink} for more tunnels; } } -function reportFailure(title: string, cause: unknown) { - const traceId = findErrorTraceId(cause); - toastManager.add({ - type: "error", - title, - description: cause instanceof Error ? cause.message : "Try again.", - data: traceId - ? { - secondaryActionProps: { - children: "Copy trace ID", - onClick: () => void navigator.clipboard?.writeText(traceId), - }, - } - : undefined, - }); -} - /** Links a T3 Chat account so paid subscribers get more tunnels. */ export function T3ChatUserProfilePage() { - const linkState = useManagedRelayT3ChatLink(); - const createLinkRequest = useAtomCommand(createManagedRelayT3ChatLinkRequestCommand, { - reportFailure: false, - }); - const unlink = useAtomCommand(unlinkManagedRelayT3ChatCommand, { reportFailure: false }); - const [pending, setPending] = useState<"link" | "unlink" | null>(null); - // Kept so a blocked popup still leaves the user a link to open. - const [linkUrl, setLinkUrl] = useState(null); - - const handleLink = async () => { - const accountId = linkState.accountId; - if (!accountId || pending) return; - setPending("link"); - const result = await createLinkRequest({ accountId }); - if (result._tag === "Success") { - setLinkUrl(result.value); - await readLocalApi() - ?.shell.openExternal(result.value) - .catch(() => undefined); - } else if (!isAtomCommandInterrupted(result)) { - reportFailure("Could not start linking", squashAtomCommandFailure(result)); - } - setPending(null); - }; - - const handleUnlink = async () => { - const accountId = linkState.accountId; - if (!accountId || pending) return; - setPending("unlink"); - const result = await unlink({ accountId }); - if (result._tag === "Success") { - setLinkUrl(null); - linkState.refresh(); - } else if (!isAtomCommandInterrupted(result)) { - reportFailure("Could not unlink T3 Chat", squashAtomCommandFailure(result)); - } - setPending(null); - }; + const { linkState, pending, linkUrl, link: handleLink, unlink: handleUnlink } = useT3ChatLink(); + const usage = useManagedTunnelUsage(); const data = linkState.data; const link = data?.link ?? null; @@ -102,7 +50,7 @@ export function T3ChatUserProfilePage() { } > @@ -160,6 +108,11 @@ export function T3ChatUserProfilePage() { )} + {usage.used !== null && usage.limit !== null ? ( +
  • + +
  • + ) : null} )} diff --git a/apps/web/src/components/clerk/T3ConnectAccountPages.tsx b/apps/web/src/components/clerk/T3ConnectAccountPages.tsx index 85dcc7dd255c4..3e804bdf68d61 100644 --- a/apps/web/src/components/clerk/T3ConnectAccountPages.tsx +++ b/apps/web/src/components/clerk/T3ConnectAccountPages.tsx @@ -28,22 +28,24 @@ export const T3_CONNECT_ACCOUNT_PAGES = [ }, ] as const; +export type T3ConnectAccountPageUrl = (typeof T3_CONNECT_ACCOUNT_PAGES)[number]["url"]; + type PortalTargets = Readonly>; /** Keep portals rendered while the modal can open; Clerk owns the custom-page DOM nodes. */ export function useT3ConnectAccountPage(): { - readonly open: (() => void) | null; + readonly open: ((page?: T3ConnectAccountPageUrl) => void) | null; readonly portals: ReactNode; } { const clerk = useClerk(); const { isSignedIn } = useAuth(); const [targets, setTargets] = useState({}); - const open = () => { + const open = (page: T3ConnectAccountPageUrl = "t3-connect") => { const setTarget = (key: string, element: HTMLDivElement | undefined) => setTargets((current) => ({ ...current, [key]: element })); clerk.openUserProfile({ - __experimental_startPath: "/t3-connect", + __experimental_startPath: `/${page}`, customPages: T3_CONNECT_ACCOUNT_PAGES.map((page) => ({ label: page.label, url: page.url, diff --git a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx index d0342005e6fd2..be59127723c2d 100644 --- a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx +++ b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx @@ -11,9 +11,11 @@ import { useRef, useState } from "react"; import { deregisterManagedRelayEnvironmentCommand, useManagedRelayEnvironments, + useManagedTunnelUsage, } from "../../cloud/managedRelayState"; import { useAtomCommand } from "../../state/use-atom-command"; -import { Button } from "../ui/button"; +import { T3CHAT_SUBSCRIPTION_URL, TunnelUsageMeter, tunnelUpgradeCopy } from "../cloud/TunnelUsage"; +import { Button, InlineButton } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { toastManager } from "../ui/toast"; @@ -22,6 +24,7 @@ import { ClerkUserProfileRefreshButton, ClerkUserProfileRow, } from "./ClerkUserProfilePage"; +import { useT3ChatLink } from "./useT3ChatLink"; const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }); @@ -110,6 +113,53 @@ function T3ConnectEnvironmentRow(props: { ); } +function T3ConnectTunnelUsage() { + const usage = useManagedTunnelUsage(); + const t3Chat = useT3ChatLink(); + if (usage.used === null || usage.limit === null) return null; + return ( +
    + + {usage.upgrade ? ( +

    + {usage.upgrade === "link" && t3Chat.linkUrl ? ( + <> + Confirm on{" "} + + t3.chat + + , then select Refresh. + + ) : ( + <> + {tunnelUpgradeCopy(usage.upgrade)}{" "} + {usage.upgrade === "link" ? ( + void t3Chat.link()}> + {t3Chat.pending === "link" ? "Opening…" : "Link T3 Chat"} + + ) : ( + + Upgrade on T3 Chat + + )} + + )} +

    + ) : null} +
    + ); +} + export function T3ConnectUserProfilePage() { const environmentsState = useManagedRelayEnvironments(); const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { @@ -188,6 +238,8 @@ export function T3ConnectUserProfilePage() { (environment) => removedEnvironmentLinkedAt.get(environment.environmentId) !== environment.linkedAt, ); + const usage = useManagedTunnelUsage(); + const refreshAll = usage.refresh; const isInitialLoad = !environmentsState.accountId || (environmentsState.data === null && !environmentsState.error); @@ -199,11 +251,12 @@ export function T3ConnectUserProfilePage() { } >
    + {environmentsState.error ? (

    diff --git a/apps/web/src/components/clerk/useT3ChatLink.ts b/apps/web/src/components/clerk/useT3ChatLink.ts new file mode 100644 index 0000000000000..66d2267c62682 --- /dev/null +++ b/apps/web/src/components/clerk/useT3ChatLink.ts @@ -0,0 +1,76 @@ +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useState } from "react"; + +import { + createManagedRelayT3ChatLinkRequestCommand, + unlinkManagedRelayT3ChatCommand, + useManagedRelayT3ChatLink, +} from "../../cloud/managedRelayState"; +import { readLocalApi } from "../../localApi"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { toastManager } from "../ui/toast"; + +function reportFailure(title: string, cause: unknown) { + const traceId = findErrorTraceId(cause); + toastManager.add({ + type: "error", + title, + description: cause instanceof Error ? cause.message : "Try again.", + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, + }); +} + +/** Starts and removes the T3 Chat link that raises the T3 Connect tunnel cap. */ +export function useT3ChatLink() { + const linkState = useManagedRelayT3ChatLink(); + const createLinkRequest = useAtomCommand(createManagedRelayT3ChatLinkRequestCommand, { + reportFailure: false, + }); + const unlinkCommand = useAtomCommand(unlinkManagedRelayT3ChatCommand, { reportFailure: false }); + const [pending, setPending] = useState<"link" | "unlink" | null>(null); + // Kept so a blocked popup still leaves the user a link to open. + const [linkUrl, setLinkUrl] = useState(null); + + const link = async () => { + const accountId = linkState.accountId; + if (!accountId || pending) return; + setPending("link"); + const result = await createLinkRequest({ accountId }); + if (result._tag === "Success") { + setLinkUrl(result.value); + await readLocalApi() + ?.shell.openExternal(result.value) + .catch(() => undefined); + } else if (!isAtomCommandInterrupted(result)) { + reportFailure("Could not start linking", squashAtomCommandFailure(result)); + } + setPending(null); + }; + + const unlink = async () => { + const accountId = linkState.accountId; + if (!accountId || pending) return; + setPending("unlink"); + const result = await unlinkCommand({ accountId }); + if (result._tag === "Success") { + setLinkUrl(null); + linkState.refresh(); + } else if (!isAtomCommandInterrupted(result)) { + reportFailure("Could not unlink T3 Chat", squashAtomCommandFailure(result)); + } + setPending(null); + }; + + return { linkState, pending, linkUrl, link, unlink }; +} diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 8daf67714cc45..2e6bb927d69dc 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -9,6 +9,7 @@ import { ConnectOnboardingOptOutSchema, EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, } from "~/cloud/connectOnboarding"; +import { useManagedTunnelUsage } from "~/cloud/managedRelayState"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -19,7 +20,12 @@ import { usePrimaryEnvironmentId, } from "~/state/environments"; import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session"; +import { + type T3ConnectAccountPageUrl, + useT3ConnectAccountPage, +} from "../clerk/T3ConnectAccountPages"; import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; +import { TunnelLimitAlert } from "./TunnelUsage"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; import { Dialog } from "../ui/dialog"; @@ -63,6 +69,7 @@ function ConfiguredConnectOnboardingDialog() { primaryEnvironmentId === null ? environmentsReady : primarySessionState._tag !== "Initial"; const controller = useCloudLinkController(); + const accountPage = useT3ConnectAccountPage(); const showPublishStep = canManageRelay && controller.linkState.target !== null; const steps: ReadonlyArray = showPublishStep ? ["publish", "devices"] @@ -180,6 +187,15 @@ function ConfiguredConnectOnboardingDialog() { setStep("devices"); }; + const openAccountPage = accountPage.open; + // The account page is a Clerk modal, so close this dialog before it opens. + const leaveForAccountPage = openAccountPage + ? (page: T3ConnectAccountPageUrl) => { + complete(); + openAccountPage(page); + } + : null; + return (

    { if ( primaryEnvironmentId !== null && @@ -274,6 +292,8 @@ function ConfiguredConnectOnboardingDialog() { )} + {/* Base UI renders Root children while closed, so this keeps the account page mounted. */} + {accountPage.portals} ); } @@ -288,6 +308,8 @@ function PublishStep({ publishAgentActivity, disabled, operationError, + tunnelLimit, + onOpenAccountPage, onExposeEnvironmentChange, onPublishAgentActivityChange, }: { @@ -295,6 +317,8 @@ function PublishStep({ readonly publishAgentActivity: boolean; readonly disabled: boolean; readonly operationError: string | null; + readonly tunnelLimit: number | null; + readonly onOpenAccountPage: ((page: T3ConnectAccountPageUrl) => void) | null; readonly onExposeEnvironmentChange: (enabled: boolean) => void; readonly onPublishAgentActivityChange: (enabled: boolean) => void; }) { @@ -317,10 +341,28 @@ function PublishStep({ />
    {operationError ?

    {operationError}

    : null} + {tunnelLimit !== null ? ( + + ) : null}
    ); } +function OnboardingTunnelLimitAlert({ + onOpenAccountPage, +}: { + readonly onOpenAccountPage: ((page: T3ConnectAccountPageUrl) => void) | null; +}) { + const { upgrade } = useManagedTunnelUsage(); + return ( + onOpenAccountPage("t3-chat") : null} + onManageTunnels={onOpenAccountPage ? () => onOpenAccountPage("t3-connect") : null} + /> + ); +} + function OnboardingToggleRow({ title, description, diff --git a/apps/web/src/components/cloud/TunnelUsage.tsx b/apps/web/src/components/cloud/TunnelUsage.tsx new file mode 100644 index 0000000000000..0b392f77b5416 --- /dev/null +++ b/apps/web/src/components/cloud/TunnelUsage.tsx @@ -0,0 +1,118 @@ +import { T3CHAT_PLAN_TUNNEL_LIMITS } from "@t3tools/contracts/relay"; +import { TriangleAlertIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { ManagedTunnelUpgrade } from "../../cloud/managedRelayState"; +import { cn } from "../../lib/utils"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; +import { Button } from "../ui/button"; + +// Above this, one slot per tunnel is too thin to read, so the bar fills instead. +const MAX_SEGMENTS = 10; + +export const T3CHAT_SUBSCRIPTION_URL = "https://t3.chat/settings/subscription"; + +export function tunnelUpgradeCopy(upgrade: Exclude): string { + const { pro, premier } = T3CHAT_PLAN_TUNNEL_LIMITS; + switch (upgrade) { + case "link": + return `Link T3 Chat Pro for up to ${pro} tunnels, or Premier for up to ${premier}.`; + case "pro": + return `Upgrade to T3 Chat Pro for up to ${pro} tunnels, or Premier for up to ${premier}.`; + case "premier": + return `Upgrade to T3 Chat Premier for up to ${premier} tunnels.`; + } +} + +/** "2 of 3 tunnels in use" with one bar slot per tunnel. */ +export function TunnelUsageMeter({ + used, + limit, + action, + className, +}: { + readonly used: number; + readonly limit: number; + readonly action?: ReactNode; + readonly className?: string; +}) { + const full = used >= limit; + const fill = full ? "bg-warning" : "bg-foreground/72"; + return ( +
    +
    + + {used} of {limit} tunnels in use + + {action} +
    +
    + {limit <= MAX_SEGMENTS ? ( + Array.from({ length: limit }, (_, index) => ( + + )) + ) : ( + + + + )} +
    +
    + ); +} + +/** Shown when the relay refuses a new tunnel because the account is at its cap. */ +export function TunnelLimitAlert({ + upgrade, + onLinkT3Chat, + onManageTunnels, +}: { + readonly upgrade: ManagedTunnelUpgrade; + readonly onLinkT3Chat: (() => void) | null; + readonly onManageTunnels: (() => void) | null; +}) { + const action = + upgrade === "link" && onLinkT3Chat ? ( + + ) : upgrade === "pro" || upgrade === "premier" ? ( + + ) : onManageTunnels ? ( + + ) : null; + return ( + + + You're out of tunnels (they cost us money). + + {upgrade === "link" + ? "Switch to Tailscale or get more tunnels by linking your T3 Chat sub (sorry!)" + : upgrade + ? "Switch to Tailscale or get more tunnels by upgrading your T3 Chat sub (sorry!)" + : "Switch to Tailscale or deregister an environment you no longer use (sorry!)"} + + {action ? {action} : null} + + ); +} diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index 1d36ef00e47a9..fee5b9857a6ae 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -39,7 +39,7 @@ import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/men import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { Button } from "../ui/button"; +import { Button, InlineButton } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { resolveServerConfigVersionMismatch, @@ -49,6 +49,9 @@ import { import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { isElectron } from "~/env"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; +import { useManagedTunnelUsage } from "~/cloud/managedRelayState"; +import { useT3ConnectAccountPage } from "../clerk/T3ConnectAccountPages"; +import { TunnelLimitAlert, TunnelUsageMeter } from "../cloud/TunnelUsage"; import { environmentCatalog } from "~/connection/catalog"; import { readEnvironmentScope } from "~/state/session"; import { @@ -463,6 +466,56 @@ function CloudLinkSwitch({ ); } +/** Tunnel meter, plus the upgrade prompt after the relay refuses a tunnel at the cap. */ +function T3ConnectTunnelUsage({ tunnelLimit }: { readonly tunnelLimit: number | null }) { + const usage = useManagedTunnelUsage(); + const accountPage = useT3ConnectAccountPage(); + const openAccountPage = accountPage.open; + // Usage when the relay refused. The relay also counts tunnels still being set up, so only + // a freed tunnel or a higher cap clears the alert, not a meter that merely shows room. + const [usedAtRefusal, setUsedAtRefusal] = useState(null); + if (tunnelLimit === null) { + if (usedAtRefusal !== null) setUsedAtRefusal(null); + } else if (usedAtRefusal === null && usage.used !== null) { + setUsedAtRefusal(usage.used); + } + const showLimitAlert = + tunnelLimit !== null && + !(usedAtRefusal !== null && usage.used !== null && usage.used < usedAtRefusal) && + !(usage.limit !== null && usage.limit > tunnelLimit); + const meter = + usage.used !== null && usage.limit !== null ? ( + openAccountPage("t3-chat")}>Get more tunnels + ) : null + } + /> + ) : null; + return ( + <> + {meter || showLimitAlert ? ( +
    + {meter} + {showLimitAlert ? ( + openAccountPage("t3-chat") : null} + onManageTunnels={openAccountPage ? () => openAccountPage("t3-connect") : null} + /> + ) : null} +
    + ) : null} + {/* Kept outside the conditional so an open account page does not remount. */} + {accountPage.portals} + + ); +} + function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const { @@ -474,6 +527,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b publishAgentActivity, holdWebhooksWhileOffline, operationError, + tunnelLimit, reconcileCloudState, } = useCloudLinkController(); const { openAuthPrompt } = useT3ConnectAuthPrompt(); @@ -625,7 +679,9 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b /> ) } - /> + > + {signedOut ? null : } + ) : linkRevoked ? ( { it.effect("rejects concurrent reservations past the limit", () => { const reserved: Array = []; - const attempts = ManagedTunnelLimits.DEFAULT_MANAGED_TUNNEL_LIMIT + 2; + const attempts = DEFAULT_MANAGED_TUNNEL_LIMIT + 2; return Effect.gen(function* () { const limits = yield* ManagedTunnelLimits.ManagedTunnelLimits; @@ -140,7 +140,7 @@ describe("ManagedTunnelLimits", () => { { concurrency: "unbounded" }, ); - expect(reserved).toHaveLength(ManagedTunnelLimits.DEFAULT_MANAGED_TUNNEL_LIMIT); + expect(reserved).toHaveLength(DEFAULT_MANAGED_TUNNEL_LIMIT); expect( results.filter( (result) => diff --git a/infra/relay/src/environments/ManagedTunnelLimits.ts b/infra/relay/src/environments/ManagedTunnelLimits.ts index 93aea073c71d3..aa373ad27ffb2 100644 --- a/infra/relay/src/environments/ManagedTunnelLimits.ts +++ b/infra/relay/src/environments/ManagedTunnelLimits.ts @@ -6,7 +6,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as SqlError from "effect/sql/SqlError"; -import { T3CHAT_PLAN_TUNNEL_LIMITS } from "@t3tools/contracts/relay"; +import { DEFAULT_MANAGED_TUNNEL_LIMIT, T3CHAT_PLAN_TUNNEL_LIMITS } from "@t3tools/contracts/relay"; import * as RelayDb from "../db.ts"; import { @@ -16,7 +16,6 @@ import { } from "../persistence/schema.ts"; import * as T3ChatAccounts from "../t3chat/T3ChatAccounts.ts"; -export const DEFAULT_MANAGED_TUNNEL_LIMIT = 3; // Reserve-to-tunnel takes two Cloudflare calls capped at 8 seconds each; a reservation older than this was abandoned. const MANAGED_TUNNEL_PENDING_WINDOW_MINUTES = 15; diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 983f74de418fa..4dbb6a199f472 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -966,6 +966,8 @@ export type RelayOkResponse = typeof RelayOkResponse.Type; export const RelayT3ChatPlan = Schema.Literals(["free", "pro", "premier"]); export type RelayT3ChatPlan = typeof RelayT3ChatPlan.Type; +export const DEFAULT_MANAGED_TUNNEL_LIMIT = 3; + // A linked paid T3 Chat plan raises the default managed tunnel cap. export const T3CHAT_PLAN_TUNNEL_LIMITS = { pro: 10, premier: 100 } as const; From 40d335a7ba8ed0e6158a400188b5ed174e6ed19b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 03:12:06 -0700 Subject: [PATCH 06/19] fix(web): settling a thread no longer scrolls the sidebar to the bottom (#26392) Co-authored-by: Claude Opus 5.5 --- apps/web/src/lib/sidebarTree.browser.test.ts | 19 +++++++++++++++++++ apps/web/src/lib/sidebarTree.ts | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/sidebarTree.browser.test.ts b/apps/web/src/lib/sidebarTree.browser.test.ts index 3d57aacb86438..6b02cd3b842bf 100644 --- a/apps/web/src/lib/sidebarTree.browser.test.ts +++ b/apps/web/src/lib/sidebarTree.browser.test.ts @@ -172,6 +172,25 @@ describe("sidebar keyboard navigation", () => { expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([-1, 0]); }); + it("keeps the scroll position when focus follows a row that moved away", async () => { + using tree = setup(); + Object.assign(tree.root.style, { height: "100px", overflow: "auto" }); + const settled = tree.row("settled"); + const settle = document.createElement("button"); + settle.textContent = "Settle"; + settled.after(settle); + settle.dataset.sidebarActionFor = "settled"; + for (let index = 0; index < 20; index++) tree.row(`row-${index}`).style.display = "block"; + tree.install(); + settle.focus(); + // Settling moves the row to the shelf at the bottom and removes the focused button. + settle.remove(); + settled.remove(); + const moved = tree.row("settled"); + await vi.waitFor(() => expect(document.activeElement).toBe(moved)); + expect(tree.root.scrollTop).toBe(0); + }); + it("does not pull focus back after a click outside the tree", async () => { using tree = setup(); const first = tree.row("first"); diff --git a/apps/web/src/lib/sidebarTree.ts b/apps/web/src/lib/sidebarTree.ts index 13dd10340e0b9..07859c72bd901 100644 --- a/apps/web/src/lib/sidebarTree.ts +++ b/apps/web/src/lib/sidebarTree.ts @@ -120,7 +120,8 @@ export function installSidebarTree(root: HTMLElement) { !rows.includes(document.activeElement as HTMLElement) && document.activeElement?.matches(ROW))) ) { - focusRow(selected); + // A settled row moves to the bottom shelf; scrolling after it fights the route reveal. + selected.focus({ preventScroll: true }); } if (ownsFocus && !selected && document.activeElement === document.body) root.focus(); }; From 80fcb7f58f90b43ba5918383cbe38d3ae804da8e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 03:24:01 -0700 Subject: [PATCH 07/19] fix(web): boring sidebar folders put your latest message on top (#26391) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ddc7ad1e7bd29..0868554b50ffd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -337,6 +337,8 @@ interface SidebarThreadPartitionInput { readonly serverConfigs: ReadonlyMap; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly workingShelfEnabled: boolean; + /** Boring mode: folders list active threads by the user's latest message. */ + readonly sortActiveBySend: boolean; // Snooze wakes, the queued-turn grace and the Working shelf's exit grace depend // on the clock, so the minute tick, the precise snooze-wake tick and the inbox // tracker's expiry version are inputs: a new key repartitions. @@ -458,6 +460,7 @@ function useSidebarThreads(input: { serverConfigs, optimisticDrop, workingShelfEnabled, + sortActiveBySend: folderGroups !== null, clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); @@ -592,6 +595,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { serverConfigs, optimisticDrop, workingShelfEnabled, + sortActiveBySend, } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); @@ -650,9 +654,11 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { } // A loaded config gates dragging only; the sort must not depend on it, so web and mobile agree. const sortedPinned = sortPinnedThreadsForSidebar(pinned); - const sortedActive = workingShelfEnabled - ? sortInboxThreadsByReturn(active, inboxObservation.returnedAt) - : sortThreadsForSidebar(active); + const sortedActive = sortActiveBySend + ? sortWorkingThreadsBySend(active) + : workingShelfEnabled + ? sortInboxThreadsByReturn(active, inboxObservation.returnedAt) + : sortThreadsForSidebar(active); return { pinnedThreads: optimisticDrop?.section !== "pinned" || optimisticDrop.order === null From 2a738273e74e2bc4637bb0977fa7818970760621 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 03:25:14 -0700 Subject: [PATCH 08/19] fix(server): threads settle right away when a watched PR merges (#26395) Co-authored-by: Claude Opus 5.5 --- ...chestrationV2PendingBackgroundWork.test.ts | 20 +++++++++++++++++-- .../orchestrationV2PendingBackgroundWork.ts | 9 +++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 91c827ed81596..1b5331c0d1bed 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -374,19 +374,26 @@ describe("derivePendingBackgroundWork kinds", () => { }; const link = ( number: number, - extra: { source?: "agent" | "stack-dismissed"; watched?: boolean }, + extra: { + source?: "agent" | "stack-dismissed"; + watched?: boolean; + state?: "open" | "closed" | "merged"; + }, ) => ({ host: "github.com", repository: "acme/app", number, url: `https://github.com/acme/app/pull/${number}`, source: extra.source ?? "agent", + snapshot: extra.state === undefined ? null : { state: extra.state }, ...(extra.watched === false ? {} : { watch }), }); const pullRequests = [ link(1, {}), link(2, { watched: false }), link(3, { source: "stack-dismissed" }), + link(4, { state: "merged" }), + link(5, { state: "closed" }), ]; it("keeps a settled run waiting on each visible watch, as an awaited monitor", () => { @@ -403,6 +410,12 @@ describe("derivePendingBackgroundWork kinds", () => { kind: "monitor", awaited: true, }, + { + taskId: "pull-request-watch:github.com/acme/app#5", + description: "Watching pull request #5", + kind: "monitor", + awaited: true, + }, ]); expect(backgroundWorkHoldsCompletion(tasks)).toBe(true); }); @@ -415,7 +428,10 @@ describe("derivePendingBackgroundWork kinds", () => { turnItems: [], pullRequests, }).map((task) => task.taskId), - ).toEqual(["pull-request-watch:github.com/acme/app#1"]); + ).toEqual([ + "pull-request-watch:github.com/acme/app#1", + "pull-request-watch:github.com/acme/app#5", + ]); }); it("lists no watch while a run is active", () => { diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 6768d9359ffc7..6dc635b5153a6 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -112,7 +112,9 @@ type PendingBackgroundWorkRun = Pick; +> & { + readonly snapshot: Pick, "state"> | null; +}; type PendingBackgroundWorkProviderThread = Pick< OrchestrationV2ProviderThread, @@ -291,7 +293,10 @@ function pullRequestWatchTasks( pullRequests: ReadonlyArray | undefined, ): Array { return (pullRequests ?? []).flatMap((link) => - link.watch === undefined || link.source === "stack-dismissed" + // The watch reactor ends a merged PR's watch without waking the agent, so it holds nothing. + link.watch === undefined || + link.source === "stack-dismissed" || + link.snapshot?.state === "merged" ? [] : [ { From d2429f733c30772ab5ef8818685615e656ec432f Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 05:53:28 -0500 Subject: [PATCH 09/19] fix(server): load long thread history in bounded pages (#26240) --- .../src/features/threads/ThreadFeed.tsx | 119 +++++-- apps/mobile/src/lib/threadActivity.test.ts | 15 + apps/mobile/src/lib/threadActivity.ts | 35 ++- .../src/orchestration-v2/Orchestrator.ts | 5 +- .../orchestration-v2/ProjectionStore.test.ts | 201 ++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 278 +++++++++++++---- .../ThreadManagementService.ts | 10 +- apps/server/src/orchestration-v2/http.ts | 16 +- .../threadHistoryPaging.test.ts | 106 +++++++ .../orchestration-v2/threadHistoryPaging.ts | 150 +++++++-- apps/server/src/ws.ts | 3 + .../chat/MessagesTimeline.logic.test.ts | 83 +++++ .../components/chat/MessagesTimeline.logic.ts | 49 ++- .../src/components/chat/MessagesTimeline.tsx | 144 +++++++-- .../pullRequest/PullRequestDetailPanel.tsx | 2 +- apps/web/src/session-logic.test.ts | 14 + apps/web/src/session-logic.ts | 2 +- docs/internals/performance-regressions.md | 9 +- .../src/state/boundedThreadSnapshotHttp.ts | 7 +- .../state/orchestrationV2Projection.test.ts | 8 +- .../src/state/orchestrationV2Projection.ts | 5 +- .../src/state/threadCommands.ts | 2 + .../src/state/threadHistoryController.ts | 10 +- .../src/state/threadHistoryHttp.ts | 3 +- .../src/state/threadHistoryMerge.test.ts | 23 ++ .../src/state/threadHistoryMerge.ts | 55 +++- .../src/state/threads-sync.test.ts | 291 +++++++++++++++++- packages/client-runtime/src/state/threads.ts | 203 +++++++----- packages/contracts/src/environmentHttp.ts | 4 +- packages/contracts/src/orchestrationV2.ts | 9 + 30 files changed, 1607 insertions(+), 254 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f845d923f9a0f..da127a29ef9bf 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1208,6 +1208,92 @@ function compactionLabel(label: string, title: string | undefined) { : label; } +function RunFoldButton({ + entry, + environmentId, + threadId, + onToggle, + rowSizing, + iconColor, +}: { + entry: Extract; + environmentId: EnvironmentId; + threadId: ThreadId; + onToggle: (runId: RunId) => void; + rowSizing: ReturnType; + iconColor: ColorValue; +}) { + const loadWork = useAtomCommand(threadEnvironment.loadEarlierHistory, { reportFailure: false }); + const anchorsKey = entry.deferredWorkItemIds?.join("\n") ?? ""; + const [failed, setFailed] = useState(false); + const load = () => { + if (!anchorsKey) return; + void Promise.all( + anchorsKey + .split("\n") + .map((workAnchorId) => loadWork({ environmentId, input: { threadId, workAnchorId } })), + ).then((results) => + setFailed( + results.some((result) => result._tag === "Failure" || result.value._tag === "error"), + ), + ); + }; + const loadOnExpand = useEffectEvent(load); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restored expanded folds load their deferred server history on mount. + useEffect(() => { + if (entry.expanded && anchorsKey && environmentId && threadId) loadOnExpand(); + }, [entry.expanded, anchorsKey, environmentId, threadId]); + const error = anchorsKey && failed ? "Couldn't load work. Tap to retry." : null; + const loading = entry.expanded && anchorsKey !== "" && !failed; + const toggle = () => { + setFailed(false); + if (error && entry.expanded) load(); + else onToggle(entry.runId); + }; + + return ( + + + + {entry.label} + {error ? ` · ${error}` : ""} + + + + {loading ? ( + + {(["40%", "60%", "33%"] as const).map((width) => ( + + + + + ))} + + ) : null} + + ); +} + function renderFeedEntry( info: { item: ThreadFeedRow; index: number }, props: Pick< @@ -1279,30 +1365,15 @@ function renderFeedEntry( if (entry.type === "run-fold") { return ( - props.onToggleTurnFold(entry.runId)} - hitSlop={4} - className="min-h-11 min-w-0 flex-1 flex-row items-center gap-2" - style={{ - minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), - }} - > - - {entry.label} - - - + {entry.checkpointSummary ? ( { "message", "message", ]); + const anchor = { + ...command(), + deferredWork: { cursor: "old-work", itemCount: 100, lastOrdinal: 100 }, + }; + const deferredFeed = buildThreadFeed([projected(userMessage(), 0), projected(anchor, 1)]); + const running = { ...latestRun, status: "running" as const, completedAt: null }; + const deferredRows = deriveThreadFeedPresentation(deferredFeed, running, new Set()); + const disclosure = deferredRows.find((entry) => entry.type === "run-fold"); + expect(disclosure).toMatchObject({ deferredWorkItemIds: [anchor.id], expanded: false }); + if (disclosure?.type !== "run-fold") throw new Error("Expected deferred work disclosure"); + expect( + deriveThreadFeedPresentation(deferredFeed, running, new Set([disclosure.runId])).some( + (entry) => entry.type === "activity-group", + ), + ).toBe(true); expect(expanded[4]).toMatchObject({ message: { id: middle.messageId, text: middle.text } }); const active = deriveThreadFeedPresentation( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9cdb3b6f8d815..fc085a7c50a0d 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -231,6 +231,7 @@ type ThreadFeedEntryContent = } | { readonly type: "run-fold"; + readonly deferredWorkItemIds?: ReadonlyArray; readonly id: string; readonly createdAt: string; readonly runId: RunId; @@ -1082,6 +1083,7 @@ function threadFeedActivityIsVisible( } interface ThreadFeedRunFold { + readonly deferredWorkItemIds?: ReadonlyArray; readonly runId: RunId; readonly createdAt: string; readonly hiddenEntryIds: ReadonlySet; @@ -1273,7 +1275,17 @@ function deriveThreadFeedRunFolds( const duration = elapsedMs === null ? null : formatDuration(elapsedMs); const interrupted = latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled"); + const deferredWorkItemIds = group.entries.flatMap((entry) => + entry.type === "activity-group" + ? entry.activities.flatMap((activity) => + activity.projectedItem.item.deferredWork + ? [String(activity.projectedItem.sourceItemId)] + : [], + ) + : [], + ); foldsByAnchorId.set(firstHiddenEntry.id, { + ...(deferredWorkItemIds.length > 0 ? { deferredWorkItemIds } : {}), runId, createdAt: firstHiddenEntry.createdAt, hiddenEntryIds, @@ -1286,6 +1298,23 @@ function deriveThreadFeedRunFolds( : "Worked", }); } + const covered = new Set( + [...foldsByAnchorId.values()].flatMap((fold) => [...fold.hiddenEntryIds]), + ); + for (const entry of feed) { + if (entry.type !== "activity-group" || covered.has(entry.id)) continue; + const deferredWorkItemIds = entry.activities.flatMap((activity) => + activity.projectedItem.item.deferredWork ? [String(activity.projectedItem.sourceItemId)] : [], + ); + if (deferredWorkItemIds.length === 0) continue; + foldsByAnchorId.set(entry.id, { + runId: RunId.make(`deferred-work:${entry.id}`), + createdAt: entry.createdAt, + hiddenEntryIds: new Set([entry.id]), + label: "Earlier work", + deferredWorkItemIds, + }); + } return foldsByAnchorId; } @@ -1501,11 +1530,13 @@ export function deriveThreadFeedPresentation( row.runId !== fold.runId || row.createdAt !== fold.createdAt || row.label !== fold.label || - row.expanded !== expanded + row.expanded !== expanded || + row.deferredWorkItemIds?.join("|") !== fold.deferredWorkItemIds?.join("|") ) { row = { type: "run-fold", id: `run-fold:${fold.runId}`, + ...(fold.deferredWorkItemIds ? { deferredWorkItemIds: fold.deferredWorkItemIds } : {}), createdAt: fold.createdAt, runId: fold.runId, label: fold.label, @@ -2189,7 +2220,7 @@ export function buildThreadFeed( for (let index = restart?.row ?? 0; index < visibleTurnItems.length; index += 1) { const row = visibleTurnItems[index]!; const item = row.item; - if (turnItemIsWorkspacePreparation(item)) continue; + if (turnItemIsWorkspacePreparation(item) && !item.deferredWork) continue; if (item.type === "todo_list" || item.type === "checkpoint") continue; if ( item.type === "user_message" && diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 3f76bd6185eb7..91acb881637c1 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -344,6 +344,7 @@ export interface OrchestratorV2Shape { cursor: string, throughEntryId?: string, conversationOnly?: boolean, + workMode?: "deferred" | "work", ) => Effect.Effect; readonly getTimelinePage: ( threadId: ThreadId, @@ -13281,9 +13282,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); }), getThreadHistoryPage: Effect.fn("OrchestratorV2.getThreadHistoryPage")( - function (threadId, cursor, throughEntryId, conversationOnly) { + function (threadId, cursor, throughEntryId, conversationOnly, workMode) { return projectionStore - .getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly) + .getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly, workMode) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); }, ), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 5bca01a57fe99..2af87ecf4feb7 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -10,6 +10,7 @@ import { type ModelSelection, type OrchestrationV2ProviderThread, type OrchestrationV2TurnItem, + type OrchestrationV2ThreadHistoryPage, type OrchestrationV2ThreadShellSnapshot, NodeId, PlanId, @@ -1972,6 +1973,206 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { Array.from({ length: 1_000 }, (_, index) => `turn-item:bounded-sql-history:${index + 1}`), ); + yield* setNewestTurnType("command_execution"); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET run_id = 'run:bounded-sql-history:1', + payload_json = json_set(payload_json, '$.runId', 'run:bounded-sql-history:1') + WHERE thread_id = ${threadId} + `; + yield* sql`UPDATE orchestration_v2_projection_turn_items SET ordinal = 1000, + payload_json = json_set(payload_json, '$.ordinal', 1000) + WHERE thread_id = ${threadId} AND ordinal = 999`; + const promptId = TurnItemId.make("turn-item:bounded-sql-history:1"); + const { statements: deferredStatements, tracer: deferredTracer } = traceSqlStatements(); + const deferred = yield* projectionStore + .getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }) + .pipe(Effect.withTracer(deferredTracer)); + const deferredQuery = deferredStatements.find((statement) => + statement.includes("blocked_runs AS MATERIALIZED"), + ); + assert.isDefined(deferredQuery); + const deferredPlan = yield* sql.unsafe<{ + readonly id: number; + readonly parent: number; + readonly detail: string; + }>(`EXPLAIN QUERY PLAN ${deferredQuery}`); + const blockedRunsPlan = deferredPlan.find((row) => row.detail === "MATERIALIZE blocked_runs"); + assert.isDefined(blockedRunsPlan); + const blockedLookup = deferredPlan.find( + (row) => row.parent === blockedRunsPlan!.id && row.detail.startsWith("SEARCH "), + ); + assert.include(blockedLookup?.detail ?? "", "run_id=?"); + assert.lengthOf(deferred.projection.visibleTurnItems, 3); + assert.strictEqual(deferred.projection.visibleTurnItems[0]?.sourceItemId, promptId); + const anchor = deferred.projection.visibleTurnItems[1]!; + assert.strictEqual(anchor.item.deferredWork?.itemCount, 999); + const workIds = new Set(); + let workCursor: string | null = anchor.item.deferredWork!.cursor; + for (let pages = 0; workCursor !== null; pages += 1) { + assert.isBelow(pages, 20); + const page: OrchestrationV2ThreadHistoryPage = yield* projectionStore.getThreadHistoryPage( + threadId, + workCursor, + anchor.sourceItemId, + false, + "work", + ); + assert.isAtMost(page.items.length, 75); + for (const row of page.items) workIds.add(row.sourceItemId); + workCursor = page.nextCursor; + } + assert.strictEqual(workIds.size, 999); + for (const toolName of ["t3-code.delegate_task", "mcp__t3_code__delegate_task"]) { + yield* sql`UPDATE orchestration_v2_projection_turn_items SET type = 'dynamic_tool', + payload_json = json_set(payload_json, '$.type', 'dynamic_tool', '$.toolName', ${toolName}) + WHERE thread_id = ${threadId} AND ordinal = 2`; + const window = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.isUndefined( + window.projection.visibleTurnItems.find((row) => row.item.ordinal === 2)?.item + .deferredWork, + ); + assert.strictEqual( + window.projection.visibleTurnItems.find((row) => row.item.deferredWork)?.item.ordinal, + 3, + ); + const page = yield* projectionStore.getThreadHistoryPage( + threadId, + encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: "turn-item:bounded-sql-history:1000", + position: 1000, + }), + undefined, + false, + "deferred", + ); + assert.isUndefined(page.items.find((row) => row.item.ordinal === 2)?.item.deferredWork); + } + yield* sql`UPDATE orchestration_v2_projection_turn_items SET type = 'command_execution', + payload_json = json_set(payload_json, '$.type', 'command_execution') WHERE thread_id = ${threadId} AND ordinal = 2`; + const sharedRunId = "run:bounded-sql-history:1"; + for (const [ordinal, status] of [ + [0, "completed"], + [1, "superseded"], + [2, "running"], + ] as const) { + const attempt = { + id: `attempt:bounded-sql-history:${ordinal}`, + runId: sharedRunId, + attemptOrdinal: ordinal + 1, + rootNodeId: `node:bounded-sql-history:attempt-${ordinal === 0 ? 1 : ordinal}`, + providerInstanceId, + providerThreadId: "provider-thread:bounded-sql-history", + providerTurnId: null, + reason: ordinal === 1 ? "initial" : "steering_restart", + status, + startedAt: nowIso, + completedAt: status === "superseded" ? nowIso : null, + }; + yield* sql`INSERT INTO orchestration_v2_projection_run_attempts + (attempt_id, thread_id, run_id, attempt_ordinal, root_node_id, provider, provider_instance_id, provider_thread_id, provider_turn_id, status, payload_json) + VALUES (${attempt.id}, ${threadId}, ${sharedRunId}, ${attempt.attemptOrdinal}, ${attempt.rootNodeId}, ${providerInstanceId}, ${providerInstanceId}, ${attempt.providerThreadId}, NULL, ${status}, ${encodeUnknownJsonString(attempt)})`; + } + yield* sql`UPDATE orchestration_v2_projection_turn_items + SET node_id = CASE WHEN ordinal < 990 AND ordinal <> 10 THEN 'node:bounded-sql-history:attempt-1' ELSE 'node:bounded-sql-history:attempt-2' END, + payload_json = json_set(payload_json, '$.nodeId', CASE WHEN ordinal < 990 AND ordinal <> 10 THEN 'node:bounded-sql-history:attempt-1' ELSE 'node:bounded-sql-history:attempt-2' END) + WHERE thread_id = ${threadId}`; + for (const status of ["completed", "running"]) { + yield* sql`UPDATE orchestration_v2_projection_runs SET status = ${status}, + payload_json = json_set(payload_json, '$.status', ${status}) WHERE run_id = ${sharedRunId}`; + const window = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + const oldWork = window.projection.visibleTurnItems.find((row) => row.item.deferredWork); + assert.strictEqual(oldWork?.item.deferredWork?.itemCount, 987); + let cursor: string | null = oldWork!.item.deferredWork!.cursor; + const loaded = new Set(); + for (let pages = 0; cursor !== null; pages += 1) { + assert.isBelow(pages, 20); + const page: OrchestrationV2ThreadHistoryPage = + yield* projectionStore.getThreadHistoryPage( + threadId, + cursor, + oldWork!.sourceItemId, + false, + "work", + ); + for (const row of page.items) loaded.add(row.sourceItemId); + cursor = page.nextCursor; + } + assert.strictEqual(loaded.size, 987); + assert.isFalse(loaded.has(TurnItemId.make("turn-item:bounded-sql-history:10"))); + } + yield* sql`DELETE FROM orchestration_v2_projection_run_attempts WHERE thread_id = ${threadId}`; + yield* sql`UPDATE orchestration_v2_projection_runs SET status = 'completed', + payload_json = json_set(payload_json, '$.status', 'completed') WHERE run_id = ${sharedRunId}`; + yield* sql`UPDATE orchestration_v2_projection_turn_items SET node_id = NULL, + payload_json = json_set(payload_json, '$.nodeId', NULL) WHERE thread_id = ${threadId}`; + yield* sql`UPDATE orchestration_v2_projection_turn_items SET run_id = NULL, + payload_json = json_set(payload_json, '$.runId', NULL) + WHERE thread_id = ${threadId} AND ordinal > 1 AND ordinal < 851`; + const inlineWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.isFalse(inlineWindow.projection.visibleTurnItems.some((row) => row.item.deferredWork)); + yield* sql`UPDATE orchestration_v2_projection_turn_items SET run_id = ${sharedRunId}, + payload_json = json_set(payload_json, '$.runId', ${sharedRunId}) + WHERE thread_id = ${threadId} AND ordinal = 850`; + const deferredWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.isTrue( + deferredWindow.projection.visibleTurnItems.some( + (row) => row.item.deferredWork?.itemCount === 151, + ), + ); + yield* sql`UPDATE orchestration_v2_projection_turn_items + SET run_id = 'run:bounded-sql-history:1', + payload_json = json_set(payload_json, '$.runId', 'run:bounded-sql-history:1') + WHERE thread_id = ${threadId}`; + yield* sql`UPDATE orchestration_v2_projection_turn_items SET ordinal = 999, + payload_json = json_set(payload_json, '$.ordinal', 999) + WHERE turn_item_id = 'turn-item:bounded-sql-history:999'`; + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'command_execution', payload_json = json_set(payload_json, '$.type', 'command_execution') + WHERE thread_id = ${threadId} AND ordinal = 1 + `; + const backgroundWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.lengthOf(backgroundWindow.projection.visibleTurnItems, sqlPageLimit); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'user_message', payload_json = json_set(payload_json, '$.type', 'user_message') + WHERE thread_id = ${threadId} AND ordinal = 1 + `; + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET run_id = 'run:bounded-sql-history:' || ordinal, + payload_json = json_set(payload_json, '$.runId', 'run:bounded-sql-history:' || ordinal) + WHERE thread_id = ${threadId} + `; + yield* setNewestTurnType("user_message"); + const tiedIds = [ TurnItemId.make("turn-item:bounded-sql-history:tie:b"), TurnItemId.make("turn-item:bounded-sql-history:tie:a"), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 1e9176d1bed69..2a21bfae4bd32 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -97,10 +97,14 @@ import { isConversationHistoryItem, THREAD_HISTORY_MAX_RAW_TURNS, missingInterruptRequests, + deferWorkHistoryRows, + selectDeferredWorkPage, + encodeThreadHistoryCursor, olderHistoryPagePolicy, selectHistoryPageFromCursor, selectRecentTimelineWindow, THREAD_HISTORY_PAGE_POLICY, + THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT, THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT, } from "./threadHistoryPaging.ts"; @@ -416,6 +420,7 @@ export interface ProjectionStoreV2Shape { cursor: string, throughEntryId?: string, conversationOnly?: boolean, + workMode?: "deferred" | "work", ) => Effect.Effect; readonly getTimelinePage: ( threadId: ThreadId, @@ -611,6 +616,7 @@ export interface ProjectionStoreV2Shape { options: { readonly rowLimit: number; readonly userTurnLimit?: number | undefined; + readonly deferWork?: boolean | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly anchorThreadId?: ThreadId | undefined; readonly requiredRunId?: RunId | undefined; @@ -625,6 +631,14 @@ export interface ProjectionStoreV2Shape { >; } +type ProjectionWindow = Omit< + Parameters[1], + "anchorThreadId" +> & { + readonly suppressLocal?: boolean | undefined; + readonly historyAnchor?: { readonly threadId: ThreadId; readonly itemId: TurnItemId } | undefined; +}; + export class ProjectionStoreV2 extends Context.Service()( "t3/orchestration-v2/ProjectionStore/ProjectionStoreV2", ) {} @@ -1796,6 +1810,11 @@ export const layer: Layer.Layer = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const inlineWorkOutput = sql`item.type = 'dynamic_tool' AND ( + item.payload_json LIKE '%htmlRender%' OR item.payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} + OR item.payload_json LIKE '%"viewedImagePath"%' + OR item.payload_json LIKE '%"toolName":"%delegate_task"%')`; + const keepRecordedRunField = (payload: Statement.Fragment, path: string) => sql` CASE WHEN json_type(excluded.payload_json, ${path}) IS NULL @@ -2676,21 +2695,26 @@ export const layer: Layer.Layer = ); // Share selection so counting discarded ancestry preserves payload-read positions. - const retainedWindowRows = ( + const retainedWindowRows = Effect.fn("ProjectionStore.retainedWindowRows")(function* ( threadId: ThreadId, - window: { - readonly rowLimit: number; - readonly userTurnLimit?: number | undefined; - readonly anchorItemId?: TurnItemId | undefined; - readonly requiredRunId?: RunId | undefined; - }, - ) => sql` + window: ProjectionWindow, + ) { + const eligible = sql` WITH eligible AS NOT MATERIALIZED ( SELECT item.rowid AS item_rowid, item.payload_json, item.ordinal, item.turn_item_id, - item.run_id, item.node_id, item.type + item.run_id, item.node_id, item.type, item.status, run.status AS run_status, + attempt.attempt_id, attempt.status AS attempt_status FROM orchestration_v2_projection_turn_items AS item LEFT JOIN orchestration_v2_projection_runs AS run ON run.run_id = item.run_id + LEFT JOIN orchestration_v2_projection_nodes AS node + ON ${window.deferWork ? 1 : 0} = 1 AND node.node_id = item.node_id + LEFT JOIN orchestration_v2_projection_run_attempts AS attempt + ON ${window.deferWork ? 1 : 0} = 1 AND attempt.attempt_id = ( + SELECT attempt_id FROM orchestration_v2_projection_run_attempts + WHERE run_id = item.run_id AND root_node_id = COALESCE(node.root_node_id, item.node_id) + ORDER BY attempt_ordinal DESC LIMIT 1 + ) WHERE item.thread_id = ${threadId} AND item.ordinal <= COALESCE( ( @@ -2749,7 +2773,10 @@ export const layer: Layer.Layer = AND request.type = 'run_interrupt_request' ) ) - ), run_starts AS ( + )`; + // Resolve anchors separately: repeated CTE expansion exceeds SQLite's subquery limit. + const anchors = yield* sql<{ readonly ordinal: number; readonly latest: number | null }>` + ${eligible}, run_starts AS ( -- A wake (PR watch, task completion) starts its run with a -- notification instead of a user message. Read the first -- input of each run, newest run first, and add the @@ -2813,6 +2840,49 @@ export const layer: Layer.Layer = ELSE 0 END AS ordinal FROM user_anchors + ) + SELECT ordinal, (SELECT MAX(ordinal) FROM user_anchors) AS latest FROM boundary + `; + const boundary = anchors[0]!; + return sql` + ${eligible}, blocked_runs AS MATERIALIZED ( + SELECT DISTINCT run_id FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${threadId} AND run_id IS NOT NULL + AND ${window.deferWork ? 1 : 0} = 1 + AND run_id IN ( + SELECT run_id FROM eligible + WHERE ordinal >= ${boundary.ordinal} AND run_id IS NOT NULL + ) + AND (type IN ('run_interrupt_request', 'run_interrupt_result') + OR (type = 'error' AND status = 'failed' AND parent_item_id IS NULL)) + ), work_segments AS ( + SELECT item_rowid, ordinal, turn_item_id, run_id, type, attempt_id, + MAX(CASE WHEN type = 'user_message' THEN ordinal ELSE 0 END) + OVER (PARTITION BY run_id ORDER BY ordinal) AS segment, + CASE WHEN status = 'completed' AND (run_status = 'completed' OR attempt_status = 'superseded') + AND type IN ('command_execution', 'reasoning', 'file_change', 'file_search', 'web_search', 'dynamic_tool') + AND NOT (${inlineWorkOutput}) + AND run_id NOT IN (SELECT run_id FROM blocked_runs) + THEN 1 ELSE 0 END AS can_defer + FROM eligible AS item + WHERE ${window.deferWork ? 1 : 0} = 1 + AND ${boundary.latest === null ? 0 : 1} = 1 + AND ordinal >= ${boundary.ordinal} + ), deferred_work AS ( + SELECT run_id, segment, attempt_id, MIN(ordinal) AS first_ordinal, + MAX(ordinal) AS last_ordinal, COUNT(*) AS item_count + FROM work_segments WHERE can_defer = 1 + GROUP BY run_id, segment, attempt_id HAVING COUNT(*) > ${THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT} + ), window_items AS NOT MATERIALIZED ( + SELECT item_rowid, ordinal, turn_item_id, run_id, type + FROM eligible WHERE ${window.deferWork ? 1 : 0} = 0 + OR ${boundary.latest === null ? 1 : 0} = 1 + UNION ALL + SELECT item.item_rowid, item.ordinal, item.turn_item_id, item.run_id, item.type + FROM work_segments item LEFT JOIN deferred_work deferred + ON deferred.run_id = item.run_id AND deferred.segment = item.segment + AND deferred.attempt_id IS item.attempt_id + WHERE item.can_defer = 0 OR deferred.run_id IS NULL OR item.ordinal = deferred.first_ordinal ), latest_user_turn AS ( -- Mirrors selectOlderTimelinePage: whole turns while the -- newest user turn fits the turn row budget, otherwise the @@ -2820,8 +2890,8 @@ export const layer: Layer.Layer = SELECT COUNT(*) AS row_count FROM ( SELECT 1 - FROM eligible - WHERE ordinal >= (SELECT MAX(ordinal) FROM user_anchors) + FROM window_items + WHERE ordinal >= ${boundary.latest} LIMIT ${THREAD_HISTORY_PAGE_POLICY.maxTurnItems + 1} ) ), selected AS ( @@ -2829,8 +2899,8 @@ export const layer: Layer.Layer = -- order. A turn window can hold megabytes of tool output, and -- carrying it through the union and sort cost about a second. SELECT item_rowid, ordinal, turn_item_id, run_id, type - FROM eligible - WHERE ordinal >= (SELECT ordinal FROM boundary) + FROM window_items + WHERE ordinal >= ${boundary.ordinal} ORDER BY ordinal DESC, turn_item_id DESC LIMIT CASE WHEN ${window.rowLimit} = 0 THEN 0 @@ -2863,17 +2933,12 @@ export const layer: Layer.Layer = ORDER BY ordinal ASC, turn_item_id ASC ) `; + }); const readCanonicalProjection = Effect.fn("ProjectionStore.readCanonicalProjection")( function* ( threadId: ThreadId, - window?: { - readonly rowLimit: number; - readonly userTurnLimit?: number | undefined; - readonly anchorItemId?: TurnItemId | undefined; - readonly requiredRunId?: RunId | undefined; - readonly suppressLocal?: boolean | undefined; - }, + window?: ProjectionWindow, fields?: ReadonlyArray, filter?: ProjectionRecordFilter, ) { @@ -2888,7 +2953,12 @@ export const layer: Layer.Layer = return yield* new ProjectionStoreThreadNotFoundError({ threadId }); } - const boundedTurnItemRows = + type WindowPayloadRow = PayloadRow & { + readonly work_last_id?: string | null; + readonly work_last_ordinal?: number | null; + readonly work_item_count?: number | null; + }; + const boundedTurnItemRows: ReadonlyArray = (fields !== undefined && !fields.includes("turnItems")) || (window === undefined && matchesNothing(filter?.turnItemTypes, filter?.turnItemStatuses, filter?.turnItemRunIds)) @@ -2905,18 +2975,51 @@ export const layer: Layer.Layer = ORDER BY ordinal ASC, turn_item_id ASC ` : yield* sql` - ${retainedWindowRows(threadId, window)} + ${yield* retainedWindowRows(threadId, window)} -- CROSS JOIN keeps the sorted IDs as the outer loop, so SQLite -- skips sorting again once the payloads are attached. Carry the -- rowid through selection to avoid a second string-key index lookup. - SELECT item.payload_json + SELECT item.payload_json, tail.turn_item_id AS work_last_id, + deferred.last_ordinal AS work_last_ordinal, deferred.item_count AS work_item_count FROM retained CROSS JOIN orchestration_v2_projection_turn_items AS item ON item.rowid = retained.item_rowid + LEFT JOIN deferred_work deferred ON deferred.run_id = item.run_id + AND deferred.first_ordinal = item.ordinal + LEFT JOIN orchestration_v2_projection_turn_items tail + ON tail.turn_item_id = ( + SELECT turn_item_id FROM orchestration_v2_projection_turn_items + WHERE thread_id = item.thread_id AND ordinal = deferred.last_ordinal + ORDER BY turn_item_id DESC LIMIT 1 + ) ORDER BY retained.ordinal ASC, retained.turn_item_id ASC `; // Decode items once for cohort ids and projection; repeated parsing duplicates retained tool output. - const turnItems = yield* decodeRows(decodeTurnItemPayload, threadId)(boundedTurnItemRows); + const decodedTurnItems = yield* decodeRows( + decodeTurnItemPayload, + threadId, + )(boundedTurnItemRows); + const turnItems = decodedTurnItems.map((item, index) => { + const row = boundedTurnItemRows[index]!; + return row.work_last_id == null || + row.work_item_count == null || + row.work_last_ordinal == null + ? item + : { + ...item, + deferredWork: { + cursor: encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: TurnItemId.make(row.work_last_id), + position: row.work_last_ordinal, + inclusive: true, + }), + itemCount: row.work_item_count, + lastOrdinal: row.work_last_ordinal, + }, + }; + }); const cohortJson = (field: string) => JSON.stringify( turnItems.flatMap((payload) => { @@ -3438,32 +3541,14 @@ export const layer: Layer.Layer = const readProjection: ( threadId: ThreadId, seenThreadIds: ReadonlySet, - window?: { - readonly rowLimit: number; - readonly userTurnLimit?: number | undefined; - readonly anchorItemId?: TurnItemId | undefined; - readonly requiredRunId?: RunId | undefined; - readonly suppressLocal?: boolean | undefined; - readonly historyAnchor?: - | { readonly threadId: ThreadId; readonly itemId: TurnItemId } - | undefined; - }, + window?: ProjectionWindow, ) => Effect.Effect = Effect.fn( "ProjectionStore.readProjection", )( function* ( threadId: ThreadId, seenThreadIds: ReadonlySet, - window?: { - readonly rowLimit: number; - readonly userTurnLimit?: number | undefined; - readonly anchorItemId?: TurnItemId | undefined; - readonly requiredRunId?: RunId | undefined; - readonly suppressLocal?: boolean | undefined; - readonly historyAnchor?: - | { readonly threadId: ThreadId; readonly itemId: TurnItemId } - | undefined; - }, + window?: ProjectionWindow, ) { const localWindow = window?.suppressLocal === true || @@ -3569,6 +3654,7 @@ export const layer: Layer.Layer = return { rowLimit: window.rowLimit, userTurnLimit: window.userTurnLimit, + deferWork: window.deferWork, requiredRunId: forkedFrom.runId, suppressLocal: anchor === undefined || anchorIsInDescendant, ...(anchor === undefined || anchorIsInDescendant @@ -3591,7 +3677,7 @@ export const layer: Layer.Layer = .length >= Math.max(window.userTurnLimit, THREAD_HISTORY_PAGE_POLICY.maxUserTurns) ) { const counted = yield* sql<{ readonly inherited_rows: number | null }>` - ${retainedWindowRows(forkedFrom.threadId, { + ${yield* retainedWindowRows(forkedFrom.threadId, { ...sourceWindow, rowLimit: sourceWindow.suppressLocal ? 0 : sourceWindow.rowLimit, })} @@ -5196,7 +5282,7 @@ export const layer: Layer.Layer = type TimelineIndexItem = Pick< OrchestrationV2TurnItem, - "id" | "threadId" | "runId" | "nodeId" | "type" + "id" | "threadId" | "runId" | "nodeId" | "parentItemId" | "type" | "status" | "ordinal" > & { readonly inputIntent?: Extract< OrchestrationV2TurnItem, @@ -5204,6 +5290,11 @@ export const layer: Layer.Layer = >["inputIntent"]; readonly createdBy?: "user" | "agent" | "system"; readonly messageId?: MessageId; + readonly runStatus?: string | undefined; + readonly attemptId?: string | null; + readonly attemptStatus?: string | null; + readonly persistentOutput?: boolean; + readonly deferredWork?: OrchestrationV2TurnItem["deferredWork"]; }; type TimelineIndexRow = { readonly sourceThreadId: ThreadId; @@ -5215,6 +5306,7 @@ export const layer: Layer.Layer = const readTimelineIndex: ( threadId: ThreadId, seen: ReadonlySet, + includePersistentOutput?: boolean, ) => Effect.Effect< { readonly records: ProjectionRecords<"runs" | "attempts">; @@ -5223,28 +5315,58 @@ export const layer: Layer.Layer = }, ProjectionStoreV2Error > = Effect.fn("ProjectionStore.readTimelineIndex")( - function* (threadId: ThreadId, seen: ReadonlySet) { + function* (threadId: ThreadId, seen: ReadonlySet, includePersistentOutput = false) { const records = yield* getThreadRecords(threadId, ["runs", "attempts"]); const rows = yield* sql<{ turn_item_id: string; run_id: string | null; node_id: string | null; + parent_item_id: string | null; type: OrchestrationV2TurnItem["type"]; input_intent: NonNullable | null; created_by: "user" | "agent" | "system" | null; message_id: string | null; - }>`SELECT turn_item_id, run_id, node_id, type, - CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.inputIntent') END AS input_intent, - CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.createdBy') END AS created_by, - CASE WHEN type IN ('user_message', 'assistant_message') THEN json_extract(payload_json, '$.messageId') END AS message_id - FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} - ORDER BY ordinal ASC, turn_item_id ASC`; + status: OrchestrationV2TurnItem["status"]; + ordinal: number; + persistent_output: number; + attempt_id: string | null; + attempt_status: string | null; + }>`SELECT item.turn_item_id, item.run_id, item.node_id, item.parent_item_id, item.type, item.status, item.ordinal, + ${includePersistentOutput ? sql`attempt.attempt_id` : sql`NULL`} AS attempt_id, + ${includePersistentOutput ? sql`attempt.status` : sql`NULL`} AS attempt_status, + ${ + includePersistentOutput ? sql`CASE WHEN ${inlineWorkOutput} THEN 1 ELSE 0 END` : sql`0` + } AS persistent_output, + CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.inputIntent') END AS input_intent, + CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.createdBy') END AS created_by, + CASE WHEN item.type IN ('user_message', 'assistant_message') THEN json_extract(item.payload_json, '$.messageId') END AS message_id + FROM orchestration_v2_projection_turn_items AS item + ${ + includePersistentOutput + ? sql`LEFT JOIN orchestration_v2_projection_nodes AS node ON node.node_id = item.node_id + LEFT JOIN orchestration_v2_projection_run_attempts AS attempt ON attempt.attempt_id = ( + SELECT attempt_id FROM orchestration_v2_projection_run_attempts + WHERE run_id = item.run_id AND root_node_id = COALESCE(node.root_node_id, item.node_id) + ORDER BY attempt_ordinal DESC LIMIT 1 + )` + : sql`` + } + WHERE item.thread_id = ${threadId} + ORDER BY item.ordinal ASC, item.turn_item_id ASC`; + const runStatuses = new Map(records.runs.map((run) => [String(run.id), run.status])); const items: Array = rows.map((row) => ({ id: TurnItemId.make(row.turn_item_id), threadId, runId: row.run_id === null ? null : RunId.make(row.run_id), nodeId: row.node_id === null ? null : NodeId.make(row.node_id), + parentItemId: row.parent_item_id === null ? null : TurnItemId.make(row.parent_item_id), type: row.type, + status: row.status, + ordinal: row.ordinal, + runStatus: row.run_id === null ? undefined : runStatuses.get(row.run_id), + attemptId: row.attempt_id, + attemptStatus: row.attempt_status, + persistentOutput: row.persistent_output === 1, ...(row.input_intent === null ? {} : { inputIntent: row.input_intent }), ...(row.created_by === null ? {} : { createdBy: row.created_by }), ...(row.message_id === null ? {} : { messageId: MessageId.make(row.message_id) }), @@ -5263,7 +5385,11 @@ export const layer: Layer.Layer = const visible = local.filter((row) => isVisible(row.item)); const fork = records.thread.forkedFrom; if (fork?.type !== "run" || seen.has(fork.threadId)) return { records, local, visible }; - const source = yield* readTimelineIndex(fork.threadId, new Set([...seen, threadId])); + const source = yield* readTimelineIndex( + fork.threadId, + new Set([...seen, threadId]), + includePersistentOutput, + ); const sourceRun = source.records.runs.find((run) => run.id === fork.runId); let inherited: Array = []; if (sourceRun !== undefined) { @@ -5329,11 +5455,12 @@ export const layer: Layer.Layer = cursor, throughEntryId, conversationOnly, + workMode, ) => sql .withTransaction( Effect.gen(function* () { - const index = yield* readTimelineIndex(threadId, new Set()); + const index = yield* readTimelineIndex(threadId, new Set(), workMode !== undefined); if (index.records.thread.deletedAt !== null) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); const revisions = yield* sql<{ @@ -5347,21 +5474,32 @@ export const layer: Layer.Layer = position, canonicalPosition: position, })); + const historyItems = workMode === "deferred" ? deferWorkHistoryRows(indexed) : indexed; const selection = yield* Effect.try({ try: () => { const policy = olderHistoryPagePolicy({ items: indexed, cursor, throughEntryId }); - const page = selectHistoryPageFromCursor({ - items: indexed, - cursor, - snapshotSequence, - rowEncodedBytes: () => 0, - policy, - }); + const page = + workMode === "work" + ? selectDeferredWorkPage({ + items: indexed, + cursor, + throughEntryId, + snapshotSequence, + rowEncodedBytes: () => 0, + }) + : selectHistoryPageFromCursor({ + items: historyItems, + cursor, + snapshotSequence, + rowEncodedBytes: () => 0, + policy, + }); return { policy, page }; }, catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), }); - const { policy, page } = selection; + const { page } = selection; + const policy = workMode === "work" ? THREAD_HISTORY_PAGE_POLICY : selection.policy; const end = (page.items.at(-1)?.canonicalPosition ?? -1) + 1; const hasUserTurns = index.visible .slice(0, end) @@ -5400,7 +5538,11 @@ export const layer: Layer.Layer = visibility: row.visibility, sourceThreadId: row.sourceThreadId, sourceItemId: row.sourceItemId, - item: projectTurnItemForWire(item), + item: projectTurnItemForWire( + row.item.deferredWork === undefined + ? item + : { ...item, deferredWork: row.item.deferredWork }, + ), }; }), ); @@ -5411,7 +5553,10 @@ export const layer: Layer.Layer = : page.items, ); const bounded = selectRecentTimelineWindow({ items, snapshotSequence, policy }); - const requests = yield* materialize(missingInterruptRequests(indexed, bounded.items)); + const requests = + workMode === "work" + ? [] + : yield* materialize(missingInterruptRequests(indexed, bounded.items)); const pageItems = [...requests, ...bounded.items].map((row, position) => ({ ...row, position, @@ -5517,6 +5662,7 @@ export const layer: Layer.Layer = const projection = yield* readProjection(threadId, new Set(), { rowLimit: options.rowLimit, userTurnLimit: options.userTurnLimit, + deferWork: options.deferWork, ...(historyAnchor?.threadId === threadId ? { anchorItemId: historyAnchor.itemId } : {}), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 917fdc807c2f8..615ea352f25af 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -1278,10 +1278,16 @@ const make = Effect.gen(function* () { }), dispatch, getThreadHistoryPage: Effect.fn("ThreadManagementService.getThreadHistoryPage")( - function (threadId, cursor, throughEntryId, conversationOnly) { + function (threadId, cursor, throughEntryId, conversationOnly, workMode) { return ensureProjectionTranscript(threadId).pipe( Effect.andThen( - orchestrator.getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly), + orchestrator.getThreadHistoryPage( + threadId, + cursor, + throughEntryId, + conversationOnly, + workMode, + ), ), ); }, diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 9d3e583b832e0..61ff54d0c794f 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -133,10 +133,12 @@ export const layer = HttpApiBuilder.group( anchorThreadId?: Parameters< typeof threadManagement.getThreadSnapshotWindow >[1]["anchorThreadId"], + deferWork = false, ) { return yield* threadManagement .getThreadSnapshotWindow(threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, + deferWork, userTurnLimit: anchorItemId === undefined ? THREAD_HISTORY_PAGE_POLICY.maxUserTurns @@ -223,9 +225,12 @@ export const layer = HttpApiBuilder.group( Effect.fn("environment.orchestration.threadBoundedSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - const snapshot = yield* loadThreadSnapshotWindow(args.params.threadId).pipe( - traceLocalHandlerWork, - ); + const snapshot = yield* loadThreadSnapshotWindow( + args.params.threadId, + undefined, + undefined, + args.query.deferWork === "1", + ).pipe(traceLocalHandlerWork); const bounded = buildBoundedThreadProjection({ projection: snapshot.projection, snapshotSequence: snapshot.snapshotSequence, @@ -261,6 +266,11 @@ export const layer = HttpApiBuilder.group( args.query.cursor, args.query.throughEntryId, args.query.view === "conversation", + args.query.view === "work" + ? "work" + : args.query.deferWork === "1" + ? "deferred" + : undefined, ) .pipe( Effect.map((page) => diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 38ad3b5c7de13..f4e54000222fc 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -12,12 +12,15 @@ import * as DateTime from "effect/DateTime"; import { buildBoundedThreadProjection, + deferWorkHistoryRows, boundedTimelineEncodedBytes, computeLatestLocalTurnOrdinal, + decodeThreadHistoryCursor, encodeThreadHistoryCursor, projectedRowBoundedSnapshotEncodedBytes, projectedRowEncodedBytes, selectHistoryPageFromCursor, + selectDeferredWorkPage, selectRecentTimelineWindow, THREAD_HISTORY_PAGE_POLICY, } from "./threadHistoryPaging.ts"; @@ -155,6 +158,76 @@ function makeProjection(visibleTurnItems: OrchestrationV2ProjectedTurnItem[]) { } describe("threadHistoryPaging", () => { + it("defers completed work above 150 items and keeps conversation and persistent rows", () => { + const work = Array.from({ length: 151 }, (_, index) => { + const row = makeRow(index); + return { ...row, item: { ...row.item, runId: RUN, runStatus: "completed" } }; + }); + expect(deferWorkHistoryRows(work.slice(0, 150))).toHaveLength(150); + const deferredWork = deferWorkHistoryRows(work)[0]!.item.deferredWork!; + expect(deferredWork.itemCount).toBe(151); + expect(decodeThreadHistoryCursor(deferredWork.cursor).p).toBe(151); + const superseded = work.map((row) => ({ + ...row, + item: { ...row.item, runStatus: "running", attemptId: "old", attemptStatus: "superseded" }, + })); + const active = work.map((row, index) => ({ + ...row, + sourceItemId: TurnItemId.make(`active-${index}`), + position: row.position + work.length, + item: { ...row.item, runStatus: "running", attemptId: "active", attemptStatus: "running" }, + })); + const attempts = deferWorkHistoryRows([...superseded, ...active]); + expect(attempts[0]?.item.deferredWork?.itemCount).toBe(151); + expect(attempts.slice(1)).toEqual(active); + const compaction = { + ...work[0]!, + sourceItemId: TurnItemId.make("compaction"), + item: { + ...work[0]!.item, + type: "compaction" as const, + }, + }; + const persistent = { + ...work[0]!, + sourceItemId: TurnItemId.make("render"), + item: { + ...work[0]!.item, + type: "dynamic_tool" as const, + persistentOutput: true, + }, + }; + const deferred = deferWorkHistoryRows([ + ...work.slice(0, 30), + compaction, + persistent, + ...work.slice(30), + ]); + expect(deferred.map((row) => row.sourceItemId)).toEqual(["item-0", "compaction", "render"]); + expect(deferred[0]?.item.deferredWork?.itemCount).toBe(151); + for (const runStatus of ["running", "failed", "interrupted", "cancelled"]) { + expect( + deferWorkHistoryRows(work.map((row) => ({ ...row, item: { ...row.item, runStatus } }))), + ).toHaveLength(151); + } + const interrupted = { + ...compaction, + item: { ...compaction.item, type: "run_interrupt_result" as const }, + }; + expect(deferWorkHistoryRows([...work, interrupted])).toHaveLength(152); + const failed = { + ...compaction, + item: { + ...compaction.item, + type: "error" as const, + status: "failed" as const, + nodeId: "root-node", + parentItemId: null, + }, + }; + expect(deferWorkHistoryRows([...work, failed])).toHaveLength(152); + }); + it("keeps a human turn whole up to the turn row budget and pages a longer one from inside", () => { const humanTurn = (length: number) => { const items = Array.from({ length }, (_, index) => makeRow(index)); @@ -394,6 +467,39 @@ describe("threadHistoryPaging", () => { expect(page.items.map((row) => row.sourceItemId)).toEqual(["item-3", "item-4"]); expect(page.hasMoreHistory).toBe(true); expect(page.nextCursor).not.toBeNull(); + const sparse = [makeRow(0), makeRow(10), makeRow(20)]; + const middle = selectHistoryPageFromCursor({ + items: sparse, + cursor: encodeThreadHistoryCursor({ + snapshotSequence: 3, + sourceThreadId: THREAD, + sourceItemId: "item-hidden", + position: 15, + }), + snapshotSequence: 3, + }); + expect(middle.items.map((row) => row.sourceItemId)).toEqual(["item-0", "item-10"]); + const work = Array.from({ length: 80 }, (_, index) => { + const row = makeRow(index); + return { ...row, item: { ...row.item, runId: RUN, ordinal: index + 100 } }; + }); + const tail = work[75]!; + const deferred = selectDeferredWorkPage({ + items: work.filter((row) => row !== tail).map((row, position) => ({ ...row, position })), + cursor: encodeThreadHistoryCursor({ + snapshotSequence: 3, + sourceThreadId: THREAD, + sourceItemId: tail.sourceItemId, + position: tail.item.ordinal, + inclusive: true, + }), + throughEntryId: work[0]!.sourceItemId, + snapshotSequence: 3, + }); + expect(deferred.items.map((row) => row.sourceItemId)).toEqual( + work.slice(0, 75).map((row) => row.sourceItemId), + ); + expect(deferred.nextCursor).toBeNull(); }); it("keeps cursor resolution stable when newer items append", () => { diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 146436fc3ca57..7f1ea4db95d6f 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -19,6 +19,8 @@ export const THREAD_HISTORY_PAGE_POLICY = { maxEncodedBytes: 1_048_576, } as const; +export const THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT = 150; + export const OLDER_THREAD_USER_TURN_LIMIT = 20; export function isConversationHistoryItem(item: Pick): boolean { @@ -46,6 +48,7 @@ export type ThreadHistoryCursorPayload = { readonly st: string; readonly si: string; readonly p: number; + readonly inclusive?: boolean; }; type HistoryRow = Pick< @@ -58,6 +61,14 @@ type HistoryRow = Pick< readonly inputIntent?: string; readonly createdBy?: string; readonly runId?: string | null; + readonly status?: OrchestrationV2TurnItem["status"]; + readonly parentItemId?: string | null; + readonly ordinal?: number; + readonly runStatus?: string | undefined; + readonly attemptId?: string | null | undefined; + readonly attemptStatus?: string | null | undefined; + readonly persistentOutput?: boolean; + readonly deferredWork?: OrchestrationV2TurnItem["deferredWork"]; }; }; export type SelectTimelinePageResult = { @@ -114,6 +125,7 @@ export function encodeThreadHistoryCursor(input: { readonly sourceThreadId: ThreadId | string; readonly sourceItemId: TurnItemId | string; readonly position: number; + readonly inclusive?: boolean; }): string { return encodeCursorPayload({ v: 1, @@ -121,6 +133,7 @@ export function encodeThreadHistoryCursor(input: { st: String(input.sourceThreadId), si: String(input.sourceItemId), p: input.position, + ...(input.inclusive ? { inclusive: true } : {}), }); } @@ -153,7 +166,8 @@ export function decodeThreadHistoryCursor(cursor: string): ThreadHistoryCursorPa !("p" in parsed) || typeof parsed.p !== "number" || !Number.isInteger(parsed.p) || - parsed.p < 0 + parsed.p < 0 || + ("inclusive" in parsed && typeof parsed.inclusive !== "boolean") ) { throw new InvalidThreadHistoryCursorError(); } @@ -167,6 +181,109 @@ function isThreadHistoryTurnStart(item: HistoryRow["item"]): boolean { ); } +const DEFERRED_WORK_ITEM_TYPES = [ + "command_execution", + "reasoning", + "file_change", + "file_search", + "web_search", + "dynamic_tool", +] as const; + +function isDeferrableWorkItem(item: HistoryRow["item"]): boolean { + return ( + item.status === "completed" && + item.persistentOutput !== true && + DEFERRED_WORK_ITEM_TYPES.some((type) => type === item.type) + ); +} + +export function deferWorkHistoryRows(items: ReadonlyArray): Row[] { + const blocked = new Set( + items.flatMap(({ item }) => + item.runId != null && + (item.type === "run_interrupt_request" || + item.type === "run_interrupt_result" || + (item.type === "error" && item.status === "failed" && item.parentItemId == null)) + ? [item.runId] + : [], + ), + ); + const segments = new Map(); + const groups = new Map(); + for (const row of items) { + const item = row.item; + if (item.runId == null) continue; + if (item.type === "user_message") segments.set(item.runId, (segments.get(item.runId) ?? 0) + 1); + if ( + (item.runStatus !== "completed" && item.attemptStatus !== "superseded") || + blocked.has(item.runId) || + !isDeferrableWorkItem(item) + ) + continue; + const key = `${row.sourceThreadId}:${item.runId}:${segments.get(item.runId) ?? 0}:${item.attemptId ?? ""}`; + const group = groups.get(key) ?? []; + group.push(row); + groups.set(key, group); + } + const omitted = new Set(); + const anchors = new Map(); + for (const group of groups.values()) { + if (group.length <= THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT) continue; + const first = group[0]!; + const last = group.at(-1)!; + for (const row of group.slice(1)) omitted.add(row); + anchors.set(first, { + ...first, + item: { + ...first.item, + deferredWork: { + cursor: encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: last.sourceThreadId, + sourceItemId: last.sourceItemId, + position: last.item.ordinal ?? last.position, + inclusive: true, + }), + itemCount: group.length, + lastOrdinal: last.item.ordinal ?? last.position, + }, + }, + }); + } + return items.flatMap((row) => (omitted.has(row) ? [] : [anchors.get(row) ?? row])); +} + +export function selectDeferredWorkPage(input: { + readonly items: ReadonlyArray; + readonly cursor: string; + readonly throughEntryId?: string | undefined; + readonly snapshotSequence: number; + readonly rowEncodedBytes?: ((row: Row) => number) | undefined; +}): SelectTimelinePageResult { + const cursor = decodeThreadHistoryCursor(input.cursor); + const end = findCursorEndIndex(input.items, cursor); + const start = input.items.findIndex((row) => row.sourceItemId === input.throughEntryId); + if (start < 0 || start >= end) throw new InvalidThreadHistoryCursorError(); + const anchor = input.items[start]!; + const items = input.items + .slice(start, end) + .filter( + (row) => + row.sourceThreadId === anchor.sourceThreadId && + row.item.runId === anchor.item.runId && + row.item.attemptId === anchor.item.attemptId && + isDeferrableWorkItem(row.item), + ); + return selectOlderTimelinePage({ + items, + exclusiveEndIndex: items.length, + snapshotSequence: input.snapshotSequence, + rowEncodedBytes: input.rowEncodedBytes, + policy: THREAD_HISTORY_PAGE_POLICY, + }); +} + /** Notifications can start wake turns when they are the run's first input; match ProjectionStore run_starts. */ function threadHistoryTurnStarts(items: ReadonlyArray): ReadonlyArray { const startedRuns = new Set(); @@ -275,17 +392,23 @@ export function selectRecentTimelineWindow(input: { }); } -function findCursorIndex( - items: ReadonlyArray>, +function findCursorEndIndex( + items: ReadonlyArray, cursor: ThreadHistoryCursorPayload, ): number { const byIdentity = items.findIndex( (row) => String(row.sourceThreadId) === cursor.st && String(row.sourceItemId) === cursor.si, ); if (byIdentity !== -1) { - return byIdentity; + return byIdentity + (cursor.inclusive ? 1 : 0); } - return Math.min(cursor.p, items.length); + // Inclusive deferred cursors carry source ordinals; ordinary cursors carry visible positions. + const byPosition = items.findIndex((row) => + cursor.inclusive + ? String(row.sourceThreadId) === cursor.st && (row.item.ordinal ?? row.position) > cursor.p + : row.position >= cursor.p, + ); + return byPosition === -1 ? items.length : byPosition; } /** @@ -304,7 +427,7 @@ export function olderHistoryPagePolicy(input: { maxUserTurns: OLDER_THREAD_USER_TURN_LIMIT, }; if (input.throughEntryId === undefined) return policy; - const anchorIndex = findCursorIndex(input.items, decodeThreadHistoryCursor(input.cursor)); + const anchorIndex = findCursorEndIndex(input.items, decodeThreadHistoryCursor(input.cursor)); const targetIndex = input.items.findIndex( (row) => (row.item.messageId ?? row.item.id) === input.throughEntryId, ); @@ -337,7 +460,7 @@ export function selectHistoryPageFromCursor(input: { const decoded = decodeThreadHistoryCursor(input.cursor); return selectOlderTimelinePage({ items: input.items, - exclusiveEndIndex: findCursorIndex(input.items, decoded), + exclusiveEndIndex: findCursorEndIndex(input.items, decoded), snapshotSequence: input.snapshotSequence, ...(input.rowEncodedBytes === undefined ? {} : { rowEncodedBytes: input.rowEncodedBytes }), policy: olderHistoryPagePolicy(input), @@ -351,7 +474,7 @@ export function selectHistoryPageFromCursor(input: { */ export function missingInterruptRequests( items: ReadonlyArray, - page: ReadonlyArray>, + page: ReadonlyArray>, ): Row[] { const runs = new Set(); for (const row of page) { @@ -407,16 +530,7 @@ function localTurnItemsForVisibleWindow( projection: OrchestrationV2ThreadProjection, visible: ReadonlyArray, ): OrchestrationV2TurnItem[] { - const localIds = new Set(); - for (const row of visible) { - if (isLocalProjectedRow(projection, row)) { - localIds.add(String(row.sourceItemId)); - } - } - if (localIds.size === 0) { - return []; - } - return projection.turnItems.filter((item) => localIds.has(String(item.id))); + return visible.filter((row) => isLocalProjectedRow(projection, row)).map((row) => row.item); } function messagesForBoundedProjection( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index bea3b8a46097a..e1d6303b23506 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -640,6 +640,7 @@ function hasCompatibleOrchestrationProtocol(url: URL): boolean { function shouldUseBoundedThreadSnapshot(input: { readonly acceptBoundedSnapshot?: boolean; + readonly acceptDeferredWork?: boolean; }): boolean { return input.acceptBoundedSnapshot === true; } @@ -773,6 +774,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; + readonly acceptDeferredWork?: boolean; readonly acceptCompactTurnItems?: boolean; readonly acceptDedupedBodies?: boolean; readonly acceptCompactIds?: boolean; @@ -860,6 +862,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs useBoundedSnapshot ? threadManagement.getThreadSnapshotWindow(input.threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, + deferWork: input.acceptDeferredWork === true, userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, }) : threadManagement.getThreadSnapshot(input.threadId) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index aa082a268667d..3ee079809f14e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2693,6 +2693,13 @@ describe("v2 run and attempt history", () => { runId, label: "Old command", tone: "tool" as const, + projectedItem: { + sourceItemId: "old-work-anchor", + item: { + type: "command_execution", + deferredWork: { cursor: "old-work", itemCount: 200, lastOrdinal: 200 }, + }, + } as never, }, }, { @@ -2765,6 +2772,7 @@ describe("v2 run and attempt history", () => { runId, label: "Superseded attempt", expanded: false, + deferredWorkItemIds: ["old-work-anchor"], }); const expandedRows = deriveMessagesTimelineRows({ @@ -2781,6 +2789,52 @@ describe("v2 run and attempt history", () => { "steer-user-entry", "active-assistant-entry", ]); + const settledRows = deriveMessagesTimelineRows({ + ...common, + latestRun: { ...common.latestRun, status: "completed", completedAt: "2026-01-01T00:00:08Z" }, + timelineEntries: timelineEntries.map((entry) => + entry.kind === "message" && entry.id === "active-assistant-entry" + ? { ...entry, message: { ...entry.message, streaming: false } } + : entry, + ), + expandedRunIds: new Set([runId]), + }); + expect(settledRows.find((row) => row.kind === "attempt-fold")).toMatchObject({ + deferredWorkItemIds: ["old-work-anchor"], + }); + expect( + settledRows + .filter((row) => row.kind === "turn-fold") + .flatMap((row) => row.deferredWorkItemIds ?? []), + ).not.toContain("old-work-anchor"); + const deferredTailRows = deriveMessagesTimelineRows({ + ...common, + timelineEntries: [ + timelineEntries[0]!, + timelineEntries[2]!, + timelineEntries[1]!, + ...timelineEntries.slice(3), + ].map((entry) => ({ + ...entry, + attempt: + entry.attempt.id === supersededAttemptId + ? { ...entry.attempt, completedAt: DateTime.makeUnsafe("2026-01-01T00:00:08Z") } + : entry.attempt, + ...(entry.id === "superseded-assistant-entry" + ? { + projectedItem: { + item: { + type: "assistant_message", + ordinal: 2, + status: "completed", + completedAt: DateTime.makeUnsafe("2026-01-01T00:00:03Z"), + }, + } as never, + } + : {}), + })), + }); + expect(deferredTailRows.some((row) => row.kind === "attempt-fold")).toBe(true); }); describe("a steer that supersedes an attempt", () => { @@ -3015,6 +3069,35 @@ describe("v2 run and attempt history", () => { }); }); +it.each([ + [undefined, ["first"]], + [["first"], ["second"]], + [["first"], []], + [ + ["first", "second"], + ["second", "first"], + ], + [undefined, []], +] as const)("keeps deferred fold anchors current from %j to %j", (before, after) => { + const row: Extract = { + kind: "turn-fold", + id: "fold", + runId: RunId.make("run"), + createdAt: "2026-01-01T00:00:00Z", + label: "Worked for 1m", + expanded: true, + ...(before === undefined ? {} : { deferredWorkItemIds: before }), + }; + const previous = computeStableMessagesTimelineRows([row], { result: [], byId: new Map() }); + const nextRow = { ...row, deferredWorkItemIds: after }; + const next = computeStableMessagesTimelineRows([nextRow], previous); + const fold = next.result[0]; + expect(fold?.kind === "turn-fold" ? (fold.deferredWorkItemIds ?? []) : null).toEqual(after); + expect( + computeStableMessagesTimelineRows([{ ...nextRow, deferredWorkItemIds: [...after] }], next), + ).toBe(next); +}); + describe("streaming v2 row projection", () => { function fixture(text = "") { const source = makeStreamingTimelineFixture(text); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ac9db2f7dd794..4ea9e852edfba 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -553,6 +553,7 @@ type MessagesTimelineRowContent = } | { kind: "turn-fold"; + deferredWorkItemIds?: ReadonlyArray; id: string; createdAt: string; runId: RunId; @@ -561,6 +562,7 @@ type MessagesTimelineRowContent = } | { kind: "attempt-fold"; + deferredWorkItemIds?: ReadonlyArray; id: string; createdAt: string; runId: RunId; @@ -709,6 +711,7 @@ interface TurnFold { } interface SupersededAttemptFold { + readonly deferredWorkItemIds: ReadonlyArray; readonly runId: RunId; readonly attemptId: RunAttemptId; readonly anchorEntryId: string; @@ -731,6 +734,18 @@ function completeSupersededReplyEntryIds( ) { continue; } + const previous = lastOutputByAttemptId.get(entry.attempt.id); + const deferredEnd = + previous?.kind === "work" + ? previous.entry.projectedItem?.item.deferredWork?.lastOrdinal + : undefined; + const ordinal = + entry.kind === "work" + ? entry.entry.projectedItem?.item.ordinal + : entry.kind === "message" + ? entry.projectedItem?.item.ordinal + : undefined; + if (deferredEnd !== undefined && ordinal !== undefined && deferredEnd > ordinal) continue; lastOutputByAttemptId.set(entry.attempt.id, entry); } const replyEntryIds = new Set(); @@ -790,6 +805,11 @@ function deriveSupersededAttemptFolds( const attempt = firstEntry?.attempt; if (firstEntry === undefined || attempt === undefined) continue; foldsByAnchorEntryId.set(firstEntry.id, { + deferredWorkItemIds: entries.flatMap((entry) => + entry.kind === "work" && entry.entry.projectedItem?.item.deferredWork + ? [String(entry.entry.projectedItem.sourceItemId)] + : [], + ), runId: attempt.runId, attemptId: attempt.id, anchorEntryId: firstEntry.id, @@ -1398,6 +1418,9 @@ export function deriveMessagesTimelineRows(input: { liveSubagentEntryIds, completeSupersededReplyIds, ); + const supersededWorkItemIds = new Set( + [...supersededFoldsByAnchorEntryId.values()].flatMap((fold) => fold.deferredWorkItemIds), + ); const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({ timelineEntries: timelineEntries, unsettledRunId, @@ -1658,9 +1681,18 @@ export function deriveMessagesTimelineRows(input: { const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); if (turnFold) { + const deferredWorkItemIds = timelineEntries.flatMap((entry) => + turnFold.entryIds.has(entry.id) && + entry.kind === "work" && + entry.entry.projectedItem?.item.deferredWork && + !supersededWorkItemIds.has(String(entry.entry.projectedItem.sourceItemId)) + ? [String(entry.entry.projectedItem.sourceItemId)] + : [], + ); nextRows.push({ kind: "turn-fold", id: `turn-fold:${turnFold.runId}`, + ...(deferredWorkItemIds.length > 0 ? { deferredWorkItemIds } : {}), createdAt: turnFold.createdAt, runId: turnFold.runId, label: turnFold.label, @@ -1676,6 +1708,7 @@ export function deriveMessagesTimelineRows(input: { if (supersededFold) { nextRows.push({ kind: "attempt-fold", + deferredWorkItemIds: supersededFold.deferredWorkItemIds, id: `attempt-fold:${supersededFold.attemptId}`, createdAt: supersededFold.createdAt, runId: supersededFold.runId, @@ -2326,14 +2359,18 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean ); } + case "attempt-fold": case "turn-fold": { const bf = b as typeof a; - return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; - } - - case "attempt-fold": { - const bf = b as typeof a; - return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; + const anchors = a.deferredWorkItemIds ?? []; + const nextAnchors = bf.deferredWorkItemIds ?? []; + return ( + a.createdAt === bf.createdAt && + a.label === bf.label && + a.expanded === bf.expanded && + anchors.length === nextAnchors.length && + anchors.every((id, index) => id === nextAnchors[index]) + ); } case "context-compaction": { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b74f9edd595c9..8a621e47eaba7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,3 +1,5 @@ +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; import type { ToolCallDiffTarget } from "../../diffPanelStore"; import { AudioPreviewUnmountContext } from "../files/AudioPreview"; import { ThreadFindTimelineContext } from "./ThreadFindProvider"; @@ -36,6 +38,8 @@ import { use, useContext, useLayoutEffect, + useEffect, + useEffectEvent, useRef, useState, type ReactNode, @@ -52,6 +56,7 @@ import { type ChatFileAttachment, type TurnDiffSummary } from "../../types"; import { ArrowRightIcon, BotIcon, CircleAlertIcon, Minimize2Icon } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; +import { Skeleton } from "../ui/skeleton"; import { ExpandedImagePreview } from "./ExpandedImagePreview"; import { CHAT_TIMELINE_ANCHOR_OFFSET, @@ -1517,7 +1522,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "thinking" ? : null} ) : null} - {row.kind === "turn-fold" ? : null} + {row.kind === "turn-fold" ? : null} {row.kind === "attempt-fold" ? : null} {row.kind === "context-compaction" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} @@ -1609,48 +1614,123 @@ function ContextCompactionTimelineRow({ ); } +function useDeferredWorkLoading( + row: { expanded: boolean; deferredWorkItemIds?: ReadonlyArray }, + onToggle: () => void, +) { + const ctx = use(TimelineRowCtx); + const loadWork = useAtomCommand(threadEnvironment.loadEarlierHistory, { reportFailure: false }); + const ref = parseScopedThreadKey(ctx.routeThreadKey); + const environmentId = ref?.environmentId; + const threadId = ref?.threadId; + const anchorsKey = row.deferredWorkItemIds?.join("\n") ?? ""; + const [failed, setFailed] = useState(false); + const load = () => { + if (!anchorsKey || environmentId === undefined || threadId === undefined) return; + void Promise.all( + anchorsKey + .split("\n") + .map((workAnchorId) => loadWork({ environmentId, input: { threadId, workAnchorId } })), + ).then((results) => + setFailed( + results.some((result) => result._tag === "Failure" || result.value._tag === "error"), + ), + ); + }; + const loadOnExpand = useEffectEvent(load); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restored expanded folds load their deferred server history on mount. + useEffect(() => { + if (row.expanded && anchorsKey && environmentId && threadId) loadOnExpand(); + }, [row.expanded, anchorsKey, environmentId, threadId]); + const error = anchorsKey && failed ? "Couldn't load work. Click to retry." : null; + const loading = ref !== null && row.expanded && anchorsKey !== "" && !failed; + const toggle = () => { + setFailed(false); + if (error && row.expanded) load(); + else onToggle(); + }; + + return { loading, error, toggle }; +} + +function WorkHistorySkeleton() { + return ( +
    + {[0, 1, 2].map((index) => ( + + ))} +
    + ); +} + function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const { loading, error, toggle } = useDeferredWorkLoading(row, () => + ctx.onToggleTurnFold(row.runId), + ); return ( -
    - - +
    +
    + + +
    + {loading ? : null}
    ); } function AttemptFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const { loading, error, toggle } = useDeferredWorkLoading(row, () => + ctx.onToggleAttemptFold(row.attemptId), + ); return ( - +
    + + {loading ? : null} +
    ); } diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index b67da2cc8f6cb..45a070b9806b1 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1284,7 +1284,7 @@ export function PullRequestDetailPanel({ openOnHostText={openOnHostText} onBack={onBack} /> -
    +
    {detail ? ( {!nativeStack && supportsStackActions && nativeStackQuery.error ? ( diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index de213de2f3c1c..f905376dc341a 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -557,6 +557,20 @@ describe("V2 session presentation", () => { if (threadCreatedEntry?.kind === "work") { expect(threadCreatedEntry.entry.projectedItem?.item.type).toBe("thread_created"); } + expect( + deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + { + ...visibleTurnItems.at(-1)!, + item: { + ...workspacePreparationItem, + deferredWork: { cursor: "work-page", itemCount: 100, lastOrdinal: 107 }, + }, + }, + ], + optimisticMessages: [], + }).map((entry) => entry.id), + ).toEqual([workspacePreparationItem.id]); }); it.each(["pending", "running", "completed"] as const)( diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 362d9fb9451a4..37d7ad27be5a7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -601,7 +601,7 @@ export function deriveTimelineEntriesFromVisibleTurnItems( ); for (const row of input.visibleTurnItems) { const { item } = row; - if (turnItemIsWorkspacePreparation(item)) continue; + if (turnItemIsWorkspacePreparation(item) && !item.deferredWork) continue; if (item.type === "checkpoint") continue; if ( item.type === "user_message" && diff --git a/docs/internals/performance-regressions.md b/docs/internals/performance-regressions.md index 51760756c1085..4458f1af93461 100644 --- a/docs/internals/performance-regressions.md +++ b/docs/internals/performance-regressions.md @@ -15,7 +15,9 @@ The v2 checks pin these invariants: - Cold opens and history pages stop at about 1 MiB of encoded rows. A window that holds a user turn also stops at 10 user turns (20 on older pages) or 500 rows, so ordinary turns stay whole. Any other window, including the inside of one turn longer than 500 rows, stops at 75 rows. The - snapshot query applies the same rule before it decodes payloads. Required control records and + snapshot query applies the same rule before it decodes payloads. Clients can opt into deferring + large work groups until expansion. Messages and items outside those groups remain in normal + history paging. Required control records and the at-least-one-row rule can exceed the byte target; it is not a hard cap. - The bounded snapshot does not retain the full duplicate conversation-message table. - Older activity is fetched in bounded HTTP pages and merged without disturbing the live scroll @@ -32,8 +34,9 @@ The v2 checks pin these invariants: projects small can increase server allocations on repeated large tool updates. - Raw command output, dynamic-tool result bodies, and inline file-change bodies are omitted at the wire boundary, including small outputs. Explicit failure flags and bounded result IDs - preserve status and grouped action counts without shipping those bodies. Subagent prompts and - results, reasoning text, command lines, search results, and checkpoint file lists still ship. + preserve status and grouped action counts without shipping those bodies. Reasoning text, + command lines, and search results ship when their work group loads. Subagent prompts and results + and checkpoint control records still accompany the initial window. Persisted events remain complete; the existing diff endpoints still provide file content when requested. Explicit cross-environment transcript exports preserve full tool bodies so the destination diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index b7cb95f703100..e3e9b5bb57fec 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -30,7 +30,12 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( }) { const endpoint = { params: { threadId: input.threadId }, - query: { compactTurnItems: "1", dedupeBodies: "1", compactIds: "1" }, + query: { + compactTurnItems: "1", + dedupeBodies: "1", + compactIds: "1", + deferWork: "1", + }, }; return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts index e0680c17c46f8..f6ba3e9f0c1a7 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts @@ -219,8 +219,9 @@ describe("applyOrchestrationV2ProjectionEvent", () => { expect(next?.visibleTurnItems[0]).toBe(visibleTurnItems[0]); }); - it("replaces only the updated visible item when membership is unchanged", () => { - const first = commandItem("item-first", "first"); + it("replaces only the updated visible item and retains its deferred work cursor", () => { + const deferredWork = { cursor: "remaining-work", itemCount: 100, lastOrdinal: 101 }; + const first = { ...commandItem("item-first", "first"), deferredWork }; const second = commandItem("item-second", "second"); const firstRow = { position: 0, @@ -253,9 +254,10 @@ describe("applyOrchestrationV2ProjectionEvent", () => { } as OrchestrationV2DomainEvent; const next = applyOrchestrationV2ProjectionEvent(projection, event); + expect(next?.visibleTurnItems[0]?.item.deferredWork).toEqual(deferredWork); expect(next?.visibleTurnItems).not.toBe(projection.visibleTurnItems); expect(next?.visibleTurnItems[0]).not.toBe(firstRow); - expect(next?.visibleTurnItems[0]?.item).toBe(updated); + expect(next?.visibleTurnItems[0]?.item).toEqual({ ...updated, deferredWork }); expect(next?.visibleTurnItems[1]).toBe(secondRow); }); diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index ffe9eb14dfa2d..fbba7d1bd9dd4 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -370,12 +370,15 @@ export function applyOrchestrationV2ProjectionEvent( case "plan.updated": return { ...base, plans: upsertEntity(base.plans, event.payload) }; case "turn-item.updated": { - const payload = restoreTurnItemBodies(event.payload, latestBodies(projection)); + let payload = restoreTurnItemBodies(event.payload, latestBodies(projection)); // Streamed updates land on the newest items, so search from the end. let itemIndex = projection.turnItems.length - 1; while (itemIndex >= 0 && projection.turnItems[itemIndex]!.id !== payload.id) { itemIndex -= 1; } + const deferredWork = + itemIndex === -1 ? undefined : projection.turnItems[itemIndex]?.deferredWork; + if (deferredWork !== undefined) payload = { ...payload, deferredWork }; if ( partialTimeline && itemIndex === -1 && diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 6070638c57a66..0f34a926676c8 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -64,6 +64,7 @@ import * as ThreadHistoryController from "./threadHistoryController.ts"; export type LoadEarlierThreadHistoryInput = { readonly threadId: ThreadId; readonly throughEntryId?: string; + readonly workAnchorId?: string; }; export function createThreadEnvironmentAtoms( @@ -90,6 +91,7 @@ export function createThreadEnvironmentAtoms( supervisor.target.environmentId, input.threadId, input.throughEntryId, + input.workAnchorId, ); }); diff --git a/packages/client-runtime/src/state/threadHistoryController.ts b/packages/client-runtime/src/state/threadHistoryController.ts index c9ae1843ff27d..1c8818fd54db1 100644 --- a/packages/client-runtime/src/state/threadHistoryController.ts +++ b/packages/client-runtime/src/state/threadHistoryController.ts @@ -13,7 +13,10 @@ export type ThreadHistoryLoadEarlierResult = | { readonly _tag: "error"; readonly message: string }; export type ThreadHistoryHandler = { - readonly loadEarlier: (throughEntryId?: string) => Effect.Effect; + readonly loadEarlier: ( + throughEntryId?: string, + workAnchorId?: string, + ) => Effect.Effect; readonly reconcilePendingWork: () => Effect.Effect; }; @@ -36,6 +39,7 @@ export class ThreadHistoryController extends Context.Service< environmentId: EnvironmentId, threadId: ThreadId, throughEntryId?: string, + workAnchorId?: string, ) => Effect.Effect; readonly reconcilePendingWork: ( environmentId: EnvironmentId, @@ -81,14 +85,14 @@ export const layer: Layer.Layer = Layer.effect( }); }), loadEarlier: Effect.fn("ThreadHistoryController.loadEarlier")( - function (environmentId, threadId, throughEntryId) { + function (environmentId, threadId, throughEntryId, workAnchorId) { return Ref.get(handlers).pipe( Effect.flatMap((current) => { const handler = current.get(threadKey({ environmentId, threadId })); if (handler === undefined) { return Effect.succeed({ _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult); } - return handler.loadEarlier(throughEntryId); + return handler.loadEarlier(throughEntryId, workAnchorId); }), ); }, diff --git a/packages/client-runtime/src/state/threadHistoryHttp.ts b/packages/client-runtime/src/state/threadHistoryHttp.ts index a9714172e44bd..f0ca836248aff 100644 --- a/packages/client-runtime/src/state/threadHistoryHttp.ts +++ b/packages/client-runtime/src/state/threadHistoryHttp.ts @@ -19,7 +19,7 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( readonly threadId: ThreadId; readonly cursor: string; readonly throughEntryId?: string | undefined; - readonly view?: "conversation" | "activity" | undefined; + readonly view?: "conversation" | "activity" | "work" | undefined; readonly signer: Option.Option; readonly remoteAuthorization?: Option.Option; readonly timeoutMs?: number; @@ -29,6 +29,7 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( query: { cursor: input.cursor, compactIds: "1", + ...(input.throughEntryId === undefined ? { deferWork: "1" } : {}), ...(input.view === undefined ? {} : { view: input.view }), ...(input.throughEntryId === undefined ? {} : { throughEntryId: input.throughEntryId }), }, diff --git a/packages/client-runtime/src/state/threadHistoryMerge.test.ts b/packages/client-runtime/src/state/threadHistoryMerge.test.ts index 4dd4e7cef5210..59e1ea3652e52 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.test.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.test.ts @@ -64,6 +64,29 @@ describe("threadHistoryMerge", () => { expect(complete.visibleTurnItems[1]?.item).toBe(updated.item); expect(complete.visibleTurnItems.map((entry) => entry.position)).toEqual([0, 1, 2, 3, 4]); expect(new Set(complete.turnItems.map((item) => item.id)).size).toBe(5); + const anchorItem = { + ...updated.item, + deferredWork: { cursor: "work", itemCount: 4, lastOrdinal: 5 }, + }; + const deferred = { + ...live, + turnItems: live.turnItems.map((item) => (item.id === anchorItem.id ? anchorItem : item)), + visibleTurnItems: live.visibleTurnItems.map((entry) => + entry.item.id === anchorItem.id ? { ...entry, item: anchorItem } : entry, + ), + }; + const loadedWork = mergeOlderHistoryIntoProjection(deferred, [row(1), row(2), row(3)], { + anchorId: anchorItem.id, + nextCursor: null, + }); + expect(loadedWork.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([ + "item-1", + "item-2", + "item-3", + "item-4", + ]); + expect(loadedWork.visibleTurnItems[0]?.item.title).toBe("newer live title"); + expect(loadedWork.visibleTurnItems[0]?.item.deferredWork).toBeUndefined(); const hidden = { ...partial, visibleTurnItems: partial.visibleTurnItems.filter( diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index 24840ee28420a..6fd678b66b129 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.ts @@ -52,11 +52,19 @@ function renumberVisible( export function mergeOlderHistoryIntoProjection( projection: OrchestrationV2ThreadProjection, olderItems: ReadonlyArray, + work?: { readonly anchorId: string; readonly nextCursor: string | null }, ): OrchestrationV2ThreadProjection { - if (olderItems.length === 0) { + if (olderItems.length === 0 && work === undefined) { return projection; } + const start = + work === undefined + ? -1 + : projection.visibleTurnItems.findIndex((row) => row.sourceItemId === work.anchorId); + const anchor = projection.visibleTurnItems[start]; + const deferred = anchor?.item.deferredWork; + if (work !== undefined && deferred === undefined) return projection; const currentByKey = new Map( projection.visibleTurnItems.map((row) => [projectedItemKey(row), row]), ); @@ -64,6 +72,15 @@ export function mergeOlderHistoryIntoProjection( const pageKeys = new Set(); const page: OrchestrationV2ProjectedTurnItem[] = []; for (const pageRow of olderItems) { + if ( + work && + anchor && + deferred && + (pageRow.sourceThreadId !== anchor.sourceThreadId || + pageRow.item.ordinal < anchor.item.ordinal || + pageRow.item.ordinal > deferred.lastOrdinal) + ) + continue; const key = projectedItemKey(pageRow); if (pageKeys.has(key)) continue; let row = currentByKey.get(key) ?? pageRow; @@ -94,11 +111,43 @@ export function mergeOlderHistoryIntoProjection( ) turnItemById.set(String(row.sourceItemId), row.item); } - if (page.length === 0) return projection; - const visible = [ + if (page.length === 0 && work === undefined) return projection; + let visible = [ ...page, ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))), ]; + if (work && anchor && deferred) { + let end = start + 1; + while (end < projection.visibleTurnItems.length) { + const row = projection.visibleTurnItems[end]!; + if ( + row.sourceThreadId !== anchor.sourceThreadId || + row.visibility === "synthetic" || + row.item.ordinal > deferred.lastOrdinal + ) + break; + end += 1; + } + const item = { ...anchor.item }; + if (work.nextCursor === null) delete item.deferredWork; + else item.deferredWork = { ...deferred, cursor: work.nextCursor }; + const updated = { ...anchor, item }; + const section = [ + ...page, + ...projection.visibleTurnItems + .slice(start, end) + .filter((row) => !pageKeys.has(projectedItemKey(row))), + ]; + const ordered = section + .map((row) => (row.sourceItemId === anchor.sourceItemId ? updated : row)) + .sort((left, right) => left.item.ordinal - right.item.ordinal); + visible = [ + ...projection.visibleTurnItems.slice(0, start), + ...ordered, + ...projection.visibleTurnItems.slice(end), + ]; + if (anchor.visibility === "local") turnItemById.set(String(updated.item.id), updated.item); + } if ( visible.length === projection.visibleTurnItems.length && visible.every((row, index) => row === projection.visibleTurnItems[index]) diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 712eaffe77f40..c8c212db41ca1 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -22,6 +22,7 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; +import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -269,6 +270,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o return { threadState, + loadWork: (anchorId: string) => + historyController.loadEarlier(TARGET.environmentId, THREAD_ID, undefined, anchorId), loadEarlier: (throughEntryId?: string) => historyController.loadEarlier(TARGET.environmentId, THREAD_ID, throughEntryId), reconcilePendingWork: historyController.reconcilePendingWork(TARGET.environmentId, THREAD_ID), @@ -688,7 +691,7 @@ describe("EnvironmentThreads", () => { }), ); - describe("Stop on cached work the server already finished (#18188)", () => { + describe("cached work (#18188)", () => { const at = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); const parentThreadId = ThreadId.make("thread:parent"); const command = ( @@ -696,7 +699,7 @@ describe("EnvironmentThreads", () => { status: "running" | "completed", exitCode?: number, ordinal = id === "older-command" ? 1 : 2, - ): OrchestrationV2TurnItem => ({ + ): Extract => ({ id: TurnItemId.make(id), threadId: THREAD_ID, runId: null, @@ -723,6 +726,261 @@ describe("EnvironmentThreads", () => { sourceItemId: item.id, item, }); + const cachedItem = (item: OrchestrationV2TurnItem) => ({ + ...BASE_PROJECTION, + turnItems: [item], + visibleTurnItems: [localRow(item, 0)], + }); + const historyPage = ( + request: Parameters[0], + items: ReadonlyArray>, + nextCursor: string | null, + ) => + HttpClientResponse.fromWeb( + request, + Response.json({ + snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, + items, + nextCursor, + hasMoreHistory: nextCursor !== null, + }), + ); + it.effect("loads a long unwrapped turn through its user message and resumes failed pages", () => + Effect.gen(function* () { + const prompt = { + ...command("prompt", "completed", 0, 0), + type: "user_message" as const, + messageId: MessageId.make("prompt"), + inputIntent: "turn_start" as const, + createdBy: "user" as const, + creationSource: "web" as const, + text: "Do the work", + attachments: [], + }; + const work = Array.from({ length: 1_000 }, (_, index) => + command(`work-${index + 1}`, "completed", 0, index + 1), + ); + const older = [prompt, ...work.slice(0, -1)]; + const tail = work.at(-1)!; + const requests: string[] = []; + let failed = false; + const harness = yield* makeHarness({ + cached: cachedItem(tail), + cachedHistory: { historyCursor: "1000", hasMoreHistory: true }, + historyHttpClient: HttpClient.make((request, url) => { + const cursor = url.searchParams.get("cursor")!; + requests.push(cursor); + if (cursor === "before-user") return Effect.succeed(historyPage(request, [], cursor)); + if (cursor === "925" && !failed) { + failed = true; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })), + ); + } + const end = Number(cursor); + expect(Number.isFinite(end)).toBe(true); + const start = Math.max(0, end - 75); + return Effect.succeed( + historyPage( + request, + older.slice(start, end).map(localRow), + start === 0 ? "before-user" : String(start), + ), + ); + }), + }); + expect((yield* harness.loadEarlier())._tag).toBe("error"); + expect(requests).toEqual(["1000", "925"]); + expect(yield* harness.loadEarlier()).toEqual({ _tag: "loaded" }); + const state = yield* SubscriptionRef.get(harness.threadState); + expect( + Option.getOrThrow(state.data).visibleTurnItems.map((row) => row.sourceItemId), + ).toEqual([prompt, ...work].map((item) => item.id)); + expect(state.history).toMatchObject({ + historyCursor: "before-user", + hasMoreHistory: true, + loading: false, + }); + expect(requests.slice(1, 3)).toEqual(["925", "925"]); + expect(yield* harness.loadEarlier()).toEqual({ + _tag: "error", + message: "History did not advance.", + }); + expect((yield* SubscriptionRef.get(harness.threadState)).history.loading).toBe(false); + }), + ); + it.effect("loads deferred work on demand and resumes a failed page", () => + Effect.gen(function* () { + const anchor = { + ...command("anchor", "completed", 0, 1), + deferredWork: { cursor: "first", itemCount: 3, lastOrdinal: 3 }, + }; + const requests: string[] = []; + let failed = false; + const harness = yield* makeHarness({ + cached: cachedItem(anchor), + cachedHistory: { historyCursor: "older", hasMoreHistory: true }, + historyHttpClient: HttpClient.make((request, url) => { + expect(url.searchParams.get("view")).toBe("work"); + const cursor = url.searchParams.get("cursor")!; + requests.push(cursor); + if (cursor === "second" && !failed) { + failed = true; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("Unavailable", { status: 503 })), + ); + } + return Effect.succeed( + historyPage( + request, + [ + localRow( + command(`work-${cursor}`, "completed", 0, cursor === "first" ? 3 : 2), + 0, + ), + ], + cursor === "first" ? "second" : null, + ), + ); + }), + }); + expect(requests).toEqual([]); + expect((yield* harness.loadWork(anchor.id))._tag).toBe("error"); + const partial = Option.getOrThrow((yield* SubscriptionRef.get(harness.threadState)).data); + expect(partial.visibleTurnItems[0]?.item.deferredWork?.cursor).toBe("second"); + expect(yield* harness.loadWork(anchor.id)).toEqual({ _tag: "loaded" }); + const state = yield* SubscriptionRef.get(harness.threadState); + expect( + Option.getOrThrow(state.data).visibleTurnItems.map((row) => row.item.ordinal), + ).toEqual([1, 2, 3]); + expect(state.history.historyCursor).toBe("older"); + expect(requests).toEqual(["first", "second", "second"]); + }), + ); + + it.effect.each(["deferred", "complete"] as const)( + "settles a replaced work request when the new snapshot is %s", + (mode) => + Effect.gen(function* () { + const anchor = { + ...command("anchor", "completed"), + deferredWork: { cursor: "before", itemCount: 80, lastOrdinal: 80 }, + }; + const asked = yield* Deferred.make(); + const release = yield* Deferred.make(); + const harness = yield* makeHarness({ + cached: cachedItem(anchor), + historyHttpClient: HttpClient.make((request) => + Effect.gen(function* () { + yield* Deferred.succeed(asked, undefined); + yield* Deferred.await(release); + return historyPage(request, [], null); + }), + ), + }); + const loading = yield* harness.loadWork(anchor.id).pipe(Effect.forkChild); + yield* Deferred.await(asked); + const replacement = + mode === "deferred" + ? { ...anchor, deferredWork: { ...anchor.deferredWork, cursor: "after" } } + : command("anchor", "completed"); + yield* Queue.offer( + harness.inputs, + snapshot( + { + ...BASE_PROJECTION, + thread: { ...BASE_PROJECTION.thread, title: "Replacement" }, + turnItems: [replacement], + visibleTurnItems: [localRow(replacement, 0)], + }, + CACHED_SNAPSHOT_SEQUENCE + 1, + ), + ); + yield* awaitThreadState( + harness.observed, + (value) => Option.getOrNull(value.data)?.thread.title === "Replacement", + ); + yield* Deferred.succeed(release, undefined); + expect((yield* Fiber.join(loading))._tag).toBe(mode === "deferred" ? "error" : "noop"); + }), + ); + + it.effect("does not mark replacement history loading when a queued request becomes stale", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + cached: BASE_PROJECTION, + cachedHistory: { historyCursor: "older", hasMoreHistory: true }, + historyHttpClient: HttpClient.make((request) => + Effect.succeed(historyPage(request, [], null)), + ), + }); + const held = yield* Deferred.make(); + const release = yield* Deferred.make(); + const replacement = yield* SubscriptionRef.updateEffect(harness.threadState, (latest) => + Deferred.succeed(held, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ ...latest, history: { ...latest.history, historyCursor: "replacement" } }), + ), + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(held); + const dispatcher = new Scheduler.MixedScheduler("sync").makeDispatcher(); + const loading = yield* harness.loadEarlier().pipe( + Effect.forkChild({ startImmediately: true }), + Effect.provideService(Scheduler.Scheduler, { + executionMode: "sync", + shouldYield: () => false, + makeDispatcher: () => dispatcher, + }), + ); + dispatcher.flush(); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(replacement); + dispatcher.flush(); + expect(yield* Fiber.join(loading)).toEqual({ _tag: "noop" }); + expect((yield* SubscriptionRef.get(harness.threadState)).history).toMatchObject({ + historyCursor: "replacement", + loading: false, + }); + }), + ); + + it.effect("loads targeted messages without fetching unrelated deferred work", () => + Effect.gen(function* () { + const anchor = { + ...command("anchor", "completed"), + deferredWork: { cursor: "work", itemCount: 80, lastOrdinal: 80 }, + }; + const message = { + ...command("older-message", "completed"), + type: "assistant_message" as const, + messageId: MessageId.make("older-message"), + text: "Older response", + streaming: false, + }; + const views: Array = []; + const harness = yield* makeHarness({ + cached: cachedItem(anchor), + cachedHistory: { historyCursor: "older", hasMoreHistory: true }, + historyHttpClient: HttpClient.make((request, url) => { + views.push(url.searchParams.get("view")); + return Effect.succeed( + url.searchParams.get("view") === "work" + ? HttpClientResponse.fromWeb(request, new Response("Unavailable", { status: 503 })) + : historyPage(request, [localRow(message, 0)], null), + ); + }), + }); + expect(yield* harness.loadEarlier(message.messageId)).toEqual({ _tag: "loaded" }); + const state = yield* SubscriptionRef.get(harness.threadState); + expect( + Option.getOrThrow(state.data).visibleTurnItems.some( + (row) => row.sourceItemId === message.id, + ), + ).toBe(true); + expect(views).toEqual(["conversation", null]); + }), + ); + // A fork's parent row keeps its own source; the child's Stop must not touch it. const inheritedRow = { position: 0, @@ -1248,6 +1506,17 @@ describe("EnvironmentThreads", () => { text: "COD4 result", streaming: false, }; + const prompt = { + ...message, + id: "item:older-prompt", + messageId: "message:older-prompt", + ordinal: 0, + type: "user_message", + inputIntent: "turn_start", + createdBy: "user", + creationSource: "web", + attachments: [], + }; const activity = { ...common, id: "item:older-command", @@ -1270,6 +1539,16 @@ describe("EnvironmentThreads", () => { const harness = yield* makeHarness({ historyHttpClient: HttpClient.make((request, url) => Effect.gen(function* () { + if (url.searchParams.get("cursor") === "next-cursor") + return HttpClientResponse.fromWeb( + request, + Response.json({ + snapshotSequence: 14, + items: [projected(prompt, 0)], + nextCursor: "before-prompt", + hasMoreHistory: true, + }), + ); expect(url.searchParams.get("cursor")).toBe("progressive-cursor"); const conversationOnly = url.searchParams.get("view") === "conversation"; if (!conversationOnly && activityRequests++ === 0) { @@ -1341,14 +1620,16 @@ describe("EnvironmentThreads", () => { const complete = yield* SubscriptionRef.get(harness.threadState); expect(complete.history).toMatchObject({ loading: false, - historyCursor: "next-cursor", + historyCursor: mode === "retry" ? "before-prompt" : "next-cursor", error: null, latestLocalTurnOrdinal: 47, }); expect( Option.getOrThrow(complete.data).visibleTurnItems.map((row) => row.sourceItemId), - ).toEqual([message.id, activity.id]); - expect(Option.getOrThrow(complete.data).turnItems).toHaveLength(2); + ).toEqual( + mode === "retry" ? [prompt.id, message.id, activity.id] : [message.id, activity.id], + ); + expect(Option.getOrThrow(complete.data).turnItems).toHaveLength(mode === "retry" ? 3 : 2); }), ); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index f46cea41fc8f6..3cb4192f0ab6c 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -658,29 +658,40 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const loadEarlier = Effect.fn("EnvironmentThreadState.loadEarlier")(function* ( throughEntryId?: string, - ) { + workAnchorId?: string, + ): Effect.fn.Return { + const workCursor = (latest: EnvironmentThreadState) => + Option.getOrNull(latest.data)?.visibleTurnItems.find( + (row) => row.sourceItemId === workAnchorId, + )?.item.deferredWork?.cursor ?? null; const current = yield* SubscriptionRef.get(state); + const initialCursor = + workAnchorId === undefined ? current.history.historyCursor : workCursor(current); if ( current.status === "deleted" || Option.isNone(current.data) || - !current.history.hasMoreHistory || - current.history.historyCursor === null - ) { - return { _tag: "noop" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; - } - if (current.history.loading) { - return { _tag: "busy" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; - } - - // Capture the cursor that initiated this request. Completions/failures must - // no-op if a socket or new bounded snapshot replaced progressive meta mid-flight. - const requestCursor = current.history.historyCursor; - yield* patchHistoryMeta((history) => - isActiveHistoryRequestCursor(requestCursor, history) - ? { ...history, loading: true, error: null } - : history, - ); - + initialCursor === null || + (workAnchorId === undefined && !current.history.hasMoreHistory) + ) + return { _tag: "noop" }; + if (workAnchorId === undefined && current.history.loading) return { _tag: "busy" }; + let requestCursor = initialCursor; + const isCurrent = (latest: EnvironmentThreadState) => + workAnchorId === undefined + ? isActiveHistoryRequestCursor(requestCursor, latest.history) + : workCursor(latest) === requestCursor; + const staleResult = ( + latest: EnvironmentThreadState, + ): ThreadHistoryController.ThreadHistoryLoadEarlierResult => + workAnchorId !== undefined && workCursor(latest) !== null + ? { _tag: "error", message: "Work history changed. Retry loading it." } + : { _tag: "noop" }; + if (workAnchorId === undefined) + yield* patchHistoryMeta((history) => + isActiveHistoryRequestCursor(requestCursor, history) + ? { ...history, loading: true, error: null } + : history, + ); const failLoad = (message: string) => SubscriptionRef.modify( state, @@ -690,72 +701,108 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ThreadHistoryController.ThreadHistoryLoadEarlierResult, EnvironmentThreadState, ] => - isActiveHistoryRequestCursor(requestCursor, latest.history) + isCurrent(latest) ? [ { _tag: "error", message }, { ...latest, - history: { ...latest.history, loading: false, error: message }, + history: + workAnchorId === undefined + ? { ...latest.history, loading: false, error: message } + : latest.history, }, ] - : [{ _tag: "noop" }, latest], + : [staleResult(latest), latest], ); const runLoad = Effect.gen(function* () { - const preparedOption = yield* SubscriptionRef.get(supervisor.prepared); - if (Option.isNone(preparedOption) || Option.isNone(httpClient)) - return yield* failLoad("Environment is not connected."); - - const views = - throughEntryId === undefined ? [undefined] : ["conversation" as const, undefined]; - for (const view of views) { - const pageResult = yield* fetchEnvironmentThreadHistoryPage({ - prepared: preparedOption.value, - threadId, - cursor: requestCursor, - throughEntryId, - view, - signer: dpopSigner, - remoteAuthorization, - }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result); - if (Result.isFailure(pageResult)) - return yield* failLoad(formatHistoryError(pageResult.failure)); - const page = pageResult.success; - const complete = view === undefined; - const waiting = yield* Ref.get(awaitingCompletion); - const result = yield* applyLock.withPermits(1)( - SubscriptionRef.modify( - state, - ( - latest, - ): readonly [ - ThreadHistoryController.ThreadHistoryLoadEarlierResult, - EnvironmentThreadState, - ] => { - if (!isActiveHistoryRequestCursor(requestCursor, latest.history)) - return [{ _tag: "noop" }, latest]; - if (Option.isNone(latest.data) || latest.status === "deleted") - return [{ _tag: "noop" }, { ...latest, history: EMPTY_THREAD_HISTORY_META }]; - return [ - { _tag: "loaded" }, - { - ...latest, - data: Option.some(mergeOlderHistoryIntoProjection(latest.data.value, page.items)), - status: complete && waiting ? "synchronizing" : latest.status, - history: complete - ? applyHistoryPageMeta(latest.history, page) - : { ...latest.history, expanded: true }, - }, - ]; - }, - ), - ); - if (result._tag !== "loaded") return result; - if (complete) yield* remember; + while (true) { + const preparedOption = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(preparedOption) || Option.isNone(httpClient)) + return yield* failLoad("Environment is not connected."); + const views = + workAnchorId !== undefined + ? ["work" as const] + : throughEntryId === undefined + ? [undefined] + : ["conversation" as const, undefined]; + let nextCursor: string | null = null; + for (const view of views) { + const pageResult = yield* fetchEnvironmentThreadHistoryPage({ + prepared: preparedOption.value, + threadId, + cursor: requestCursor, + throughEntryId: workAnchorId ?? throughEntryId, + view, + signer: dpopSigner, + remoteAuthorization, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result); + if (Result.isFailure(pageResult)) + return yield* failLoad(formatHistoryError(pageResult.failure)); + const page = pageResult.success; + const complete = view !== "conversation"; + const keepPaging = + complete && + (workAnchorId !== undefined + ? page.nextCursor !== null + : throughEntryId === undefined && + page.hasMoreHistory && + !page.items.some( + ({ item }) => item.type === "user_message" && item.createdBy === "user", + )); + if (keepPaging && (page.nextCursor === null || page.nextCursor === requestCursor)) + return yield* failLoad( + workAnchorId === undefined + ? "History did not advance." + : "Work history did not advance.", + ); + const waiting = yield* Ref.get(awaitingCompletion); + const result = yield* applyLock.withPermits(1)( + SubscriptionRef.modify( + state, + ( + latest, + ): readonly [ + ThreadHistoryController.ThreadHistoryLoadEarlierResult, + EnvironmentThreadState, + ] => { + if (!isCurrent(latest)) return [staleResult(latest), latest]; + if (Option.isNone(latest.data) || latest.status === "deleted") + return [{ _tag: "noop" }, { ...latest, history: EMPTY_THREAD_HISTORY_META }]; + return [ + { _tag: "loaded" }, + { + ...latest, + data: Option.some( + mergeOlderHistoryIntoProjection( + latest.data.value, + page.items, + workAnchorId === undefined + ? undefined + : { anchorId: workAnchorId, nextCursor: page.nextCursor }, + ), + ), + status: + workAnchorId === undefined && complete && waiting + ? "synchronizing" + : latest.status, + history: + workAnchorId === undefined && complete + ? { ...applyHistoryPageMeta(latest.history, page), loading: keepPaging } + : { ...latest.history, expanded: true }, + }, + ]; + }, + ), + ); + if (result._tag !== "loaded") return result; + if (complete) yield* remember; + nextCursor = keepPaging ? page.nextCursor : null; + } + if (nextCursor === null) return { _tag: "loaded" } as const; + requestCursor = nextCursor; } - return { _tag: "loaded" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; }); - return yield* runLoad.pipe( Effect.onInterrupt(() => SubscriptionRef.update(state, (latest) => ({ @@ -832,8 +879,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (Option.isSome(historyController)) { const scope = yield* Scope.Scope; const registration = yield* historyController.value.register(environmentId, threadId, { - loadEarlier: (throughEntryId) => - loadEarlier(throughEntryId).pipe(Effect.forkIn(scope), Effect.flatMap(Fiber.join)), + loadEarlier: (throughEntryId, workAnchorId) => + loadEarlier(throughEntryId, workAnchorId).pipe( + Effect.forkIn(scope), + Effect.flatMap(Fiber.join), + ), reconcilePendingWork: () => reconcilePendingWork().pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), @@ -962,6 +1012,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ...(acceptBoundedSnapshot ? { acceptBoundedSnapshot: true as const, + acceptDeferredWork: true as const, acceptCompactTurnItems: true as const, acceptCompactIds: true as const, } diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index ad6780ae71481..30c33deebd55c 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -657,6 +657,7 @@ const EnvironmentOrchestrationShellSnapshotQuery = Schema.Struct({ }); const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ + deferWork: Schema.optionalKey(Schema.String), compactTurnItems: Schema.optionalKey(Schema.String), dedupeBodies: Schema.optionalKey(Schema.String), compactIds: Schema.optionalKey(Schema.String), @@ -665,7 +666,8 @@ const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ cursor: TrimmedNonEmptyString, throughEntryId: Schema.optional(TrimmedNonEmptyString), - view: Schema.optional(Schema.Literals(["conversation", "activity"])), + view: Schema.optional(Schema.Literals(["conversation", "activity", "work"])), + deferWork: Schema.optionalKey(Schema.String), compactIds: Schema.optionalKey(Schema.String), }); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 993fb62453d59..56d910b752deb 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1447,6 +1447,14 @@ export type OrchestrationV2UserMessageInputIntent = typeof OrchestrationV2UserMessageInputIntent.Type; const OrchestrationV2TurnItemBaseFields = { + /** Wire-only cursor for collapsed work; persisted items keep their complete history. */ + deferredWork: Schema.optionalKey( + Schema.Struct({ + cursor: TrimmedNonEmptyString, + itemCount: PositiveInt, + lastOrdinal: NonNegativeInt, + }), + ), toolNonExecutionKind: Schema.optional(Schema.String), toolSurface: Schema.optional(ToolActivitySurface), toolIcon: Schema.optional(ToolActivityIcon), @@ -3680,6 +3688,7 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ afterSequence: Schema.optionalKey(NonNegativeInt), requestCompletionMarker: Schema.optionalKey(Schema.Boolean), acceptBoundedSnapshot: Schema.optionalKey(Schema.Boolean), + acceptDeferredWork: Schema.optionalKey(Schema.Boolean), acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean), /** The client restores `*Omitted` bodies from records it already holds. */ acceptDedupedBodies: Schema.optionalKey(Schema.Boolean), From c3858bf1f70a1a16a3582ff870307c55b358b5c3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 04:05:51 -0700 Subject: [PATCH 10/19] fix(web): snooze options sort soonest first, fit 2 digits, and link from custom snooze (#26427) Co-authored-by: Claude Opus 5.5 --- .../web/src/components/CustomSnoozeDialog.tsx | 19 +++++- .../SnoozePresetsSettings.logic.test.ts | 20 +++++- .../settings/SnoozePresetsSettings.logic.ts | 25 +++++++ .../settings/SnoozePresetsSettings.tsx | 68 +++++++++++++++---- 4 files changed, 117 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index efbc5d90da6b6..43c466184bf2c 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useId, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; import { create } from "zustand"; import { localSnoozeDate, @@ -7,7 +8,7 @@ import { type CustomSnoozeInput, } from "@t3tools/client-runtime/state/thread-settled"; import { Button } from "./ui/button"; -import { CalendarIcon } from "lucide-react"; +import { CalendarIcon, Settings2Icon } from "lucide-react"; import { Calendar } from "./ui/calendar"; import { weekStartsOn } from "../timestampFormat"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -63,6 +64,7 @@ export function CustomSnoozeDialogHost() { function CustomSnoozeDialog() { const id = useId(); + const navigate = useNavigate(); const [initial] = useState(() => new Date(Date.now() + 3_600_000)); const nowMinute = useNowMinute(); // Follows the clock so a dialog left open past midnight stops offering yesterday. @@ -225,6 +227,21 @@ function CustomSnoozeDialog() { )} + diff --git a/apps/web/src/components/settings/SnoozePresetsSettings.logic.test.ts b/apps/web/src/components/settings/SnoozePresetsSettings.logic.test.ts index e5126ba8bba6d..05d62987fb23e 100644 --- a/apps/web/src/components/settings/SnoozePresetsSettings.logic.test.ts +++ b/apps/web/src/components/settings/SnoozePresetsSettings.logic.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { reconcileSnoozeDraft } from "./SnoozePresetsSettings.logic"; +import { reconcileSnoozeDraft, sortSnoozeRows } from "./SnoozePresetsSettings.logic"; describe("reconcileSnoozeDraft", () => { it("keeps the draft while our own writes echo back in order", () => { @@ -18,3 +18,21 @@ describe("reconcileSnoozeDraft", () => { expect(reconcileSnoozeDraft(["a", "b", "a"], "b")).toEqual({ adopt: false, pending: ["a"] }); }); }); + +describe("sortSnoozeRows", () => { + it("orders rows by wake time and puts a passed today option last", () => { + // 16:00 local, so 20 hours wakes tomorrow and today 09:00 has passed. + const now = new Date(2026, 9, 28, 16, 0); + const sorted = sortSnoozeRows( + [ + { kind: "after", minutes: 20 * 60 }, + { kind: "at", day: "today", time: "09:00" }, + { kind: "at", day: "today", time: "18:00" }, + { kind: "after", minutes: 60 }, + ], + ["20h", "passed", "evening", "1h"], + now, + ); + expect(sorted.keys).toEqual(["1h", "evening", "20h", "passed"]); + }); +}); diff --git a/apps/web/src/components/settings/SnoozePresetsSettings.logic.ts b/apps/web/src/components/settings/SnoozePresetsSettings.logic.ts index ece0e2ee5a922..5e778be591b68 100644 --- a/apps/web/src/components/settings/SnoozePresetsSettings.logic.ts +++ b/apps/web/src/components/settings/SnoozePresetsSettings.logic.ts @@ -1,3 +1,5 @@ +import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; +import { resolveSnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import * as Equal from "effect/Equal"; /** @@ -13,3 +15,26 @@ export function reconcileSnoozeDraft( ? { adopt: true } : { adopt: false, pending: pending.slice(acknowledged + 1) }; } + +/** + * Orders editor rows the way the menu lists them: soonest wake first, with options that + * have no wake right now (a passed "Today") last. Ties keep their current order. + */ +export function sortSnoozeRows( + presets: ReadonlyArray, + keys: ReadonlyArray, + now: Date, +): { readonly presets: ReadonlyArray; readonly keys: ReadonlyArray } { + const order = presets + .map((preset, index) => ({ + index, + wake: resolveSnoozePreset(preset, now)?.snoozedUntil ?? null, + })) + .toSorted((a, b) => + a.wake === b.wake ? 0 : a.wake === null ? 1 : b.wake === null ? -1 : a.wake < b.wake ? -1 : 1, + ); + return { + presets: order.map(({ index }) => presets[index]!), + keys: order.map(({ index }) => keys[index]!), + }; +} diff --git a/apps/web/src/components/settings/SnoozePresetsSettings.tsx b/apps/web/src/components/settings/SnoozePresetsSettings.tsx index d7129e76dd208..22f2d43497fde 100644 --- a/apps/web/src/components/settings/SnoozePresetsSettings.tsx +++ b/apps/web/src/components/settings/SnoozePresetsSettings.tsx @@ -16,7 +16,7 @@ import * as Schema from "effect/Schema"; import { PlusIcon, XIcon } from "lucide-react"; import { useNowMinute } from "../../hooks/useNowMinute"; -import { reconcileSnoozeDraft } from "./SnoozePresetsSettings.logic"; +import { reconcileSnoozeDraft, sortSnoozeRows } from "./SnoozePresetsSettings.logic"; import { formatShortTimestamp } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -90,6 +90,16 @@ function nextCandidate(presets: ReadonlyArray): SnoozePrese ); } +// Row order is only display order, so it does not make a list differ from the defaults. +function sameSnoozePresets( + left: ReadonlyArray, + right: ReadonlyArray, +) { + const ids = (presets: ReadonlyArray) => + presets.map(snoozePresetId).toSorted(); + return Equal.equals(ids(left), ids(right)); +} + const isSnoozePresetDay = Schema.is(SnoozePresetDay); interface KindMemory { @@ -109,16 +119,28 @@ function withWhen( return { kind: "at", day: when, time: preset.kind === "at" ? preset.time : memory.time }; } -/** Edits the snooze menu's options. Menus sort them by wake time, so rows keep the order they were added in. */ +/** + * Edits the snooze menu's options. Rows sort by wake time like the menu, but only when focus + * leaves the list, so a row never moves under the pointer mid-edit. + */ export function SnoozePresetsRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); const saved = settings.snoozePresets; + const nowMinute = useNowMinute(); + const now = new Date(`${nowMinute}Z`); // Edits build on a local draft, so a second edit before the first save echoes back // does not restore what the first one changed. Echoes of our own writes keep the draft. - const [presets, setPresets] = useState(saved); - // Row keys survive removals, so a row's in-progress field state stays with that row. - const [keys, setKeys] = useState>(() => saved.map((_, index) => index)); + // Row keys survive removals and sorts, so a row's in-progress field state stays with it. + // One state, so a sort queued in the same event as an edit applies on top of it. + const [rows, setRows] = useState(() => + sortSnoozeRows( + saved, + saved.map((_, index) => index), + now, + ), + ); + const { presets, keys } = rows; const [nextKey, setNextKey] = useState(saved.length); // Null forces the next render to adopt the saved value, as after a failed write. const [savedSource, setSavedSource] = useState | null>(saved); @@ -134,8 +156,7 @@ export function SnoozePresetsRow() { const reconciled = reconcileSnoozeDraft(written, saved); if (reconciled.adopt) { setWritten([]); - setPresets(saved); - setKeys(freshKeys(saved.length)); + setRows(sortSnoozeRows(saved, freshKeys(saved.length), now)); } else { setWritten(reconciled.pending); } @@ -145,8 +166,7 @@ export function SnoozePresetsRow() { nextKeys: ReadonlyArray = keys, ) => { if (!mixed && Equal.equals(snoozePresets, presets)) return; - setPresets(snoozePresets); - setKeys(nextKeys); + setRows({ presets: snoozePresets, keys: nextKeys }); setWritten((previous) => [...previous, snoozePresets]); updateSettings( { snoozePresets }, @@ -168,10 +188,17 @@ export function SnoozePresetsRow() { {...searchableSetting("snooze-options")} description="Choices in every snooze menu, listed soonest first. Weekdays mean the next one after today." resetAction={ - !mixed && Equal.equals(presets, DEFAULT_SNOOZE_PRESETS) ? null : ( + !mixed && sameSnoozePresets(presets, DEFAULT_SNOOZE_PRESETS) ? null : ( save(DEFAULT_SNOOZE_PRESETS, freshKeys(DEFAULT_SNOOZE_PRESETS.length))} + onClick={() => { + const sorted = sortSnoozeRows( + DEFAULT_SNOOZE_PRESETS, + freshKeys(DEFAULT_SNOOZE_PRESETS.length), + now, + ); + save(sorted.presets, sorted.keys); + }} /> ) } @@ -188,6 +215,7 @@ export function SnoozePresetsRow() { ) } onAdd={() => save([...presets, nextCandidate(presets)], [...keys, ...freshKeys(1)])} + onSettle={() => setRows((current) => sortSnoozeRows(current.presets, current.keys, now))} /> ); @@ -200,6 +228,7 @@ function SnoozePresetList(props: { onChange: (index: number, preset: SnoozePresetSetting) => void; onRemove: (index: number) => void; onAdd: () => void; + onSettle: () => void; }) { const nowMinute = useNowMinute(); const now = new Date(`${nowMinute}Z`); @@ -232,7 +261,20 @@ function SnoozePresetList(props: { ); return ( -
    +
    { + const list = event.currentTarget; + const next = event.relatedTarget; + if (list.contains(next)) return; + // Select popups portal out of the list, so focus moving into one is not leaving it. + if (next instanceof Element && next.closest('[data-slot="select-popup"]')) return; + // Waits for the amount field's blur commit, and skips its Enter blur-and-refocus. + queueMicrotask(() => { + if (!list.contains(document.activeElement)) props.onSettle(); + }); + }} + > {props.presets.map((preset, index) => { const preview = previews[index]!; return ( @@ -371,7 +413,7 @@ function DurationFields(props: { minutes: number; onChange: (minutes: number) => <> Date: Wed, 28 Oct 2026 04:21:58 -0700 Subject: [PATCH 11/19] fix(web): file preview wrap buttons no longer turn off word wrap everywhere (#26432) Co-authored-by: Claude Opus 5.5 --- .../AttachmentFilePreview.browser.test.tsx | 22 ++++++++++++++++++- .../files/AttachmentFilePreview.tsx | 13 ++++++----- .../src/components/files/FilePreviewPanel.tsx | 12 +++++++--- .../src/components/files/LargeTextViewer.tsx | 5 ++++- .../files/ReadOnlySourcePreview.tsx | 5 ++++- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 9879edfeedbd8..7492313f3c7e8 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -18,7 +18,9 @@ vi.mock(import("~/assets/assetUrls"), async (original) => ({ // The real source view highlights through a worker pool; the text it receives is what matters here. // Never load the original: its highlighter graph can outlast waitFor on a cold CI browser. vi.mock(import("./ReadOnlySourcePreview"), () => ({ - default: ({ text }: { text: string }) =>
    {text}
    , + default: ({ text, wordWrap }: { text: string; wordWrap?: boolean }) => ( +
    {text}
    + ), })); // The Word engine compiles about 30 MB of WebAssembly in a fresh worker per document, which took @@ -41,6 +43,7 @@ vi.mock(import("./office/WordDocument"), async () => { }; }); +import { getClientSettings } from "~/hooks/useSettings"; import { AttachmentFilePreview } from "./AttachmentFilePreview"; import LargeTextViewer from "./LargeTextViewer"; import VirtualTablePreview from "./VirtualTablePreview"; @@ -492,6 +495,7 @@ describe("large text preview recovery", () => { workspaceMutationId={mutation} revealLine={null} revealRequestId={0} + wordWrap />
    ); @@ -2341,6 +2345,22 @@ it("toggles a Mermaid attachment between diagram and source through the lazy pre expect(screen.container.querySelector('[role="alert"]')).toBeNull(); }); +it("unwraps one text attachment without turning off the word wrap setting", async () => { + const file = new Blob(["a long line"], { type: "text/plain" }); + await using screen = await render( + , + ); + await screen.getByRole("button", { name: "Disable word wrap" }).click(); + await expect.element(screen.getByRole("button", { name: "Enable word wrap" })).toBeVisible(); + expect(getClientSettings().wordWrap).toBe(true); + expect(screen.container.querySelector("pre")?.dataset.wordWrap).toBe("false"); +}); + it("mounts a bounded set of saved notebook outputs as the reader scrolls", async () => { const notebook = { nbformat: 4, diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index e57289bd60144..1d8bc609ff56a 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -21,7 +21,7 @@ import { MorphIcon } from "~/components/MorphIcon"; import { ScrollArea } from "~/components/ui/scroll-area"; import { toastManager } from "~/components/ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; +import { useClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { BinaryFilePreview } from "./BinaryFilePreview"; @@ -55,7 +55,7 @@ const SourcePreview = lazy(() => import("./ReadOnlySourcePreview")); const STALE_URL_MS = 5 * 60_000; -export function ReadOnlySourcePreview(props: { name: string; text: string }) { +export function ReadOnlySourcePreview(props: { name: string; text: string; wordWrap?: boolean }) { return ( }> @@ -214,7 +214,9 @@ export function AttachmentFilePreview(props: { return () => controller.abort(); }, [url, needsText, streamsText, revision, props.sizeBytes, props.file, refresh]); const failure = error ?? (needsText ? contentError : null); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const [wordWrapOverride, setWordWrapOverride] = useState(null); + const wordWrap = wordWrapOverride ?? defaultWordWrap; const showsRawText = failure === null && needsText && @@ -272,6 +274,7 @@ export function AttachmentFilePreview(props: { onRetry={() => setRevision((value) => value + 1)} delimiter={rendered ? delimiter : null} copyContents={copyContents} + wordWrap={wordWrap} /> ) : !url || (needsText && !content) ? ( @@ -292,7 +295,7 @@ export function AttachmentFilePreview(props: { /> ) : ( - + ) ) : kind === "archive" || kind === "sqlite" ? ( updateClientSettings({ wordWrap: !wordWrap })} + onPress={() => setWordWrapOverride(!wordWrap)} > diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 56854b3ec6f72..03e682758e321 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -56,7 +56,7 @@ import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; import { MorphIcon } from "~/components/MorphIcon"; import { useRemoteOpenState } from "~/remoteOpen"; -import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; +import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; @@ -996,7 +996,9 @@ export default function FilePreviewPanel({ attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; const draft = typeof composerDraftTarget === "string"; const { resolvedTheme } = useTheme(); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const [wordWrapOverride, setWordWrapOverride] = useState(null); + const wordWrap = wordWrapOverride ?? defaultWordWrap; const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); const primaryEnvironmentId = usePrimaryEnvironmentId(); const remoteOpenState = useRemoteOpenState(environmentId); @@ -1291,7 +1293,7 @@ export default function FilePreviewPanel({ updateClientSettings({ wordWrap: !wordWrap })} + onPress={() => setWordWrapOverride(!wordWrap)} > @@ -1403,6 +1405,7 @@ export default function FilePreviewPanel({ workspaceMutationId={workspaceMutationId} revealLine={revealLine} revealRequestId={revealRequestId} + wordWrap={wordWrap} /> ) : relativePath && absolutePath && @@ -1488,6 +1491,7 @@ export default function FilePreviewPanel({ text={file.data.contents} cacheKey={projectFileCacheKey(cwd, relativePath, file.data.contents)} onPostRender={onFilePostRender} + wordWrap={wordWrap} /> ) : ( @@ -1557,6 +1561,7 @@ export function WorkspaceLargeTextPreview(props: { workspaceMutationId: string | null; revealLine: number | null; revealRequestId: number; + wordWrap: boolean; }) { const resource = { _tag: "media-file", @@ -1659,6 +1664,7 @@ export function WorkspaceLargeTextPreview(props: { delimiter={props.delimiter} revealLine={props.revealLine} revealRequestId={props.revealRequestId} + wordWrap={props.wordWrap} /> diff --git a/apps/web/src/components/files/LargeTextViewer.tsx b/apps/web/src/components/files/LargeTextViewer.tsx index a12842f7047c9..097f0dc717f70 100644 --- a/apps/web/src/components/files/LargeTextViewer.tsx +++ b/apps/web/src/components/files/LargeTextViewer.tsx @@ -36,6 +36,8 @@ export default function LargeTextViewer(props: { readonly delimiter: "," | "\t" | null; readonly revealLine?: number | null; readonly revealRequestId?: number; + /** A preview's own wrap toggle; omitted follows the word wrap setting. */ + readonly wordWrap?: boolean; }) { const [meta, setMeta] = useState | null>(null); const [pages, setPages] = useState(new Map()); @@ -46,7 +48,8 @@ export default function LargeTextViewer(props: { reject: (cause: Error) => void; } | null>(null); const pending = useRef(new Set()); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const wordWrap = props.wordWrap ?? defaultWordWrap; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the prefix fetch and indexing worker, cancelling requests and pending copies when the source changes or unmounts. useEffect(() => { // oxlint-disable-next-line react/set-state-in-effect -- A replacement source must discard the previous worker's error and pages. diff --git a/apps/web/src/components/files/ReadOnlySourcePreview.tsx b/apps/web/src/components/files/ReadOnlySourcePreview.tsx index b35143fd36575..2fdfe04a238d6 100644 --- a/apps/web/src/components/files/ReadOnlySourcePreview.tsx +++ b/apps/web/src/components/files/ReadOnlySourcePreview.tsx @@ -13,9 +13,12 @@ export default function ReadOnlySourcePreview(props: { readonly text: string; readonly cacheKey?: string; readonly onPostRender?: FileOptions["onPostRender"]; + /** A preview's own wrap toggle; omitted follows the word wrap setting. */ + readonly wordWrap?: boolean; }) { const resolvedTheme = useRenderedAppearance(); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const wordWrap = props.wordWrap ?? defaultWordWrap; return ( Date: Wed, 28 Oct 2026 04:49:42 -0700 Subject: [PATCH 12/19] feat(web): simpler composer with grouped controls, effort words and no collapsed state (#25869) Co-authored-by: Claude Opus 5.5 --- .../settings/DesktopClientSettings.test.ts | 1 - .../web/src/components/BranchToolbar.logic.ts | 6 +- apps/web/src/components/BranchToolbar.tsx | 50 +- .../BranchToolbarBranchSelector.tsx | 3 +- apps/web/src/components/ChatView.tsx | 125 ++--- .../components/ComposerContextPermissions.tsx | 40 ++ apps/web/src/components/chat/ChatComposer.tsx | 465 +++--------------- .../chat/CompactComposerControlsMenu.tsx | 53 +- .../chat/ComposerAttachmentStrip.tsx | 3 +- .../src/components/chat/ComposerControl.tsx | 44 +- .../components/chat/ComposerControlsBar.tsx | 136 +++-- .../chat/ComposerFooterControls.tsx | 204 ++++++-- ...omposerPermissionsSection.browser.test.tsx | 151 ++++++ .../src/components/chat/ComposerSurface.tsx | 35 +- .../src/components/chat/EffortGlyph.test.ts | 67 +++ apps/web/src/components/chat/EffortGlyph.tsx | 86 ++++ .../chat/MessagesTimeline.browser.test.tsx | 23 - .../src/components/chat/MessagesTimeline.tsx | 46 +- .../components/chat/ProviderModelPicker.tsx | 48 +- apps/web/src/components/chat/TraitsPicker.tsx | 140 +++++- .../chat/composerControlsOwnership.test.ts | 90 ++++ .../chat/composerControlsOwnership.ts | 63 +++ .../src/components/chat/composerEventScope.ts | 19 + .../components/chat/composerProviderState.tsx | 13 + .../components/chat/composerScrollGesture.ts | 79 --- .../src/components/chat/runtimeModeConfig.ts | 17 +- .../chat/timelineScrollTarget.test.ts | 24 - .../components/chat/useComposerFocusState.ts | 18 - .../chat/useComposerRestingTransition.ts | 81 +-- .../src/components/chat/useTimelineFollow.ts | 35 -- .../components/composerFooterLayout.test.ts | 25 - .../src/components/composerFooterLayout.ts | 52 -- apps/web/src/components/guest/GuestPage.tsx | 4 - .../settings/AppearanceSettingsPanel.tsx | 39 ++ .../components/settings/SettingsPanels.tsx | 34 +- .../src/components/settings/settingsSearch.ts | 12 +- docs/user/keybindings.md | 2 +- docs/user/permission-modes.md | 4 +- packages/client-runtime/src/modelTraits.ts | 13 +- packages/contracts/src/settings.ts | 8 +- 40 files changed, 1162 insertions(+), 1196 deletions(-) create mode 100644 apps/web/src/components/ComposerContextPermissions.tsx create mode 100644 apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx create mode 100644 apps/web/src/components/chat/EffortGlyph.test.ts create mode 100644 apps/web/src/components/chat/EffortGlyph.tsx create mode 100644 apps/web/src/components/chat/composerControlsOwnership.test.ts create mode 100644 apps/web/src/components/chat/composerControlsOwnership.ts delete mode 100644 apps/web/src/components/chat/composerScrollGesture.ts delete mode 100644 apps/web/src/components/chat/useComposerFocusState.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ac4c6e61378e3..1a927643be628 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -34,7 +34,6 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, confirmThreadUnpin: false, contextWindowMeterEnabled: false, - composerCollapseOnScroll: true, diffFilesCollapsed: true, diffIgnoreWhitespace: true, diffLayout: "stacked", diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 80cd7e981375d..e22dcfaddedeb 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -66,12 +66,12 @@ export function shouldShowComposerContextStrip(input: { hasActiveProject: boolean; isGitRepo: boolean; showEnvironmentIndicator: boolean; - hostsRestingComposerControls: boolean; }): boolean { + // A new thread always shows the strip, since it names the permissions the first send uses. return ( input.hasActiveProject && - (input.isDraftHeroState || input.persistInActiveThreads) && - (input.isGitRepo || input.showEnvironmentIndicator || input.hostsRestingComposerControls) + (input.isDraftHeroState || + (input.persistInActiveThreads && (input.isGitRepo || input.showEnvironmentIndicator))) ); } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 80edf40d79504..4d57bc7332525 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -5,17 +5,11 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { ComposerContextLabel } from "./ComposerContextLabel"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; -import { - ChevronDownIcon, - FolderGit2Icon, - FolderGitIcon, - FolderIcon, - PlusIcon, - ScaleIcon, -} from "lucide-react"; +import { FolderGit2Icon, FolderGitIcon, FolderIcon, PlusIcon, ScaleIcon } from "lucide-react"; import { type Ref, type MouseEvent as ReactMouseEvent, + type ReactNode, memo, useImperativeHandle, useRef, @@ -72,8 +66,6 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { MiddleTruncate } from "./ui/middle-truncate"; import { ComposerSurface } from "./chat/ComposerSurface"; import { useComposerMenuProps } from "./chat/composerEventScope"; -import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; -import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; import { observeResize } from "~/lib/observeResize"; @@ -105,10 +97,10 @@ interface BranchToolbarProps { envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; + /** Shown at the end of the composer strip, such as the new thread's permissions. */ + trailing?: ReactNode; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; - composerControlsHostRef?: (element: HTMLDivElement | null) => void; - contextStripVisible?: boolean; } function showWorkspaceContextMenu(event: ReactMouseEvent, workspacePath: string) { @@ -317,7 +309,6 @@ const RunContextSelector = memo(function RunContextSelector({ ].join(" ")} > {triggerContent} - {!isPanel ? : null} ('[data-chat-composer-resting-controls="true"]') - : null; - const hostedMeasurement = hostedControls - ? measureRestingComposerControls(hostedControls) - : null; - const width = hostedMeasurement - ? resolveRestingComposerControlsNaturalWidth(hostedMeasurement) - : contentWidth(hostedControls ?? child); + const width = contentWidth(child); if (width <= 1) continue; groups += 1; needed += width; @@ -585,7 +567,6 @@ function useLabelsOverflow(): readonly [ [ current, ...Array.from(current.children), - ...Array.from(current.querySelectorAll("[data-chat-composer-resting-controls='true']")), ...Array.from(current.querySelectorAll(COMPOSER_CONTEXT_LABEL_SELECTOR)), ], measure, @@ -640,8 +621,7 @@ export const BranchToolbar = memo(function BranchToolbar({ onComposerFocusRequest, availableEnvironments, onEnvironmentChange, - composerControlsHostRef, - contextStripVisible = true, + trailing, }: BranchToolbarProps) { const branchSelectorRef = useRef(null); const threadRef = scopeThreadRef(environmentId, threadId); @@ -764,11 +744,7 @@ export const BranchToolbar = memo(function BranchToolbar({ {showGitControls ? (
    @@ -799,7 +775,7 @@ export const BranchToolbar = memo(function BranchToolbar({ className={cn( "min-h-7 min-w-10 items-center gap-1 sm:min-h-6", showGitControls ? "hidden @3xl/composer-surface:flex" : "flex", - composerControlsHostRef ? "shrink" : "flex-1", + "flex-1", )} > {showEnvironmentIndicator && availableEnvironments && ( @@ -837,15 +813,6 @@ export const BranchToolbar = memo(function BranchToolbar({
    ) : null} - {composerControlsHostRef ? ( -
    - ) : null} - {showGitControls ? ( ) : null} + {trailing} ); }); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index ef842f21c44c0..95848c209d8c5 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -16,7 +16,7 @@ import { type VcsRef, type ThreadId, } from "@t3tools/contracts"; -import { ChevronDownIcon, GitBranchIcon } from "lucide-react"; +import { GitBranchIcon } from "lucide-react"; import { useDeferredValue, useEffect, @@ -732,7 +732,6 @@ export function BranchToolbarBranchSelector({ className="w-full" /> - {displayMode !== "panel" ? : null} {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6af016c18ce91..253cc1ea2e84b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -175,6 +175,8 @@ import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { GuestPendingMessages } from "./chat/GuestShareControls"; import { useDeviceState } from "~/state/device"; import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; +import { ComposerContextPermissions } from "./ComposerContextPermissions"; +import { resolveCompatibleRuntimeMode } from "./chat/runtimeModeConfig"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; @@ -267,11 +269,7 @@ import type { MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; import { ThreadTimeline } from "./chat/ThreadTimeline"; import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; -import { - overlayComposerIsResting, - resolveComposerTimelineInset, - resolveScrollToEndClearance, -} from "./composerFooterLayout"; +import { resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { useRemoteOpenState } from "~/remoteOpen"; import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; @@ -611,9 +609,6 @@ export default function ChatView(props: ChatViewProps) { const composerFilesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const branchToolbarRef = useRef(null); - const [restingComposerControlsHost, setRestingComposerControlsHost] = - useState(null); - const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); const citeAssistantText = ( citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor, @@ -704,8 +699,6 @@ export default function ChatView(props: ChatViewProps) { setPendingServerThreadStartFromOriginByThreadId, ] = useState>({}); const legendListRef = useRef(null); - const getTimelineScrollableNode = () => legendListRef.current?.getScrollableNode() ?? null; - const getTimelineScrollableNodeStable = useStableCallback(getTimelineScrollableNode); const conversationArea = workspaceAreaProps("conversation", 1, "Conversation"); const composerArea = workspaceAreaProps("composer", 2, "Message input"); const [composerOverlayElement, setComposerOverlayElement] = useState(null); @@ -714,9 +707,7 @@ export default function ChatView(props: ChatViewProps) { const [composerReservedHeight, setComposerReservedHeight] = useState(0); const composerReservedHeightRef = useRef(0); const composerExpandedHeightRef = useRef(0); - const composerRestingRef = useRef(false); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); - const [timelineOverflows, setTimelineOverflows] = useState(false); const fanoutSelectionsAtom = draftFanoutSelectionsAtom(routeThreadKey); const { sendInFlight: sendInFlightRef } = draftSendState(routeThreadKey); const multipleModelSelections = useAtomValue(fanoutSelectionsAtom); @@ -1686,25 +1677,13 @@ export default function ChatView(props: ChatViewProps) { providerSubagentModels, reportedModelSelection, ); - const mountComposerContextStrip = shouldShowComposerContextStrip({ - isDraftHeroState, - persistInActiveThreads: settings.persistComposerContextStrip, - hasActiveProject: activeProject !== null && !showProviderSubagentBar, - isGitRepo, - showEnvironmentIndicator: showComposerEnvironmentIndicator, - hostsRestingComposerControls: routeKind === "server", - }); const showComposerContextStrip = shouldShowComposerContextStrip({ isDraftHeroState, persistInActiveThreads: settings.persistComposerContextStrip, hasActiveProject: activeProject !== null && !showProviderSubagentBar, isGitRepo, showEnvironmentIndicator: showComposerEnvironmentIndicator, - hostsRestingComposerControls: routeKind === "server" && restingComposerControlsVisible, }); - const mountComposerModelStrip = - routeKind === "server" && !mountComposerContextStrip && !showProviderSubagentBar; - const showComposerModelStrip = mountComposerModelStrip && restingComposerControlsVisible; const terminalShortcutLabelOptions = { context: { terminalFocus: true, @@ -1792,6 +1771,9 @@ export default function ChatView(props: ChatViewProps) { }); }; const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus); + const openComposerPermissionsStable = useStableCallback(() => + composerRef.current?.openControl("composer.mode"), + ); const insertArtifactTemplate = (template: CodexArtifactTemplate) => { const composer = composerRef.current; if (!composer) return; @@ -2047,14 +2029,12 @@ export default function ChatView(props: ChatViewProps) { const { showScrollToBottom, timelineLiveFollowEnabled, - isTimelineAtLogicalEndStable, onComposerPageScrollKeyDownStable, onComposerPageScrollKeyUpStable, onComposerPageScrollReleaseStable, scrollToEnd, onTimelineAnchorReadyStable, onTimelineAnchorSizeChangedStable, - onToolOutputCollapsedAtEndStable, onTimelineFooterGrowStable, onIsAtEndChangeStable, anchorNewTurn, @@ -2062,7 +2042,6 @@ export default function ChatView(props: ChatViewProps) { attachComposerOverlay: attachFollowComposerOverlay, } = useTimelineFollow({ legendListRef, - composerRef, composerTimelineInset, routeThreadKey, activeThreadKey, @@ -2177,29 +2156,9 @@ export default function ChatView(props: ChatViewProps) { const publishComposerOverlayHeight = (height: number) => { const nextHeight = Math.ceil(height); if (nextHeight <= 0) return; - const isResting = overlayComposerIsResting({ - composerMounted, - composerReportedResting: composerRestingRef.current, - }); - const modelStrip = composerOverlayElement?.querySelector( - '[data-composer-model-strip="true"]', - ); - const restingOnlyHeight = - modelStrip && isResting - ? Math.max( - 0, - modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop), - ) - : 0; - const nextInset = resolveComposerTimelineInset({ - currentInset: composerTimelineInsetRef.current, - overlayHeight: height, - isResting, - restingOnlyHeight, - }); - if (composerTimelineInsetRef.current !== nextInset) { - composerTimelineInsetRef.current = nextInset; - setComposerTimelineInset(nextInset); + if (composerTimelineInsetRef.current !== nextHeight) { + composerTimelineInsetRef.current = nextHeight; + setComposerTimelineInset(nextHeight); } // Reserve the expanded composer height for the terminal drawer and empty placeholder. // Keep it while the mobile strip collapses, but allow growth from approvals or tasks. @@ -2208,7 +2167,7 @@ export default function ChatView(props: ChatViewProps) { '[data-chat-composer-stack="true"]', ); const expandedComposerHeight = - isDraftHeroState && stack ? Math.ceil(stack.getBoundingClientRect().height) : nextInset; + isDraftHeroState && stack ? Math.ceil(stack.getBoundingClientRect().height) : nextHeight; const mobileCollapsed = Boolean( stack?.querySelector('[data-chat-composer-mobile-collapsed="true"]'), ); @@ -2240,11 +2199,6 @@ export default function ChatView(props: ChatViewProps) { setScrollToEndClearance(clearance); }; const publishComposerOverlayHeightStable = useStableCallback(publishComposerOverlayHeight); - // The composer's layout effect publishes this flag before parent layout effects or resize observations. - const onComposerRestingChange = (resting: boolean) => { - composerRestingRef.current = resting; - }; - const onComposerRestingChangeStable = useStableCallback(onComposerRestingChange); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the composer overlay element before paint for a new thread or composer mount useLayoutEffect(() => { if (!composerOverlayElement) return; @@ -3333,8 +3287,6 @@ export default function ChatView(props: ChatViewProps) { contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChangeStable} - onContentOverflowChange={setTimelineOverflows} - onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEndStable} onViewportAttach={attachTimelineViewport} onFooterGrow={onTimelineFooterGrowStable} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} @@ -3349,10 +3301,7 @@ export default function ChatView(props: ChatViewProps) { { - composerRef.current?.restoreAfterTimelineReachedEnd(); - scrollToEnd(true); - }} + onScrollToEnd={() => scrollToEnd(true)} /> )}
    @@ -3403,9 +3352,7 @@ export default function ChatView(props: ChatViewProps) { : undefined } > - + - {mountComposerModelStrip ? ( - -
    - - ) : null} - {mountComposerContextStrip && ( + {showComposerContextStrip && (
    + ), + })} />
    )} diff --git a/apps/web/src/components/ComposerContextPermissions.tsx b/apps/web/src/components/ComposerContextPermissions.tsx new file mode 100644 index 0000000000000..8ee7766edc565 --- /dev/null +++ b/apps/web/src/components/ComposerContextPermissions.tsx @@ -0,0 +1,40 @@ +import type { RuntimeMode } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; + +import { shortcutLabelForCommand } from "../keybindings"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { ComposerControl } from "./chat/ComposerControl"; +import { runtimeModeConfig } from "./chat/runtimeModeConfig"; +import { ComposerContextLabel } from "./ComposerContextLabel"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +/** The new-thread strip names the permissions the first send will use; clicking opens them. */ +export function ComposerContextPermissions(props: { + runtimeMode: RuntimeMode; + onOpen: () => void; +}) { + const shortcutLabel = shortcutLabelForCommand( + useAtomValue(primaryServerKeybindingsAtom), + "composer.mode", + ); + const { icon: Icon, label } = runtimeModeConfig[props.runtimeMode]; + return ( + + + } + > + + Permissions{shortcutLabel ? ` · ${shortcutLabel}` : null} + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 57a3a8aa41909..57838e598161e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,7 +1,11 @@ import { useComposerTypingGuard } from "./useComposerTypingGuard"; import { insertAttachmentReferencesForDraft } from "./composerAttachmentReferences"; import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; -import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig"; +import { + resolveCompatibleRuntimeMode, + runtimeModeConfig, + runtimeModeOptions as runtimeModes, +} from "./runtimeModeConfig"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useRightPanelStore } from "~/rightPanelStore"; @@ -56,7 +60,7 @@ import { useRef, useState, } from "react"; -import { createPortal, flushSync } from "react-dom"; +import { flushSync } from "react-dom"; import { clampCollapsedComposerCursor, type ComposerTrigger, @@ -86,6 +90,7 @@ import { import { isInsideCollapsedComposerControls, isInsideRestingComposerControlScope, + markOpenToPermissions, } from "./composerEventScope"; import { type ComposerFileAttachment, @@ -102,7 +107,6 @@ import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; import { useComposerTriggerState } from "./useComposerTriggerState"; import { useComposerMenuItems } from "./useComposerMenuItems"; -import { useComposerFocusState } from "./useComposerFocusState"; import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; import { reconcileAttachmentContextReferences, @@ -140,7 +144,6 @@ import { import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, - shouldUseRestingComposerLayout, } from "../composerFooterLayout"; import { type ComposerCitationCommentRequest, @@ -221,20 +224,9 @@ import { import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; import { pendingDraftWork } from "./pendingDraftWork"; import { importComposerThreadAttachment } from "./composerThreadImport"; -import { isTimelineScrollTarget } from "./timelineScrollTarget"; -import { - createComposerScrollGestureState, - recordComposerScrollGestureEvent, - shouldCollapseComposerForScrollKey, - resetComposerScrollGesture, - suppressActiveComposerScrollGesture, -} from "./composerScrollGesture"; import { observeResize } from "~/lib/observeResize"; const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError); - -const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24; -const COMPOSER_SCROLL_GESTURE_RESET_MS = 120; import { toastManager } from "../ui/toast"; import { PlayIcon } from "lucide-react"; import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText"; @@ -297,8 +289,6 @@ const runtimeModeOptions = runtimeModes.map((mode) => ({ mode, ...runtimeModeCon export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; - restoreAfterTimelineReachedEnd: () => void; - collapseForTimelineScrollKey: (key: string) => void; addDroppedFiles: (files: File[]) => void; addDroppedFolders: (folders: File[]) => void; hasPendingAttachments: () => boolean; @@ -424,6 +414,7 @@ export interface ChatComposerProps { threadSyncPhase: ThreadSyncPhase | null; runtimeMode: RuntimeMode; + defaultRuntimeMode?: RuntimeMode; interactionMode: ProviderInteractionMode; lockedProvider: ProviderDriverKind | null; @@ -447,14 +438,9 @@ export interface ChatComposerProps { pullRequestProjectId: ProjectId | null; pullRequestRepository: string | null; threadPullRequests: ReadonlyArray | undefined; - restingControlsHost: HTMLDivElement | null; - restingControlsHaveLeadingContext: boolean; - onRestingControlsVisibilityChange: (visible: boolean) => void; - getTimelineScrollableNode: () => HTMLElement | null; - isTimelineAtLogicalEnd: () => boolean; - timelineOverflows: boolean; onComposerOverlayHeightChange: (height: number) => void; - onRestingChange: (resting: boolean) => void; + /** The new-thread strip names the permissions, so the controls row hides its chip. */ + permissionsShownInStrip: boolean; promptRef: React.RefObject; composerImagesRef: React.RefObject; @@ -575,14 +561,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pullRequestProjectId, pullRequestRepository, threadPullRequests, - restingControlsHost, - restingControlsHaveLeadingContext, - onRestingControlsVisibilityChange, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - timelineOverflows, onComposerOverlayHeightChange, - onRestingChange, promptRef, composerRef, composerImagesRef, @@ -725,7 +704,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { pendingSnapShotAnimations, pendingSnapShotIds, - pendingSnapShotIdSet, uncommittedSnapShotIds, standaloneComposerImages, } = useComposerImageGroups(attachmentDraftTarget, composerImages, composerPreviewAnnotations); @@ -923,16 +901,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : undefined; const resolvedCompactDisabledReason = compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null); - const supportedRuntimeModes = selectedProviderEntry?.snapshot.supportedRuntimeModes; - const compatibleRuntimeModeOptions = - supportedRuntimeModes && supportedRuntimeModes.length > 0 - ? runtimeModeOptions.filter((option) => supportedRuntimeModes.includes(option.mode)) - : runtimeModeOptions; - const compatibleRuntimeMode = compatibleRuntimeModeOptions.some( - (option) => option.mode === runtimeMode, - ) - ? runtimeMode - : (compatibleRuntimeModeOptions[0]?.mode ?? runtimeMode); + const { modes: compatibleRuntimeModes, mode: compatibleRuntimeMode } = + resolveCompatibleRuntimeMode( + runtimeMode, + selectedProviderEntry?.snapshot.supportedRuntimeModes, + ); + const compatibleRuntimeModeOptions = runtimeModeOptions.filter((option) => + compatibleRuntimeModes.includes(option.mode), + ); const providerSendBlockReason = getAntigravitySendBlockReason( selectedProviderEntry?.snapshot, @@ -1029,18 +1005,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) null, ); const [isDragOverComposer, setIsDragOverComposer] = useState(false); - const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const isMobileViewport = useMediaQuery("max-sm"); const isSoftwareKeyboardOpen = useSoftwareKeyboardOpen(); - const { - isComposerFocused, - setIsComposerFocused, - isComposerScrollCollapsed, - setIsComposerScrollCollapsed, - restoreAfterTimelineReachedEnd, - } = useComposerFocusState(); + const [isComposerFocused, setIsComposerFocused] = useState(false); const [composerSubmissionError, setComposerSubmissionError] = useState(null); const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( null, @@ -1070,10 +1039,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandFrameRef = useRef(null); const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); - const composerScrollCollapseTimeoutRef = useRef(null); - const composerScrollCollapseEligibleRef = useRef(false); - const windowRefocusInFlightRef = useRef(false); - const composerScrollGestureRef = useRef(createComposerScrollGestureState()); const pendingThreadImportsRef = useRef>(new Map()); const isRevertingCheckpointRef = useRef(isRevertingCheckpoint); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed checkpoint revert flag to a ref read by async handlers @@ -1183,8 +1148,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeComposerMenuItemRef.current = activeComposerMenuItem; }); - const nonPersistedComposerImageIdSet = new Set(nonPersistedComposerImageIds); - const isComposerApprovalState = activePendingApproval !== null; // A send declines the approvals shown here to redirect the agent, so it steers the waiting // turn instead of queueing behind it. Approvals the typing guard holds stay unseen and pending. @@ -1236,7 +1199,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hiddenBlockCount: restingControlsHiddenBlockCount, iconOnlyBlockCount: restingControlsIconOnlyBlockCount, controlsVisible: restingControlsVisible, - } = useRestingComposerControlsLayout(restingControlsHost ?? inlineRestingControlsHost); + } = useRestingComposerControlsLayout(inlineRestingControlsHost); const { attachControls: attachExpandedComposerControls, hiddenBlockCount: expandedControlsHiddenBlockCount, @@ -1524,43 +1487,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerCursor(cursor); resetComposerTrigger(trigger); setIsDragOverComposer(false); - setIsComposerScrollCollapsed(false); } // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes composer form resizes to switch the footer and primary actions between compact layouts useLayoutEffect(() => { const composerForm = composerFormRef.current; if (!composerForm) return; - const measureComposerFormWidth = () => composerForm.clientWidth; - const measureFooterCompactness = () => { - const composerFormWidth = measureComposerFormWidth(); - const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, { - hasWideActions: composerFooterHasWideActions, - }); - const primaryActionsCompact = - footerCompact && - shouldUseCompactComposerPrimaryActions(composerFormWidth, { - hasWideActions: composerFooterHasWideActions, - }); - return { - primaryActionsCompact, - footerCompact, - }; + const measurePrimaryActionsCompact = () => { + const composerFormWidth = composerForm.clientWidth; + const options = { hasWideActions: composerFooterHasWideActions }; + return ( + shouldUseCompactComposerFooter(composerFormWidth, options) && + shouldUseCompactComposerPrimaryActions(composerFormWidth, options) + ); }; - const initialCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); - setIsComposerFooterCompact(initialCompactness.footerCompact); + setIsComposerPrimaryActionsCompact(measurePrimaryActionsCompact()); return observeResize(composerForm, () => { - const nextCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact((previous) => - previous === nextCompactness.primaryActionsCompact - ? previous - : nextCompactness.primaryActionsCompact, - ); - setIsComposerFooterCompact((previous) => - previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, - ); + setIsComposerPrimaryActionsCompact(measurePrimaryActionsCompact()); }); }, [composerFooterHasWideActions]); @@ -1631,16 +1575,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) syncComposerDraftPersistedAttachments, ]); - const expandComposerForEditorChange = () => { - suppressActiveComposerScrollGesture( - composerScrollGestureRef.current, - window.performance.now(), - COMPOSER_SCROLL_GESTURE_RESET_MS, - ); - setIsComposerScrollCollapsed(false); - }; - const expandComposerForEditorChangeStable = useStableCallback(expandComposerForEditorChange); - const retainedContextPayloads = useRetainedContextPayloads(composerDraftTargetKey); const onPromptChange = ( @@ -1650,7 +1584,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cursorAdjacentToMention: boolean, contextIds: string[], ) => { - expandComposerForEditorChange(); refreshWorkspaceSnapshotOnTyping(); if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; @@ -2319,72 +2252,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) expandMobileComposer: expandMobileComposerStable, }); const hasBlockingComposerTopDrawer = pendingUserInputs.length > 0; - const hasImageAttachmentAttention = standaloneComposerImages.some((image) => { - const upload = uploadsByImageId[image.id]; - const failedInCurrentEnvironment = - supportsAttachmentUploads && - upload?.status === "failed" && - upload.environmentId === environmentId; - return nonPersistedComposerImageIdSet.has(image.id) && !failedInCurrentEnvironment; - }); - const composerHasExpandedChrome = - showComposerTopDrawer || - composerMenuOpen || - isStashMenuOpen || - isDragOverComposer || - isPreparingWorktree || - showProviderUnavailable || - projectSelectionRequired || - environmentUnavailable !== null || - composerSubmissionError !== null || - providerInputSubmissionError !== null || - hasImageAttachmentAttention || - preparingComposerImages.length > 0; - const isComposerResting = shouldUseRestingComposerLayout({ - isExistingThread: routeKind === "server" && activeThreadId !== null, - isMobileViewport, - isScrollCollapsed: isComposerScrollCollapsed, - hasExpandedChrome: composerHasExpandedChrome, - hasMultilinePrompt, - timelineOverflows, - }); - const expandedComposerImages = isComposerResting - ? standaloneComposerImages.filter((image) => pendingSnapShotIdSet.has(image.id)) - : standaloneComposerImages; - const composerControlsCollapsed = isComposerResting || isComposerCollapsedMobile; - const showInlineRestingControls = composerControlsCollapsed && restingControlsHost === null; - const composerControlsVisibleInStrip = - composerControlsCollapsed && restingControlsHost !== null && restingControlsVisible; - const composerControlsHidden = composerControlsCollapsed && !restingControlsVisible; + const composerControlsCollapsed = isComposerCollapsedMobile; + // The collapsed mobile web composer keeps a second controls row inside the card. + const showInlineRestingControls = isComposerCollapsedMobile; + const composerControlsHidden = showInlineRestingControls && !restingControlsVisible; if (composerControlsHidden && isComposerModelPickerOpen) { setIsComposerModelPickerOpen(false); } - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reports whether resting controls show in the strip to ChatView before paint - useLayoutEffect(() => { - onRestingControlsVisibilityChange(composerControlsVisibleInStrip); - }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reports the resting state to ChatView's ref before paint - useLayoutEffect(() => { - onRestingChange(isComposerResting); - }, [isComposerResting, onRestingChange]); - const [restingActionsElement, setRestingActionsElement] = useState(null); - const [restingActionsWidth, setRestingActionsWidth] = useState(null); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes resizes of the resting actions DOM node to measure its width - useLayoutEffect(() => { - if (!isComposerResting || !restingActionsElement) return; - const measure = () => setRestingActionsWidth(restingActionsElement.offsetWidth); - measure(); - return observeResize(restingActionsElement, measure); - }, [isComposerResting, restingActionsElement]); - const showMoreRestingImages = () => { - if (isComposerCollapsedMobile) { - expandMobileComposer(); - return; - } - setIsComposerScrollCollapsed(false); - setIsComposerFocused(true); - scheduleComposerFocus(); - }; const collapsedComposerImagePreviews = standaloneComposerImages.length > 0 ? ( ) : null; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const composerMainSurfaceRef = useComposerRestingTransition( composerControlsCollapsed, - isComposerResting, restingComposerControlsRef, onComposerOverlayHeightChange, panelAnimationsActive, panelAnimationDurationMs, ); - const canTrackComposerScrollGesture = - routeKind === "server" && activeThreadId !== null && !isMobileViewport; - const canScrollCollapseComposer = - canTrackComposerScrollGesture && - settings.composerCollapseOnScroll && - !hasMultilinePrompt && - !composerHasExpandedChrome; - // Clear rather than mask: once eligibility drops, the composer stays expanded until the next - // scroll gesture even if it becomes eligible again. - if (!canScrollCollapseComposer && isComposerScrollCollapsed) { - setIsComposerScrollCollapsed(false); - } - const composerScrollCollapseEligible = canScrollCollapseComposer && !isComposerResting; - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed scroll-collapse eligibility to a ref read by the wheel listener - useLayoutEffect(() => { - composerScrollCollapseEligibleRef.current = composerScrollCollapseEligible; - }); - - // Window activation re-fires focus; ignore that frame to keep the composer collapsed. - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener and animation frame that ignore activation focus while the composer is collapsed - useEffect(() => { - if (!isComposerScrollCollapsed) return; - let frame: number | null = null; - const onWindowFocus = () => { - windowRefocusInFlightRef.current = true; - if (frame !== null) window.cancelAnimationFrame(frame); - frame = window.requestAnimationFrame(() => { - frame = null; - windowRefocusInFlightRef.current = false; - }); - }; - window.addEventListener("focus", onWindowFocus); - return () => { - window.removeEventListener("focus", onWindowFocus); - if (frame !== null) window.cancelAnimationFrame(frame); - windowRefocusInFlightRef.current = false; - }; - }, [isComposerScrollCollapsed]); - - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a capture-phase document wheel listener that collapses the composer on timeline scroll - useEffect(() => { - if (!canTrackComposerScrollGesture) return; - - const finishScrollGesture = () => { - if (composerScrollCollapseTimeoutRef.current !== null) { - window.clearTimeout(composerScrollCollapseTimeoutRef.current); - } - composerScrollCollapseTimeoutRef.current = null; - resetComposerScrollGesture(composerScrollGestureRef.current); - }; - const handleTimelineWheel = (event: WheelEvent) => { - if (event.ctrlKey || !(event.target instanceof Element)) { - return; - } - - const scrollNode = getTimelineScrollableNode(); - if (!scrollNode) return; - const targetsTimeline = isTimelineScrollTarget(event.target, scrollNode, event.deltaY); - if ( - !scrollNode.contains(event.target) && - !composerScrollGestureRef.current.collapseSuppressed - ) - return; - - if (composerScrollCollapseTimeoutRef.current !== null) { - window.clearTimeout(composerScrollCollapseTimeoutRef.current); - } - composerScrollCollapseTimeoutRef.current = window.setTimeout( - finishScrollGesture, - COMPOSER_SCROLL_GESTURE_RESET_MS, - ); - - const canScrollInGestureDirection = - targetsTimeline && - (event.deltaY < 0 - ? scrollNode.scrollTop > 0 - : scrollNode.scrollTop < scrollNode.scrollHeight - scrollNode.clientHeight); - const deltaPx = - Math.abs(event.deltaY) * - (event.deltaMode === WheelEvent.DOM_DELTA_LINE - ? 16 - : event.deltaMode === WheelEvent.DOM_DELTA_PAGE - ? scrollNode.clientHeight - : 1); - const shouldCollapse = recordComposerScrollGestureEvent(composerScrollGestureRef.current, { - now: window.performance.now(), - deltaPx, - collapseThresholdPx: COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX, - collapseEligible: targetsTimeline && composerScrollCollapseEligibleRef.current, - canScrollInGestureDirection, - scrollsTowardLogicalEnd: event.deltaY > 0 && isTimelineAtLogicalEnd(), - }); - if (!shouldCollapse) { - return; - } - - setIsComposerScrollCollapsed(true); - }; - - document.addEventListener("wheel", handleTimelineWheel, { capture: true, passive: true }); - return () => { - document.removeEventListener("wheel", handleTimelineWheel, true); - finishScrollGesture(); - }; - }, [ - canTrackComposerScrollGesture, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - setIsComposerScrollCollapsed, - ]); - - const restingHiddenBlockCount = composerControlsCollapsed + const restingHiddenBlockCount = showInlineRestingControls ? restingControlsHiddenBlockCount - : expandedControlsHiddenBlockCount; - const iconOnlyBlockCount = composerControlsCollapsed + : composerControlsCollapsed + ? 0 + : expandedControlsHiddenBlockCount; + const iconOnlyBlockCount = showInlineRestingControls ? restingControlsIconOnlyBlockCount - : expandedControlsIconOnlyBlockCount; + : composerControlsCollapsed + ? 0 + : expandedControlsIconOnlyBlockCount; const composerControls = ( { composerEditorRef.current?.focusAt(cursor); }, - restoreAfterTimelineReachedEnd, - collapseForTimelineScrollKey: (key) => { - const scrollNode = getTimelineScrollableNode(); - if ( - composerScrollCollapseEligibleRef.current && - scrollNode && - shouldCollapseComposerForScrollKey({ - key, - scrollTop: scrollNode.scrollTop, - scrollHeight: scrollNode.scrollHeight, - clientHeight: scrollNode.clientHeight, - isAtLogicalEnd: isTimelineAtLogicalEnd(), - }) - ) { - setIsComposerScrollCollapsed(true); - } - }, addDroppedFiles: (files: File[]) => { void addComposerAttachments(files).then((inserted) => { if (!inserted) focusComposer(); @@ -3112,10 +2857,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; } - flushSync(() => { - setIsComposerScrollCollapsed(false); - setIsComposerFocused(true); - }); + flushSync(() => setIsComposerFocused(true)); const shell = composerFormRef.current?.closest('[data-slot="composer-shell"]'); const trigger = Array.from( shell?.querySelectorAll( @@ -3127,6 +2869,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); if (!trigger) return; trigger.focus({ preventScroll: true }); + if (command === "composer.mode") markOpenToPermissions(trigger); trigger.click(); }, compactContext: compactThreadContextStable, @@ -3290,10 +3033,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode, planModeUiEnabled, compactThreadContextStable, - restoreAfterTimelineReachedEnd, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - setIsComposerScrollCollapsed, ], ); @@ -3302,11 +3041,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) !isComposerCollapsedMobile && (uncommittedSnapShotIds.length > 0 || composerVideos.length > 0 || - expandedComposerImages.length > 0 || + standaloneComposerImages.length > 0 || preparingComposerImages.length > 0) ? ( { - const target = event.target; - if (isInsideRestingComposerControlScope(target)) return; - if (isInsideCollapsedComposerControls(target)) return; - if (!(target instanceof Element)) return; - const isInteractive = Boolean( - target.closest('button, a, input, select, [role="button"], [role="menuitem"]'), - ); - if (isInteractive) return; - - setIsComposerScrollCollapsed(false); - if (isComposerResting && !target.closest('[data-testid="composer-editor"]')) { - event.preventDefault(); - setIsComposerFocused(true); - scheduleComposerFocus(); - } - }} onFocusCapture={(event) => { const activeElement = event.target; if ( @@ -3381,9 +3103,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (isInsideCollapsedComposerControls(activeElement)) { return; } - if (!windowRefocusInFlightRef.current) { - setIsComposerScrollCollapsed(false); - } if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; @@ -3419,23 +3138,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) data-chat-composer-form="true" {...threadContextDropTargetProps()} > - {composerControlsCollapsed && restingControlsHost - ? createPortal( -
    - {composerControls} -
    , - restingControlsHost, - ) - : null} @@ -3479,10 +3181,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
    {isStashMenuOpen && !composerMenuOpen && ( @@ -3621,14 +3319,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {composerAttachmentFileList} -
    +
    {previewFile ? ( - {isComposerResting ? collapsedComposerImagePreviews : null} {showMobilePendingAnswerActions ? (
    0 && "pt-2", - isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", showMobilePendingAnswerActions && "hidden sm:flex", - isComposerResting && - "absolute right-px z-10 h-12 w-auto gap-0 py-0 sm:gap-0 sm:py-0", - isComposerResting && - (showInlineRestingControls ? "bottom-[calc(2rem+1px)]" : "bottom-px"), )} > + {composerAttachButton ?
    {composerAttachButton}
    : null}
    - {composerControlsCollapsed ? null : composerControls} -
    - -
    - {composerAttachButton} +
    + {composerControls} +
    ; + runtimeModeOptions: ReadonlyArray; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; + showPermissions?: boolean; size?: "sm" | "xs"; /** Close the portaled popup when its trigger hides. */ hidden?: boolean; @@ -31,17 +30,28 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls const composerFloatingLayerProps = useComposerMenuProps(); const size = props.size ?? "sm"; const [open, setOpen] = useComposerMenuState(props.hidden); + const triggerRef = useRef(null); return ( - + focusRequestedPermission(isOpen, triggerRef.current)} + > } @@ -52,7 +62,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls {props.traitsMenuContent ? ( <> {props.traitsMenuContent} - + {props.showInteractionModeToggle || props.showPermissions !== false ? ( + + ) : null} ) : null} {props.showInteractionModeToggle ? ( @@ -68,23 +80,16 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls Chat Plan - + {props.showPermissions !== false ? : null} ) : null} -
    Access
    - { - if (!value || value === props.runtimeMode) return; - props.onRuntimeModeChange(value as RuntimeMode); - }} - > - {props.runtimeModeOptions.map((option) => ( - - {option.label} - - ))} - + {props.showPermissions === false ? null : ( + + )}
    ); diff --git a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx index 2a0c0f7b1a1ad..e3560312925e5 100644 --- a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx +++ b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx @@ -707,7 +707,8 @@ export function ComposerAttachButton({ /> } > - + {/* The clip's weight sits in its upper loop; 1px down reads as centered on the row. */} + Attach files diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx index 6f1824fd72642..9ec5d4e1144d4 100644 --- a/apps/web/src/components/chat/ComposerControl.tsx +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -1,11 +1,9 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { Select as SelectPrimitive } from "@base-ui/react/select"; import { useRender } from "@base-ui/react/use-render"; -import type { ComponentProps, ComponentType, SVGProps } from "react"; -import { ChevronDownIcon } from "lucide-react"; +import type { ComponentType, SVGProps } from "react"; import { cn } from "~/lib/utils"; -import { Separator } from "../ui/separator"; export type ComposerControlSize = "sm" | "xs"; @@ -66,43 +64,6 @@ export function ComposerControlIcon({ ); } -export function ComposerControlChevron({ - className, - size = "sm", -}: { - className?: string; - size?: ComposerControlSize; -} = {}) { - return ( -