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..ba40c4fb89cba 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -163,6 +163,29 @@ jobs: env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + # The CLI archive and the staged app install without a lockfile, so they + # ask the registry about every package and fetch platform packages the + # workspace store lacks. They share this registry metadata and store. A + # new week starts cold, which bounds how stale cached ranges can get. + - name: Resolve staged install cache + id: stage_pnpm + shell: bash + run: | + echo "dir=$RUNNER_TEMP/desktop-stage-pnpm" >> "$GITHUB_OUTPUT" + echo "prefix=desktop-stage-pnpm-v2-${{ inputs.platform }}-${{ inputs.arch }}-$(date -u +%G-%V)-" >> "$GITHUB_OUTPUT" + + - name: Restore staged install cache + id: stage_pnpm_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.stage_pnpm.outputs.dir }} + key: ${{ steps.stage_pnpm.outputs.prefix }}${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: ${{ steps.stage_pnpm.outputs.prefix }} + + - name: Mark staged install cache + shell: bash + run: mkdir -p "${{ steps.stage_pnpm.outputs.dir }}" && touch "$RUNNER_TEMP/desktop-stage-pnpm.marker" + - name: Cache resource monitor id: resource_monitor_cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 @@ -254,21 +277,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" @@ -332,6 +394,9 @@ jobs: if: inputs.cli_archive shell: bash env: + pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache + pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store + pnpm_config_prefer_offline: "true" APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} @@ -451,7 +516,10 @@ jobs: - name: Build desktop artifact shell: bash env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + # pnpm only goes to the registry for packages or versions the cache lacks. + pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache + pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store + pnpm_config_prefer_offline: "true" T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: true T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} @@ -536,6 +604,23 @@ jobs: vp run dist:desktop:artifact "${args[@]}" + # Warm installs only touch the store index, so new content means a fetch. + - name: Check staged install cache for new content + id: stage_pnpm_changed + shell: bash + run: | + dir="${{ steps.stage_pnpm.outputs.dir }}" + new=$(find "$dir/cache/v12" "$dir/store/v11/files" -type f -newer "$RUNNER_TEMP/desktop-stage-pnpm.marker" 2>/dev/null | wc -l | tr -d ' ') + echo "Staged installs added $new cached files." + echo "changed=$([[ "$new" -gt 0 ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Save staged install cache + if: steps.stage_pnpm_changed.outputs.changed == 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.stage_pnpm.outputs.dir }} + key: ${{ steps.stage_pnpm_cache.outputs.cache-primary-key }} + # A build can package fine and still not open (a missing module in the # backend or the bundle). This launches the app from the shipped # AppImage or DMG. diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ac4c6e61378e3..ea351b6ceb2f4 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", @@ -64,6 +63,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", boringSidebarEnabled: false, + boringSidebarMultilineRows: false, legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, 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, diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index afbef3362d6d5..6371da34fd190 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -56,10 +56,6 @@ async function generateMobileThirdPartyLicenses() { const manifest = await generateThirdPartyLicenseManifest({ configFile: path.join(workspaceRoot, "third-party-licenses.config.json"), packageManifests: [{ bundle: "mobile", path: path.join(__dirname, "package.json") }], - allowMissingGeneratedNotices: - process.env.NODE_ENV !== "production" && - process.env.EAS_BUILD !== "true" && - process.env.T3CODE_LICENSES_STRICT !== "1", }); await Promise.all([ diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx index ac96063001836..4150c96a34e5c 100644 --- a/apps/mobile/src/features/threads/LinkRequestCard.tsx +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -30,9 +30,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; -import { useLinkableMachines } from "../../state/peerLinks"; +import { peerLinkEnvironment, useLinkableMachines } from "../../state/peerLinks"; import { mintPairingCodeOn } from "../../state/peerPairingCode"; -import { serverEnvironment } from "../../state/server"; import { useEnvironmentScope, usePreparedConnection } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow"; @@ -107,13 +106,13 @@ function PendingLinkRequest(props: { const [open, setOpen] = useState(false); const [declining, setDeclining] = useState(false); const [error, setError] = useState(null); - const answer = useAtomCommand(serverEnvironment.answerLinkRequest, { + const answer = useAtomCommand(peerLinkEnvironment.answerRequest, { label: "decline link request", reportFailure: false, reportDefect: false, }); const canAnswer = useAtomValue( - serverEnvironment.answerLinkRequest.permissionAtom(props.environmentId), + peerLinkEnvironment.answerRequest.permissionAtom(props.environmentId), ); const target = linkRequestTargetName(item); @@ -244,7 +243,7 @@ function LinkRequestSheet(props: { const [mintFailed, setMintFailed] = useState>(new Set()); // Submit then a tap can both run before a re-render; this guard is synchronous. const inFlight = useRef(false); - const answer = useAtomCommand(serverEnvironment.answerLinkRequest, { + const answer = useAtomCommand(peerLinkEnvironment.answerRequest, { label: "answer link request", // The failure cause holds the request, and with it the code; keep it out of the console. reportFailure: false, 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 ? ( - + {driver === undefined ? ( + + ) : ( + + )} ); } @@ -91,11 +100,11 @@ export function ThreadSubagentGroup(props: { const expanded = props.expandedRows[props.anchorKey] ?? false; const serverConfigs = useServerConfigs(); // A task in a linked environment runs a provider instance of that environment. - const iconUrl = (item: SubagentItem) => + const providerOf = (item: SubagentItem) => (item.remoteChild === undefined ? config : serverConfigs.get(item.remoteChild.environmentId) - )?.providers.find((provider) => provider.instanceId === item.providerInstanceId)?.iconUrl; + )?.providers.find((provider) => provider.instanceId === item.providerInstanceId); return ( {grouped ? ( @@ -109,7 +118,7 @@ export function ThreadSubagentGroup(props: { {agents.slice(0, 3).map((agent, index) => ( - + ))} {agents.length > 3 ? ( diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 6582b8a55d91e..5060f359ebcd3 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -809,6 +809,21 @@ describe("buildThreadFeed", () => { "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/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index c85ad630bd52f..a397abc783481 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1433,6 +1433,16 @@ describe("OrchestratorMcpService provider resolution", () => { models: ["oc/m12", "oc/m01", "oc/m03", "oc/m04", "oc/m05", "oc/m06", "oc/m07", "oc/m08"], omitted: 3, }); + // A word naming the provider matches all its models, so legacy ones stay out. + assert.deepEqual(listing(yield* capabilitiesWith("Codex")), [ + { id: codexInstanceId, models: ["gpt-6", "gpt-4", "gpt-3", "my-alpha"], omitted: 1 }, + { id: opencodeInstanceId, models: [], omitted: 0 }, + ]); + assert.deepEqual(listing(yield* capabilitiesWith("codex gpt 5"))[0], { + id: codexInstanceId, + models: ["gpt-5"], + omitted: 0, + }); }), ); @@ -2778,6 +2788,71 @@ describe("OrchestratorMcpService thread lookup", () => { ); }); +describe("OrchestratorMcpService thread list", () => { + const client: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:thread-list"), + requestNamespace: "client:thread-list", + thread: undefined, + client: { sessionId: "thread-list", label: "Claude Code", access: "full-access" }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const shell = (id: string, projectId: string, snoozed: boolean) => ({ + ...liveThreadShell(ThreadId.make(id)), + projectId: ProjectId.make(projectId), + status: "completed" as const, + snoozedUntil: snoozed ? DateTime.makeUnsafe("2099-01-01T00:00:00Z") : null, + }); + const shells = [ + shell("thread:a-snoozed", "project:a", true), + shell("thread:a-awake", "project:a", false), + shell("thread:b-snoozed", "project:b", true), + ]; + const service = layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + listProjectThreads: (input) => + Effect.succeed( + shells.filter( + (thread) => input.projectId === undefined || thread.projectId === input.projectId, + ), + ), + }), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ), + ), + ); + + it.effect("lists snoozed threads across every project with allProjects", () => + Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + const listed = yield* mcp.listThreads(client, { + allProjects: true, + projectId: ProjectId.make("project:a"), + snoozed: true, + }); + assert.equal(listed.projectId, null); + assert.deepStrictEqual( + listed.threads.map((thread) => [thread.threadId, thread.projectId]), + [ + ["thread:a-snoozed", "project:a"], + ["thread:b-snoozed", "project:b"], + ], + ); + }).pipe(Effect.provide(service)), + ); +}); + describe("OrchestratorMcpService thread stop", () => { const driver = ProviderDriverKind.make("codex"); const instanceId = ProviderInstanceId.make("codex"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index d3c4ac87b9721..ca29a1e5a0b05 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -289,9 +289,9 @@ const FULL_CATALOG_DRIVERS: ReadonlySet = new Set(["codex", "claudeAgent const LISTED_MODEL_LIMIT = 8; /** - * Picks the models orchestrator_capabilities shows for one provider. Hidden - * models never appear. With search terms, lists matches; otherwise lists pinned - * models (custom, favorite, the caller's own) plus the provider's non-legacy + * Picks the models orchestrator_capabilities shows for one provider. Hidden models never + * appear. Search words naming the provider are dropped; other words list matches. With no + * words left, lists pinned models (custom, favorite, the caller's own) plus non-legacy * models, or only the first few in the user's model order for large catalogs. */ function listCapabilityModels(input: { @@ -306,10 +306,13 @@ function listCapabilityModels(input: { const visible = input.provider.models .filter((model) => model.isCustom || !input.preferences?.hiddenModels.includes(model.slug)) .toSorted((a, b) => (rank.get(a.slug) ?? rank.size) - (rank.get(b.slug) ?? rank.size)); - if (input.searchTerms.length > 0) { + const { instanceId, driver, displayName } = input.provider; + const providerText = `${instanceId} ${driver} ${displayName ?? ""}`.toLowerCase(); + const modelTerms = input.searchTerms.filter((term) => !providerText.includes(term)); + if (modelTerms.length > 0) { const matches = visible.filter((model) => { const text = `${model.slug} ${model.name} ${model.subProvider ?? ""}`.toLowerCase(); - return input.searchTerms.every((term) => text.includes(term)); + return modelTerms.every((term) => text.includes(term)); }); const listed = matches.slice(0, LISTED_MODEL_LIMIT); return { listed, omitted: matches.length - listed.length }; @@ -757,6 +760,7 @@ function listItemFromShell( ): OrchestratorMcpThreadListItem { return { threadId: shell.id, + projectId: shell.projectId, title: shell.title, link: formatThreadLink({ environmentId, threadId: shell.id }), createdBy: shell.createdBy, @@ -2765,10 +2769,12 @@ const make = Effect.gen(function* () { ), listThreads: Effect.fn("OrchestratorMcpService.listThreads")(function* (scope, input) { const { parent } = yield* loadCaller(scope); - const projectId = yield* resolveProjectTarget(parent, input.projectId); + const projectId = input.allProjects + ? null + : yield* resolveProjectTarget(parent, input.projectId); const projectThreads = yield* threadManagement .listProjectThreads({ - projectId, + projectId: projectId ?? undefined, includeSubagents: input.includeSubagents !== false, }) .pipe( diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 10cf6e2106efc..413ab984211f8 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -222,7 +222,7 @@ const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List threads in a project, newest first. Omit `projectId` for yours. Filter by status, title, settled, or snoozed. Page with the cursor.", + "List threads newest first, in one project or with `allProjects`. Page with `cursor`.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, 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 8333445230250..f5c63c32f7f5f 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, @@ -1992,6 +1993,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 573b95ff2a652..63a3f90268a30 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..cfbbd4a68a51d 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -279,12 +279,14 @@ export class ThreadManagementProjectionLoadError extends Schema.TaggedError()( "ThreadManagementProjectThreadsListError", { - projectId: ProjectId, + projectId: Schema.optional(ProjectId), cause: Schema.Defect(), }, ) { override get message(): string { - return `Unable to list threads in project ${this.projectId}.`; + return this.projectId === undefined + ? "Unable to list threads." + : `Unable to list threads in project ${this.projectId}.`; } } @@ -404,8 +406,9 @@ export interface ThreadManagementServiceShape { Orchestrator.OrchestratorV2Error >; readonly getThreadShell: Orchestrator.OrchestratorV2["Service"]["getThreadShell"]; + /** Omit `projectId` to list every project's threads. */ readonly listProjectThreads: (input: { - readonly projectId: ProjectId; + readonly projectId?: ProjectId | undefined; readonly includeSubagents: boolean; }) => Effect.Effect, ThreadManagementError>; readonly sendToThread: ( @@ -736,27 +739,32 @@ const make = Effect.gen(function* () { ); const listProjectThreads: ThreadManagementServiceShape["listProjectThreads"] = (input) => - orchestrator.getShellSnapshot({ projectId: input.projectId, location: "active" }).pipe( - Effect.mapError( - (cause) => - new ThreadManagementProjectThreadsListError({ - projectId: input.projectId, - cause, - }), - ), - Effect.map((snapshot) => - snapshot.threads - .filter( - (thread) => - input.includeSubagents || thread.lineage.relationshipToParent !== "subagent", - ) - .toSorted( - (left, right) => - DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || - right.id.localeCompare(left.id), - ), - ), - ); + orchestrator + .getShellSnapshot({ + location: "active", + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + }) + .pipe( + Effect.mapError( + (cause) => + new ThreadManagementProjectThreadsListError({ + projectId: input.projectId, + cause, + }), + ), + Effect.map((snapshot) => + snapshot.threads + .filter( + (thread) => + input.includeSubagents || thread.lineage.relationshipToParent !== "subagent", + ) + .toSorted( + (left, right) => + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || + right.id.localeCompare(left.id), + ), + ), + ); const sendToThread: ThreadManagementServiceShape["sendToThread"] = Effect.fn( "ThreadManagementService.sendToThread", @@ -1278,10 +1286,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/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index fdc39bc78bc0a..e67da4d775a89 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 5687, - "mcpToolListJsonBytes": 45861 + "mcpToolListJsonBytes": 45842 } 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/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/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/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/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e22169b79b094..2c5baf7d427a5 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1375,7 +1375,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"); @@ -1399,21 +1399,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 4a14127d238f6..8f8e2a98b477d 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -923,8 +923,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< @@ -938,6 +938,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(); @@ -949,7 +950,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 67d77c08bd375..59fd61a27eb21 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -341,6 +341,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. @@ -403,6 +405,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, @@ -413,6 +417,7 @@ function useSidebarThreads(input: { delegatedUnderParent, routeRowThreadKey, folderGroups, + keepEmptyFolders: keepEmpty, } = input; // Folders already separate the work, so boring mode has no Working shelf. const workingShelfEnabled = @@ -464,6 +469,7 @@ function useSidebarThreads(input: { serverConfigs, optimisticDrop, workingShelfEnabled, + sortActiveBySend: folderGroups !== null, clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); @@ -514,7 +520,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), @@ -614,6 +620,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { serverConfigs, optimisticDrop, workingShelfEnabled, + sortActiveBySend, } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); @@ -675,9 +682,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 @@ -982,6 +991,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, }); const boringSidebarEnabled = useBoringSidebarEnabled(); + const folderRowsMultiline = useClientSettings((settings) => settings.boringSidebarMultilineRows); const newThreadContext = useHandleNewThread(); // Row callbacks capture only the handler; the rest of the context changes with the route. const { handleNewThread } = newThreadContext; @@ -1304,6 +1314,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 @@ -1874,7 +1885,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 + @@ -3200,7 +3211,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve section === "pinned" || section === "working" || section === "bots"; - const rowVariant = folderRow ? "folder" : isCard ? "card" : "slim"; + const rowVariant = folderRow + ? folderRowsMultiline + ? "folder-multiline" + : "folder" + : isCard + ? "card" + : "slim"; return ( ({ 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 ( -