diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index c3dc1c103d4..b59ed9ddfe8 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -1,6 +1,6 @@ --- name: test-t3-app -description: Launch and test the T3 Code web app in isolated development environments, including first-try browser authentication with one-time pairing URLs, pairing-token recovery, worktree-safe state directories, dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, test UI behavior in a browser, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite. +description: Launch, retain, and test the T3 Code web app in isolated development environments, including first-try browser authentication with one-time pairing URLs, pairing-token recovery, worktree-safe state directories, cross-turn dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, iteratively test UI behavior with a human, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite. --- # Test T3 App @@ -20,6 +20,16 @@ Treat a base directory as disposable only when it was created or deliberately se The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it. +## Preserve the environment while iterating + +Treat the overall testing or implementation loop—not an assistant turn or one verification pass—as the environment lifecycle boundary. + +- Keep the dev process, base directory, selected ports, authenticated browser tab, registered projects, and seeded fixtures alive while the user may inspect the result or request follow-up changes. +- Do not stop the server merely because one verification pass completed or because you are yielding a response to the user. +- Before starting another environment, check whether the existing process and browser tab still serve the task. Reuse them when healthy instead of discarding useful state. +- On a later turn, verify that the existing process is alive and reuse its printed ports and base directory. If it exited, restart with the same base directory; create a new pairing token only when the browser session is no longer valid. +- Tell the user when a test environment remains available, including its non-secret web URL when useful. Never include a pairing token. + ## Authenticate the browser on the first navigation 1. Wait for the server log that says authentication is required and includes a URL ending in `/pair#token=...`. @@ -58,9 +68,17 @@ Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) before chang The helper refuses to write to the shared `~/.t3` directory by default and creates a database backup before each mutation. -## Finish the test +## Tear down only when the testing loop is finished + +Tear down when the user explicitly asks, confirms the iteration is finished, or the overall task is genuinely complete with no pending human review. Do not infer completion from the end of an assistant turn. + +When teardown is appropriate: + +1. Stop the dev process with its terminal interrupt. +2. Preserve the isolated base directory when it contains useful reproduction evidence or state for a likely follow-up. +3. Otherwise remove only a path created for this test after resolving and verifying the exact target. -Stop the dev process with its terminal interrupt. Preserve the isolated base directory when it contains useful reproduction evidence; otherwise remove only a path that was created for this test after resolving and verifying the exact target. A fresh isolated base directory is the safest reset when authentication, migrations, or fixture state becomes ambiguous. +If completion is uncertain, keep the environment alive and mention that it is retained for further iteration. A fresh isolated base directory remains the safest reset when authentication, migrations, or fixture state becomes ambiguous. ## Troubleshoot predictably diff --git a/.agents/skills/test-t3-app/agents/openai.yaml b/.agents/skills/test-t3-app/agents/openai.yaml index a3b89c95e60..0445ee4cb9e 100644 --- a/.agents/skills/test-t3-app/agents/openai.yaml +++ b/.agents/skills/test-t3-app/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "T3 App Testing" - short_description: "Launch and seed isolated T3 test environments" - default_prompt: "Use $test-t3-app to launch an isolated T3 development environment and test it in the browser." + short_description: "Launch and retain isolated T3 test environments" + default_prompt: "Use $test-t3-app to launch an isolated T3 environment and iteratively test it in the browser while preserving state." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 668d1fcb59d..3df8831480d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,9 +23,13 @@ on: type: string permissions: - contents: read + contents: write id-token: none +env: + T3CODE_RELEASE_REPOSITORY: aaditagrawal/t3code + T3CODE_DESKTOP_UPDATE_REPOSITORY: aaditagrawal/t3code + jobs: check_changes: name: Check for changes since last nightly @@ -78,6 +82,7 @@ jobs: cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} make_latest: ${{ steps.release_meta.outputs.make_latest }} + release_repository: ${{ steps.release_meta.outputs.release_repository }} ref: ${{ github.sha }} steps: - name: Checkout @@ -105,6 +110,20 @@ jobs: NIGHTLY_SHA: ${{ github.sha }} NIGHTLY_RUN_NUMBER: ${{ github.run_number }} run: | + release_repository="${T3CODE_RELEASE_REPOSITORY:?T3CODE_RELEASE_REPOSITORY is required}" + desktop_update_repository="${T3CODE_DESKTOP_UPDATE_REPOSITORY:?T3CODE_DESKTOP_UPDATE_REPOSITORY is required}" + for repository in "$release_repository" "$desktop_update_repository"; do + if [[ ! "$repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "Invalid release repository: $repository" >&2 + exit 1 + fi + done + if [[ "$GITHUB_REPOSITORY" != "$release_repository" || "$GITHUB_REPOSITORY" != "$desktop_update_repository" ]]; then + echo "This fork release workflow is configured for release repository $release_repository and desktop updater repository $desktop_update_repository, but it is running in $GITHUB_REPOSITORY." >&2 + exit 1 + fi + echo "release_repository=$release_repository" >> "$GITHUB_OUTPUT" + if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" @@ -204,6 +223,7 @@ jobs: - id: relay_state name: Read production relay tracing config + continue-on-error: true shell: bash run: | vp run --filter t3code-relay deploy \ @@ -213,15 +233,17 @@ jobs: --github-env-file "$RUNNER_TEMP/relay-client-tracing.env" - name: Upload relay client tracing config + continue-on-error: true uses: actions/upload-artifact@v7 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing.env - if-no-files-found: error + if-no-files-found: warn retention-days: 1 - id: public_config name: Resolve production relay public config + if: always() shell: bash run: | set -euo pipefail @@ -243,8 +265,13 @@ jobs: fi done if (( ${#missing[@]} > 0 )); then - printf 'Missing required relay deployment configuration: %s\n' "${missing[*]}" >&2 - exit 1 + # Fork: relay/Clerk production config is optional for desktop releases. + printf 'Relay public config unavailable (%s); continuing without hosted relay wiring.\n' "${missing[*]}" >&2 + echo "clerk_publishable_key=" >> "$GITHUB_OUTPUT" + echo "clerk_jwt_template=" >> "$GITHUB_OUTPUT" + echo "clerk_cli_oauth_client_id=" >> "$GITHUB_OUTPUT" + echo "relay_url=" >> "$GITHUB_OUTPUT" + exit 0 fi echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" @@ -262,7 +289,11 @@ jobs: name: Build WSL node-pty (linux-x64) needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + # Build against Debian bullseye glibc (≈ Ubuntu 20.04) so the bundled WSL + # native addon loads on common Ubuntu 20.04/22.04 WSL distros. N-API covers + # Node ABI, not libc ABI. Use a standard GitHub-hosted runner + Node bullseye + # container instead of Blacksmith Ubuntu 2004 labels, which can queue forever. + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - name: Checkout @@ -283,11 +314,16 @@ jobs: run: | set -euo pipefail # Resolve node-pty from apps/server (where it's a dependency) and build - # its native binary from source for Linux. node-addon-api resolves from - # node-pty's own dependency tree, so node-gyp has everything it needs. + # its native binary from source for Linux against an older glibc. node- + # addon-api resolves from node-pty's own dependency tree inside the + # mounted workspace, so node-gyp has everything it needs. pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")" pty_dir="$(dirname "$pty_pkg")" - ( cd "$pty_dir" && npx --yes node-gyp rebuild ) + docker run --rm \ + -v "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE" \ + -w "$pty_dir" \ + node:24.13.1-bullseye \ + bash -lc 'apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq build-essential python3 >/dev/null && npx --yes node-gyp rebuild' mkdir -p wsl-prebuild cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node file wsl-prebuild/pty.node @@ -303,20 +339,15 @@ jobs: name: Build ${{ matrix.label }} # build_wsl_node_pty stays in `needs` so it runs first and its artifact is # available to download, but only the Windows matrix entry consumes it. We - # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring - # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux - # builds. `!cancelled()` (not `!failure()`) lets the job run even when - # build_wsl_node_pty failed; the Windows-only download step below then fails - # that single platform if the prebuild is missing. - needs: [preflight, relay_public_config, build_wsl_node_pty] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} + # Gate the job on preflight only (fork: no relay requirement). `!cancelled()` + # (not `!failure()`) lets macOS/Linux builds run even when build_wsl_node_pty + # failed; the Windows-only download step below then fails that single + # platform if the prebuild is missing. + needs: [preflight, build_wsl_node_pty] + # Fork: desktop builds must not depend on upstream relay/Clerk production config. + if: ${{ !cancelled() && needs.preflight.result == 'success' }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 - env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} - T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} strategy: fail-fast: false matrix: @@ -361,6 +392,7 @@ jobs: run-install: true - name: Download relay client tracing config + continue-on-error: true uses: actions/download-artifact@v8 with: name: relay-client-tracing-config @@ -370,6 +402,10 @@ jobs: shell: bash run: | config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + if [[ ! -f "$config_path" ]]; then + echo "No relay client tracing config artifact; continuing without it." + exit 0 + fi tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" echo "::add-mask::$tracing_token" cat "$config_path" >> "$GITHUB_ENV" @@ -608,7 +644,8 @@ jobs: publish_cli: name: Publish CLI to npm needs: [preflight, relay_public_config, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} + # Fork: do not publish the globally named upstream npm package from this repo. + if: false runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 permissions: @@ -637,6 +674,7 @@ jobs: - --filter=@t3tools/scripts... - name: Download relay client tracing config + continue-on-error: true uses: actions/download-artifact@v8 with: name: relay-client-tracing-config @@ -646,6 +684,10 @@ jobs: shell: bash run: | config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + if [[ ! -f "$config_path" ]]; then + echo "No relay client tracing config artifact; continuing without it." + exit 0 + fi tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" echo "::add-mask::$tracing_token" cat "$config_path" >> "$GITHUB_ENV" @@ -664,8 +706,8 @@ jobs: release: name: Publish GitHub Release - needs: [preflight, build, publish_cli] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} + needs: [preflight, build] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: @@ -700,15 +742,29 @@ jobs: - name: Merge macOS updater manifests run: | + set -euo pipefail shopt -s nullglob + + found_mac_manifest=false for x64_manifest in release-assets/*-mac-x64.yml; do - arm64_manifest="${x64_manifest%-x64.yml}.yml" - if [[ -f "$arm64_manifest" ]]; then - node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" - rm -f "$x64_manifest" + arm64_manifest="${x64_manifest/-x64.yml/.yml}" + if [[ ! -f "$arm64_manifest" ]]; then + echo "Missing matching arm64 macOS manifest for $x64_manifest" >&2 + exit 1 fi + + found_mac_manifest=true + node scripts/merge-update-manifests.ts --platform mac \ + "$arm64_manifest" \ + "$x64_manifest" + rm -f "$x64_manifest" done + if [[ "$found_mac_manifest" != true ]]; then + echo "No macOS updater manifests found to merge." >&2 + exit 1 + fi + # - name: Merge Windows updater manifests # run: | # shopt -s nullglob @@ -745,6 +801,7 @@ jobs: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} + repository: ${{ needs.preflight.outputs.release_repository }} generate_release_notes: true previous_tag: ${{ needs.preflight.outputs.previous_tag }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} @@ -766,6 +823,7 @@ jobs: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} + repository: ${{ needs.preflight.outputs.release_repository }} generate_release_notes: true prerelease: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} @@ -814,6 +872,7 @@ jobs: - --filter=@t3tools/web... - name: Download relay client tracing config + continue-on-error: true uses: actions/download-artifact@v8 with: name: relay-client-tracing-config @@ -823,6 +882,10 @@ jobs: shell: bash run: | config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + if [[ ! -f "$config_path" ]]; then + echo "No relay client tracing config artifact; continuing without it." + exit 0 + fi tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" echo "::add-mask::$tracing_token" cat "$config_path" >> "$GITHUB_ENV" diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 108a4b7056f..82e563a0e7d 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -472,6 +472,10 @@ export function HomeScreen(props: HomeScreenProps) { // boundary is actually crossed while the app stays open (mirrors web); // without a clock dependency the partition memoizes a frozen "now". const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + // Snooze wake times are second-precise; a counter bumped exactly at the + // next wake boundary re-runs the partition with a fresh clock so a woken + // thread reappears immediately instead of on the next minute tick. + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; // Refresh immediately on enable: the mount-time value can be hours old @@ -493,8 +497,22 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const snoozeEnvironmentIds = useMemo(() => { + // Until server configs arrive, treat snooze support as unknown so the + // list builder keeps its default (assume supported) instead of flashing + // every snoozed thread as an empty Set of capabilities. + if (serverConfigs.size === 0) return undefined; + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSnooze === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; + if (!threadListV2Enabled) + return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ @@ -504,20 +522,38 @@ export function HomeScreen(props: HomeScreenProps) { searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, + snoozeEnvironmentIds, settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, + snoozeNow: new Date().toISOString(), }); }, [ changeRequestStateByKey, nowMinute, + snoozeWakeTick, settledVisibleCount, settlementEnvironmentIds, + snoozeEnvironmentIds, props.searchQuery, props.selectedEnvironmentId, props.threads, threadListV2Enabled, v2ScopedProjectGroup, ]); + // Re-partition the moment the earliest snooze expires (clamped to the + // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). + const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; + useEffect(() => { + if (nextSnoozeWakeAt === null) return; + const wakeAtMs = Date.parse(nextSnoozeWakeAt); + if (Number.isNaN(wakeAtMs)) return; + const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); + const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); + return () => clearTimeout(id); + // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is + // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout + // range) the boundary string is identical and the chain would die. + }, [nextSnoozeWakeAt, snoozeWakeTick]); const threadListV2Items = threadListV2Layout.items; const renderV2Item = useCallback( @@ -813,11 +849,31 @@ export function HomeScreen(props: HomeScreenProps) { // Self-contained: v1's listEmpty keys off projectGroups, which ignores the // v2 project scope, so it can be null (results elsewhere) while this list // is empty. Search outranks the scope — "No results" names the actionable - // fact when a query is active. Pending tasks render in the header, so the - // list showing them isn't empty in the user's eyes. + // fact when a query is active. Snoozed threads outrank the rest: "No + // threads yet" over an inbox that is merely all-snoozed reads as data + // loss. Pending tasks render in the header, so the list showing them + // isn't empty in the user's eyes. + const v2SnoozedCount = threadListV2Layout.snoozedCount; const v2ListEmpty = v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( - + v2SnoozedCount > 0 ? ( + // The snoozed threads already passed this search filter: "No + // results" would claim nothing matched when matches are merely + // parked. + + ) : ( + + ) + ) : v2SnoozedCount > 0 ? ( + ) : v2ScopedProjectGroup !== null ? ( void; @@ -45,7 +49,7 @@ export function GitActionProgressOverlay(props: { return ( + const glassBorder = useThemeColor("--color-header-border"); + const glassTint = useThemeColor("--color-glass-tint"); + const isDarkMode = useColorScheme() === "dark"; + const content = ( + <> @@ -89,7 +88,56 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { {progress.prUrl ? ( ) : null} - + + ); + + if (isLiquidGlassSupported) { + return ( + + + + {content} + + + + ); + } + + const bgClass = + progress.phase === "error" + ? "bg-red-50 dark:bg-red-950/80 border-red-200 dark:border-red-800" + : "bg-card border-border"; + + return ( + + {content} + ); } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 01403047c8e..e8b2c9b1903 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -399,6 +399,10 @@ function ThreadNavigationSidebarPane( // crossed while the pane stays open; without a clock dependency the // partition memoizes a frozen "now". const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + // Snooze wake times are second-precise; a counter bumped exactly at the + // next wake boundary re-runs the partition with a fresh clock so a woken + // thread reappears immediately instead of on the next minute tick. + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; // Refresh immediately on enable: the mount-time value can be hours old @@ -420,8 +424,19 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const snoozeEnvironmentIds = useMemo(() => { + if (serverConfigs.size === 0) return undefined; + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSnooze === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; + if (!threadListV2Enabled) + return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; return buildThreadListV2Items({ threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, @@ -429,20 +444,38 @@ function ThreadNavigationSidebarPane( searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, + snoozeEnvironmentIds, settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, + snoozeNow: new Date().toISOString(), }); }, [ changeRequestStateByKey, nowMinute, + snoozeWakeTick, options.selectedEnvironmentId, props.searchQuery, settledVisibleCount, settlementEnvironmentIds, + snoozeEnvironmentIds, threadListV2Enabled, threads, selectedProjectScope, ]); + // Re-partition the moment the earliest snooze expires (clamped to the + // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). + const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; + useEffect(() => { + if (nextSnoozeWakeAt === null) return; + const wakeAtMs = Date.parse(nextSnoozeWakeAt); + if (Number.isNaN(wakeAtMs)) return; + const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); + const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); + return () => clearTimeout(id); + // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is + // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout + // range) the boundary string is identical and the chain would die. + }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems = useMemo(() => { if (!threadListV2Enabled) return listLayout.items; // Queued offline tasks render above the thread rows (mirrors the @@ -930,15 +963,28 @@ function ThreadNavigationSidebarPane( }), [filterIcon, filterMenu, props.onOpenSettings], ); + // "No threads yet" over an inbox that is merely all-snoozed reads as + // data loss; name the snoozed threads instead. + const snoozedCount = threadListV2Layout.snoozedCount; const listEmpty = ( {catalogState.isLoadingConnections ? "Loading threads…" : props.searchQuery.trim().length > 0 - ? "No matching threads" - : selectedProjectScope !== null - ? `No threads in ${selectedProjectScope.title}` - : "No threads yet"} + ? snoozedCount > 0 + ? // Snoozed matches passed this same search filter — "No + // matching threads" would misreport them as nonexistent. + snoozedCount === 1 + ? "1 matching thread snoozed" + : "All matching threads snoozed" + : "No matching threads" + : snoozedCount > 0 + ? snoozedCount === 1 + ? "1 thread snoozed" + : `${snoozedCount} threads snoozed` + : selectedProjectScope !== null + ? `No threads in ${selectedProjectScope.title}` + : "No threads yet"} ); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index cdbf2e3c585..e62b9ceda34 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -77,6 +77,85 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { + it("hides snoozed threads and counts them — visibility parity with web", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("woken"), + title: "Woken", + // Wake time already passed: back in the active list. + snoozedUntil: "2026-06-01T18:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + // Same createdAt → static sort tiebreaks by id; the point is the woken + // thread is BACK in the card block and the snoozed one is gone. + expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]); + expect(layout.snoozedCount).toBe(1); + }); + + it("classifies snooze with the second-precise clock and reports the next wake", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("just-woke"), + title: "Just woke", + // Woke 30s ago: hidden under the minute-floored clock, visible + // under the precise one. + snoozedUntil: "2026-06-02T00:00:30.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("still-snoozed"), + title: "Still snoozed", + snoozedUntil: "2026-06-02T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + // Minute-floored partition clock vs precise snooze clock. + now: "2026-06-02T00:01:00.000Z", + snoozeNow: "2026-06-02T00:01:07.500Z", + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); + expect(layout.snoozedCount).toBe(1); + expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z"); + }); + + it("keeps snoozed threads visible on environments without the snooze capability", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + snoozeEnvironmentIds: new Set(), + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["snoozed"]); + expect(layout.snoozedCount).toBe(0); + }); + it("partitions settled threads into a slim tail with one divider", () => { const { items } = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 071e1d93be0..efa68153a3f 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,4 +1,4 @@ -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; @@ -84,6 +84,13 @@ export interface ThreadListV2Layout { readonly items: ThreadListV2Item[]; /** Settled threads beyond the render limit (behind "Show more"). */ readonly hiddenSettledCount: number; + /** Snoozed threads hidden from the list (visibility parity with web's + collapsed Snoozed shelf; mobile has no shelf UI yet). */ + readonly snoozedCount: number; + /** Soonest wake time among hidden snoozed threads, or null. Callers arm + a timeout at this boundary so the list re-partitions the moment a + snooze expires instead of on the next minute tick. */ + readonly nextSnoozeWakeAt: string | null; } /** @@ -106,13 +113,22 @@ export function buildThreadListV2Items(input: { other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ readonly settlementEnvironmentIds?: ReadonlySet; + /** Environments whose server supports thread.snooze/unsnooze. Same + contract as settlementEnvironmentIds. */ + readonly snoozeEnvironmentIds?: ReadonlySet; readonly autoSettleAfterDays?: number; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Injectable for tests; defaults to now. */ readonly now?: string; + /** Second-precise clock for snooze classification. Callers pass a + minute-quantized `now` for memoization; snooze wake times are + second-precise, so classifying with the floored minute would hold a + woken thread hidden for up to a minute. Defaults to `now`. */ + readonly snoozeNow?: string; }): ThreadListV2Layout { const now = input.now ?? new Date().toISOString(); + const snoozeNow = input.snoozeNow ?? now; const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs @@ -121,6 +137,8 @@ export function buildThreadListV2Items(input: { const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; + let snoozedCount = 0; + let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { // Callers pass live (unarchived) shells; settled threads are among them // and partition into the tail via effectiveSettled. @@ -130,8 +148,23 @@ export function buildThreadListV2Items(input: { } if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; + const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; const changeRequestState = input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; + // Visibility parity with web: a snoozed thread leaves the list until it + // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed + // work). Snooze outranks settled classification, same as web. + if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { + snoozedCount += 1; + if ( + thread.snoozedUntil != null && + (nextSnoozeWakeAt === null || + parseTimestampMs(thread.snoozedUntil) < parseTimestampMs(nextSnoozeWakeAt)) + ) { + nextSnoozeWakeAt = thread.snoozedUntil; + } + continue; + } if ( supportsSettlement && effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) @@ -168,5 +201,10 @@ export function buildThreadListV2Items(input: { if (last) { items[items.length - 1] = { ...last, isLast: true }; } - return { items, hiddenSettledCount: orderedSettled.length - visibleSettled.length }; + return { + items, + hiddenSettledCount: orderedSettled.length - visibleSettled.length, + snoozedCount, + nextSnoozeWakeAt, + }; } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 80bc1916b11..8e340ef33bd 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -60,6 +60,8 @@ function threadDetailToShell( archivedAt: thread.archivedAt, settledOverride: thread.settledOverride, settledAt: thread.settledAt, + snoozedUntil: thread.snoozedUntil ?? null, + snoozedAt: thread.snoozedAt ?? null, session: thread.session, latestUserMessageAt: latestUserMessageAt(thread), hasPendingApprovals: false, diff --git a/apps/server/package.json b/apps/server/package.json index 37d3de3c94b..8626a7d0a29 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -35,7 +35,8 @@ "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", - "node-pty": "^1.1.0" + "node-pty": "^1.1.0", + "yaml": "catalog:" }, "devDependencies": { "@effect/vitest": "catalog:", diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 181237d76b6..0eaf5a7c16a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -141,6 +141,7 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: true, threadSettlement: true, + threadSnooze: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index cfb88a06cd2..1f24a4a0200 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -609,6 +609,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -679,6 +681,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.snoozed": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + snoozedUntil: event.payload.snoozedUntil, + snoozedAt: event.payload.snoozedAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unsnoozed": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + snoozedUntil: null, + snoozedAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 15ded458e22..d4a24a209ad 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -310,6 +310,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, deletedAt: null, messages: [ { @@ -422,6 +424,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, session: { threadId: ThreadId.make("thread-1"), status: "running", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 155e9ab0013..3d05bef4bdf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -336,6 +336,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -366,6 +368,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -398,6 +402,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -762,6 +768,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1196,6 +1204,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1396,6 +1406,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1527,6 +1539,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1663,6 +1677,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1905,6 +1921,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + snoozedUntil: threadRow.value.snoozedUntil, + snoozedAt: threadRow.value.snoozedAt, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -2001,6 +2019,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + snoozedUntil: threadRow.value.snoozedUntil, + snoozedAt: threadRow.value.snoozedAt, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 0d0d7bdd5e4..3b558d24739 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -11,6 +11,8 @@ import { ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, + ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema, + ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -38,6 +40,8 @@ export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSet export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; +export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; +export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts new file mode 100644 index 00000000000..1012240b18a --- /dev/null +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -0,0 +1,286 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +// The decider's clock is the Effect test clock, pinned to the epoch, so +// "future" wake times are relative to 1970-01-01T00:00:00.000Z. +const FUTURE_WAKE = "1970-01-02T09:00:00.000Z"; +const PAST_WAKE = "1969-12-31T09:00:00.000Z"; +const SNOOZED_AT = "1969-12-30T00:00:00.000Z"; + +function makeReadModel(input: { + readonly snoozedUntil?: string | null; + readonly snoozedAt?: string | null; + readonly archivedAt?: string | null; + readonly activities?: OrchestrationThread["activities"]; + readonly messages?: OrchestrationThread["messages"]; +}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: input.archivedAt ?? null, + settledOverride: null, + settledAt: null, + snoozedUntil: input.snoozedUntil ?? null, + snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null), + deletedAt: null, + messages: input.messages ?? [], + proposedPlans: [], + activities: input.activities ?? [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("snoozed thread decider", (it) => { + it.effect("snoozes a thread to a future wake time", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.snoozed"); + if (events[0]?.type === "thread.snoozed") { + expect(events[0].payload.snoozedUntil).toBe(FUTURE_WAKE); + expect(events[0].payload.snoozedAt).toBe(events[0].payload.updatedAt); + } + }), + ); + + it.effect("rejects a wake time that is not in the future", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-past"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: PAST_WAKE, + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects an unparseable wake time", () => + Effect.gen(function* () { + // IsoDateTime is structurally a string, so garbage can reach the + // decider; a NaN wake time must never persist as snooze state. + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-garbage"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: "not-a-date", + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects snoozing blocked-on-you work", () => + Effect.gen(function* () { + const requestActivity = { + id: EventId.make("activity-req-1"), + tone: "approval" as const, + kind: "approval.requested", + summary: "approval.requested", + payload: { requestId: "req-1" }, + turnId: null, + createdAt: NOW, + } as OrchestrationThread["activities"][number]; + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-blocked"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ activities: [requestActivity] }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("re-emits idempotently for a duplicate snooze to the same wake time", () => + Effect.gen(function* () { + const reEmit = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-again"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(reEmit) ? reEmit : [reEmit]; + expect(events).toHaveLength(1); + if (events[0]?.type === "thread.snoozed") { + // Original snoozedAt preserved; updatedAt must not churn. + expect(events[0].payload.snoozedAt).toBe(SNOOZED_AT); + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("re-snoozing to a DIFFERENT wake time stamps fresh", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-extend"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: "1970-01-03T09:00:00.000Z", + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(event) ? event : [event]; + if (events[0]?.type === "thread.snoozed") { + expect(events[0].payload.snoozedUntil).toBe("1970-01-03T09:00:00.000Z"); + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("unsnoozes with reason user and re-emits idempotently when awake", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsnooze", + commandId: CommandId.make("cmd-unsnooze"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.unsnoozed"); + if (events[0]?.type === "thread.unsnoozed") { + expect(events[0].payload.reason).toBe("user"); + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + + const awake = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsnooze", + commandId: CommandId.make("cmd-unsnooze-awake"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel({}), + }); + const awakeEvents = Array.isArray(awake) ? awake : [awake]; + expect(awakeEvents[0]?.type).toBe("thread.unsnoozed"); + if (awakeEvents[0]?.type === "thread.unsnoozed") { + // No state change — keep the existing updatedAt. + expect(awakeEvents[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("rejects snoozing a thread with a queued turn start", () => + Effect.gen(function* () { + // The decider clock is the Effect test clock pinned to the epoch: a + // user message 30s before it with no adopting turn is queued work. + const queuedMessage = { + id: MessageId.make("message-queued"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt: "1969-12-31T23:59:30.000Z", + updatedAt: "1969-12-31T23:59:30.000Z", + } as OrchestrationThread["messages"][number]; + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-queued"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ messages: [queuedMessage] }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects snoozing an archived thread", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-archived"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ archivedAt: NOW }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("a user message spends the snooze return ticket (activity wake)", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(result) ? result : [result]; + const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed"); + expect(unsnoozed).toBeDefined(); + if (unsnoozed?.type === "thread.unsnoozed") { + expect(unsnoozed.payload.reason).toBe("activity"); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index cba967afc7c..100369ae6e3 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -53,6 +53,13 @@ function isStaleRequestFailureDetail(payload: Record | null): b ); } +// Scans the read model's activities, which the projector caps at the most +// recent 500. That bound is safe here: an OPEN approval/user-input request +// blocks its turn, so the thread cannot accumulate hundreds of later +// activities while one is outstanding — a request that has scrolled out of +// the window is one whose turn kept running, i.e. it was resolved or went +// stale. (The projection pipeline's pendingApprovalCount reads the same +// capped stream and stays consistent with this view.) function hasOpenBlockingRequest(thread: { readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; }): boolean { @@ -79,6 +86,62 @@ function hasOpenBlockingRequest(thread: { return openRequestIds.size > 0; } +/** + * A queued turn start — a user message no turn has picked up yet — is work + * in flight even though session is still null (turn.start emits + * message-sent + turn-start-requested; the session arrives later). Detection + * mirrors the client's hasQueuedTurnStart: the newest user message is + * strictly newer than every latestTurn timestamp (adoption stamps the new + * turn's requestedAt with the message time, clearing this), and only within + * the adoption grace window — historical threads whose last user message + * postdates their turn timestamps (older-server data, mid-turn messages) + * must not be blocked forever. A failed session start (status "error") + * clears the block immediately. + * + * The age check is bounded on BOTH sides: message timestamps are + * client-supplied, so a client clock ahead of the server yields a negative + * age. Without the lower bound that negative age satisfies `<= grace` for + * as long as the skew lasts, extending the block far past the intended two + * minutes. + */ +function threadHasQueuedTurnStart( + thread: { + readonly messages: ReadonlyArray<{ readonly role: string; readonly createdAt: string }>; + readonly latestTurn: { + readonly requestedAt: string; + readonly startedAt: string | null; + readonly completedAt: string | null; + } | null; + readonly session: { readonly status: string } | null; + }, + occurredAt: string, +): boolean { + const latestUserMessageAtMs = thread.messages.reduce( + (latest, message) => + message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, + Number.NEGATIVE_INFINITY, + ); + const latestTurnAtMs = + thread.latestTurn === null + ? Number.NEGATIVE_INFINITY + : Math.max( + ...[ + thread.latestTurn.requestedAt, + thread.latestTurn.startedAt, + thread.latestTurn.completedAt, + ].map((candidate) => + candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), + ), + ); + const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; + return ( + thread.session?.status !== "error" && + Number.isFinite(latestUserMessageAtMs) && + latestUserMessageAtMs > latestTurnAtMs && + Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS + ); +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -411,46 +474,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ); } const occurredAt = yield* nowIso; - // A queued turn start — a user message no turn has picked up yet — is - // work in flight even though session is still null (turn.start emits - // message-sent + turn-start-requested; the session arrives later). - // Settling in that window would hide just-requested work. Detection - // mirrors the client's hasQueuedTurnStart: the newest user message is - // strictly newer than every latestTurn timestamp (adoption stamps the - // new turn's requestedAt with the message time, clearing this), and - // only within the adoption grace window — historical threads whose - // last user message postdates their turn timestamps (older-server - // data, mid-turn messages) must stay settleable. A failed session - // start (status "error") clears the block immediately. - const latestUserMessageAtMs = thread.messages.reduce( - (latest, message) => - message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, - Number.NEGATIVE_INFINITY, - ); - const latestTurnAtMs = - thread.latestTurn === null - ? Number.NEGATIVE_INFINITY - : Math.max( - ...[ - thread.latestTurn.requestedAt, - thread.latestTurn.startedAt, - thread.latestTurn.completedAt, - ].map((candidate) => - candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), - ), - ); - // The age check is bounded on BOTH sides: message timestamps are - // client-supplied, so a client clock ahead of the server yields a - // negative age. Without the lower bound that negative age satisfies - // `<= grace` for as long as the skew lasts, extending the settle - // block far past the intended two minutes. - const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; - const hasQueuedTurnStart = - thread.session?.status !== "error" && - Number.isFinite(latestUserMessageAtMs) && - latestUserMessageAtMs > latestTurnAtMs && - Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS; - if (hasQueuedTurnStart) { + // Settling inside the adoption window would hide just-requested work. + if (threadHasQueuedTurnStart(thread, occurredAt)) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, @@ -508,6 +533,103 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.snooze": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + // A wake time in the past would create a thread that is snoozed and + // woken at once — the row would never leave the inbox but still carry + // snooze state. Reject instead of silently normalizing. The negated + // comparison also catches unparseable wake times (IsoDateTime is + // structurally just a string): NaN fails every comparison, and an + // unparseable snoozedUntil must never persist. + if (!(Date.parse(command.snoozedUntil) > Date.parse(occurredAt))) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future`, + }), + ); + } + // Blocked-on-you work must not be snoozed away: a pending approval or + // user-input request is the agent waiting on the user, and hiding it + // defeats the request. (A running session IS snoozable — snooze only + // affects visibility, never the agent.) + if (hasOpenBlockingRequest(thread)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed`, + }), + ); + } + // A queued turn start — a user message no turn has adopted yet — is + // invisible pending work: no session, no pending flags. Snoozing in + // that window would hide a just-requested turn exactly the way settle + // would. + if (threadHasQueuedTurnStart(thread, occurredAt)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, + }), + ); + } + // Re-snoozing an already-snoozed thread to the SAME wake time is a + // duplicate (double-click, raced clients): re-emit with the original + // timestamps so the projection is a no-op. A different wake time is a + // real change and stamps fresh. + const existingSnoozedAt = + thread.snoozedUntil === command.snoozedUntil && thread.snoozedAt != null + ? thread.snoozedAt + : null; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.snoozed", + payload: { + threadId: command.threadId, + snoozedUntil: command.snoozedUntil, + snoozedAt: existingSnoozedAt ?? occurredAt, + updatedAt: existingSnoozedAt !== null ? thread.updatedAt : occurredAt, + }, + }; + } + + case "thread.unsnooze": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.settle): waking a thread that + // is not snoozed lands on the same null state without churning + // updatedAt. + const alreadyAwake = thread.snoozedUntil == null; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: command.reason, + updatedAt: alreadyAwake ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -663,24 +785,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // Real activity resets ANY override: it wakes an explicitly settled // thread, and it clears a keep-active pin back to neutral so the // thread can auto-settle again after this burst of work goes stale. - if (targetThread.settledOverride === null) { - return [userMessageEvent, turnStartRequestedEvent]; + // A snooze clears the same way — sending a message to a snoozed + // thread is the user re-engaging, so the return ticket is spent. + const lifecycleResetEvents: Array> = []; + if (targetThread.settledOverride !== null) { + lifecycleResetEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); } - const unsettledEvent: Omit = { - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, - })), - type: "thread.unsettled", - payload: { - threadId: command.threadId, - reason: "activity", - updatedAt: command.createdAt, - }, - }; - return [unsettledEvent, userMessageEvent, turnStartRequestedEvent]; + if (targetThread.snoozedUntil != null) { + lifecycleResetEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); + } + return [...lifecycleResetEvents, userMessageEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { @@ -822,7 +962,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; // Only a session coming alive is activity worth waking a settled thread // for — status writes like ready/stopped/error arrive after the fact and - // must not fight a user's explicit settle. + // must not fight a user's explicit settle. Snooze is deliberately NOT + // cleared here: snooze never pauses the agent, so its session starting + // or erroring is not the user re-engaging. Blocked/failed work still + // surfaces immediately — effectiveSnoozed refuses to classify a thread + // with a raised hand (approval / input / failure / fresh completion) + // as snoozed, without spending the return ticket. const isSessionActivity = command.session.status === "starting" || command.session.status === "running"; // Real activity resets ANY override (settled wakes, active unpins). diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index e9a55c0796b..9c07a312023 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -91,6 +91,8 @@ describe("orchestration projector", () => { archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index c8f47dcebbf..0504cb36f9a 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -23,8 +23,10 @@ import { ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, ThreadSettledPayload, + ThreadSnoozedPayload, ThreadUnarchivedPayload, ThreadUnsettledPayload, + ThreadUnsnoozedPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -290,6 +292,8 @@ export function projectEvent( archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, deletedAt: null, messages: [], activities: [], @@ -365,6 +369,30 @@ export function projectEvent( })), ); + case "thread.snoozed": + return decodeForEvent(ThreadSnoozedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + snoozedUntil: payload.snoozedUntil, + snoozedAt: payload.snoozedAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unsnoozed": + return decodeForEvent(ThreadUnsnoozedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + snoozedUntil: null, + snoozedAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 497aa8b7d2c..4763f565653 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -93,6 +93,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -153,6 +155,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: "settled", settledAt: "2026-03-25T00:00:00.000Z", + snoozedUntil: "2026-03-26T09:00:00.000Z", + snoozedAt: "2026-03-25T00:00:00.000Z", latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -169,12 +173,17 @@ projectionRepositoriesLayer("Projection repositories", (it) => { } assert.strictEqual(row.settledOverride, "settled"); assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); + assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z"); + assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z"); - // Un-settle to the keep-active pin and confirm the flip persists. + // Un-settle to the keep-active pin and wake the snooze; confirm the + // flips persist. yield* threads.upsert({ ...row, settledOverride: "active", settledAt: null, + snoozedUntil: null, + snoozedAt: null, }); const repersisted = yield* threads.getById({ threadId: ThreadId.make("thread-settled"), @@ -182,6 +191,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const updated = Option.getOrNull(repersisted); assert.strictEqual(updated?.settledOverride, "active"); assert.strictEqual(updated?.settledAt, null); + assert.strictEqual(updated?.snoozedUntil, null); + assert.strictEqual(updated?.snoozedAt, null); }), ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index e0c85e91494..7e86d49eac3 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -45,6 +45,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at, settled_override, settled_at, + snoozed_until, + snoozed_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -66,6 +68,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.archivedAt}, ${row.settledOverride}, ${row.settledAt}, + ${row.snoozedUntil}, + ${row.snoozedAt}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -87,6 +91,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at = excluded.archived_at, settled_override = excluded.settled_override, settled_at = excluded.settled_at, + snoozed_until = excluded.snoozed_until, + snoozed_at = excluded.snoozed_at, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -115,6 +121,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -145,6 +153,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 2855baf58f9..7e7c54ec4d1 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -56,6 +56,7 @@ import Migration0033 from "./Migrations/030_ProjectionThreadShellArchiveIndexes. import Migration0034 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0035 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0036 from "./Migrations/033_ProjectionThreadsSettled.ts"; +import Migration0037 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; /** * Migration loader with all migrations defined inline. @@ -104,6 +105,7 @@ export const migrationEntries = [ [34, "AuthAuthorizationScopes", Migration0034], [35, "AuthPairingProofKeyThumbprint", Migration0035], [36, "ProjectionThreadsSettled", Migration0036], + [37, "ProjectionThreadsSnoozed", Migration0037], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/034_ProjectionThreadsSnoozed.ts b/apps/server/src/persistence/Migrations/034_ProjectionThreadsSnoozed.ts new file mode 100644 index 00000000000..5259ae44501 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_ProjectionThreadsSnoozed.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "snoozed_until")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN snoozed_until TEXT + `; + } + + if (!columns.some((column) => column.name === "snoozed_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN snoozed_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 8057d434950..056425ae886 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -38,6 +38,8 @@ export const ProjectionThread = Schema.Struct({ archivedAt: Schema.NullOr(IsoDateTime), settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), settledAt: Schema.NullOr(IsoDateTime), + snoozedUntil: Schema.NullOr(IsoDateTime), + snoozedAt: Schema.NullOr(IsoDateTime), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ee9cddf949c..c2fc11311aa 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -118,6 +118,7 @@ export const ClaudeDriver: ProviderDriver = { create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; @@ -165,9 +166,11 @@ export const ClaudeDriver: ProviderDriver = { effectiveConfig, () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), processEnv, + cwd, ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts new file mode 100644 index 00000000000..1ad843d7573 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -0,0 +1,205 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverClaudeSkills } from "./ClaudeSkills.ts"; + +const writeSkill = Effect.fn(function* ( + skillsDir: string, + directoryName: string, + contents: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDir = path.join(skillsDir, directoryName); + yield* fs.makeDirectory(skillDir, { recursive: true }); + yield* fs.writeFileString(path.join(skillDir, "SKILL.md"), contents); +}); + +it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { + it.effect("discovers user and project skills with frontmatter metadata", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "codex-review", + [ + "---", + "name: codex-review", + "description: Ask Codex for a review.", + "---", + "", + "# Body", + ].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "deploy", + ["---", "name: deploy", "description: Deploy the app.", "---", "", "# Deploy"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "codex-review", + path: path.join(configDir, "skills", "codex-review", "SKILL.md"), + enabled: true, + scope: "user", + description: "Ask Codex for a review.", + }, + { + name: "deploy", + path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), + enabled: true, + scope: "project", + description: "Deploy the app.", + }, + ]); + }), + ); + + it.effect("prefers project skills over user skills on name collisions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "deploy", + ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "deploy", + ["---", "name: deploy", "description: Project deploy.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.equal(skills.length, 1); + assert.equal(skills[0]?.scope, "project"); + assert.equal(skills[0]?.description, "Project deploy."); + }), + ); + + it.effect("falls back to the directory name and skips malformed frontmatter", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const skillsDir = path.join(configDir, "skills"); + + yield* writeSkill(skillsDir, "no-frontmatter", "# Just a heading\n"); + yield* writeSkill(skillsDir, "broken-yaml", "---\nname: [unclosed\n---\n"); + // A stray file (not a directory with SKILL.md) must be skipped. + yield* fs.makeDirectory(skillsDir, { recursive: true }); + yield* fs.writeFileString(path.join(skillsDir, "README.md"), "not a skill"); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined); + + // A skill with no frontmatter falls back to its directory name; a skill + // whose frontmatter fails to parse is skipped entirely (Claude Code + // won't load it either). + assert.deepEqual( + skills.map((skill) => skill.name), + ["no-frontmatter"], + ); + assert.equal(skills[0]?.description, undefined); + }), + ); + + it.effect("honors CLAUDE_CONFIG_DIR from the environment when homePath is unset", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const environmentConfigDir = path.join(tempDir, "env-config"); + + yield* writeSkill( + path.join(environmentConfigDir, "skills"), + "env-skill", + ["---", "name: env-skill", "description: From env config dir.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: "" }, undefined, { + CLAUDE_CONFIG_DIR: environmentConfigDir, + }); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["env-skill"], + ); + + // An explicit homePath wins over the environment variable, matching + // makeClaudeEnvironment which overwrites CLAUDE_CONFIG_DIR for the CLI. + const explicitHome = path.join(tempDir, "explicit-home"); + yield* writeSkill( + path.join(explicitHome, "skills"), + "explicit-skill", + ["---", "name: explicit-skill", "---"].join("\n"), + ); + const explicitSkills = yield* discoverClaudeSkills({ homePath: explicitHome }, undefined, { + CLAUDE_CONFIG_DIR: environmentConfigDir, + }); + assert.deepEqual( + explicitSkills.map((skill) => skill.name), + ["explicit-skill"], + ); + }), + ); + + it.effect("resolves a relative CLAUDE_CONFIG_DIR against the workspace cwd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const workspace = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspace, { recursive: true }); + + // The spawned CLI resolves a relative CLAUDE_CONFIG_DIR against its own + // cwd (the workspace), so discovery must do the same. + yield* writeSkill( + path.join(workspace, "relative-config", "skills"), + "relative-skill", + ["---", "name: relative-skill", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: "" }, workspace, { + CLAUDE_CONFIG_DIR: "relative-config", + }); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["relative-skill"], + ); + assert.equal(skills[0]?.scope, "user"); + }), + ); + + it.effect("returns an empty list when no skill roots exist", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + + const skills = yield* discoverClaudeSkills( + { homePath: path.join(tempDir, "missing-home") }, + path.join(tempDir, "missing-workspace"), + ); + + assert.deepEqual(skills, []); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts new file mode 100644 index 00000000000..335c3d4681d --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -0,0 +1,148 @@ +/** + * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker. + * + * Claude Code loads skills from `/skills` (user scope) and + * `/.claude/skills` (project scope), one directory per skill with a + * `SKILL.md` carrying YAML frontmatter. The Agent SDK init handshake surfaces + * skills only as slash commands without their filesystem paths, so the + * provider snapshot scans the same locations directly, mirroring how the + * Codex app-server reports its skills. + * + * @module provider/Drivers/ClaudeSkills + */ +import * as NodeOS from "node:os"; + +import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { parse as parseYamlDocument } from "yaml"; + +import { expandHomePath } from "../../pathExpansion.ts"; + +type ClaudeSkillScope = "user" | "project"; + +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; + +type SkillFrontmatter = + | { readonly kind: "missing" } + | { readonly kind: "malformed" } + | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; + +function parseSkillFrontmatter(contents: string): SkillFrontmatter { + const match = FRONTMATTER_PATTERN.exec(contents); + if (!match) { + return { kind: "missing" }; + } + + let parsed: unknown; + try { + parsed = parseYamlDocument(match[1] ?? ""); + } catch { + return { kind: "malformed" }; + } + if (typeof parsed !== "object" || parsed === null) { + return { kind: "malformed" }; + } + + const record = parsed as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + return { + kind: "parsed", + ...(name ? { name } : {}), + ...(description ? { description } : {}), + }; +} + +/** + * Resolve the Claude config directory the CLI would use, matching the + * precedence the spawned CLI sees: the instance's `homePath` (exported as + * `CLAUDE_CONFIG_DIR` by `makeClaudeEnvironment`), then a `CLAUDE_CONFIG_DIR` + * already present in the process environment, then `~/.claude`. + */ +const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* ( + config: Pick, + environment: NodeJS.ProcessEnv, + cwd?: string, +): Effect.fn.Return { + const path = yield* Path.Path; + const homePath = config.homePath.trim(); + if (homePath.length > 0) { + return path.resolve(expandHomePath(homePath)); + } + // No tilde expansion here: the spawned CLI receives this env var verbatim + // (env vars are never shell-expanded), so a literal `~` must stay literal + // for discovery to scan the same directory the runtime would. A relative + // value is resolved against the workspace cwd — the subprocess's own cwd — + // for the same reason. + const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; + if (environmentConfigDir.length > 0) { + return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir); + } + return path.join(NodeOS.homedir(), ".claude"); +}); + +/** + * Enumerate Claude Code skills from the user config dir and the workspace. + * Discovery is best-effort: unreadable roots and malformed skill entries are + * skipped so a broken skill never degrades the provider snapshot. On name + * collisions the project-scoped skill wins, matching Claude Code's + * most-specific-wins resolution. + */ +export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( + config: Pick, + cwd?: string, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); + + const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ + { directory: path.join(configDirPath, "skills"), scope: "user" }, + ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), + ]; + + const skillsByName = new Map(); + for (const root of roots) { + const entries = yield* fileSystem + .readDirectory(root.directory) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + for (const entry of [...entries].sort()) { + const skillPath = path.join(root.directory, entry, "SKILL.md"); + const contents = yield* fileSystem + .readFileString(skillPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (contents === undefined) { + continue; + } + + const frontmatter = parseSkillFrontmatter(contents); + // Malformed frontmatter means the skill won't load in Claude Code + // either — skip it rather than surfacing a broken entry under its + // directory name. + if (frontmatter.kind === "malformed") { + continue; + } + + const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); + if (!name) { + continue; + } + + skillsByName.set(name, { + name, + path: skillPath, + enabled: true, + scope: root.scope, + ...(frontmatter.kind === "parsed" && frontmatter.description + ? { description: frontmatter.description } + : {}), + }); + } + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 835400d2dfa..af461365528 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -532,6 +532,29 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("preserves xhigh effort for Claude Opus 5", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-5", + [{ id: "effort", value: "xhigh" }], + ), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "xhigh"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 76079504f72..1850b814901 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; @@ -40,6 +41,7 @@ import { } from "../providerSnapshot.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -49,6 +51,7 @@ const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, } as const; +const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_SONNET_5_VERSION = "2.1.197"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; @@ -149,6 +152,42 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], }), }, + { + slug: "claude-opus-5", + name: "Claude Opus 5", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + { value: "ultracode", label: "Ultracode" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + // Claude Code selects the 1M variant explicitly (`claude-opus-5[1m]`). + options: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], + }), + ], + }), + }, { slug: "claude-opus-4-8", name: "Claude Opus 4.8", @@ -332,6 +371,10 @@ function withClaudeSonnet5ContextWindowSelector( }); } +function supportsClaudeOpus5(version: string | null | undefined): boolean { + return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; +} + function supportsClaudeFable5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; } @@ -352,6 +395,9 @@ export function getBuiltInClaudeModelsForVersion( version: string | null | undefined, ): ReadonlyArray { return BUILT_IN_MODELS.filter((model) => { + if (model.slug === "claude-opus-5") { + return supportsClaudeOpus5(version); + } if (model.slug === "claude-fable-5") { return supportsClaudeFable5(version); } @@ -368,6 +414,11 @@ export function getBuiltInClaudeModelsForVersion( }); } +function formatClaudeOpus5UpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; +} + function formatClaudeFable5UpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; return `Claude Code ${versionLabel} is too old for Claude Fable 5. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_VERSION} or newer to access it.`; @@ -432,6 +483,7 @@ export function normalizeClaudeCliEffort( if ( effort === "xhigh" && model !== "claude-fable-5" && + model !== "claude-opus-5" && model !== "claude-opus-4-8" && model !== "claude-sonnet-5" ) { @@ -812,10 +864,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings: ClaudeSettings, ) => Effect.Effect, environment?: NodeJS.ProcessEnv, + cwd?: string, ): Effect.fn.Return< ServerProviderDraft, never, - ChildProcessSpawner.ChildProcessSpawner | Path.Path + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -917,19 +970,22 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeSonnet5(parsedVersion) + const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) ? undefined - : supportsClaudeFable5(parsedVersion) - ? formatClaudeSonnet5UpgradeMessage(parsedVersion) - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + : supportsClaudeSonnet5(parsedVersion) + ? formatClaudeOpus5UpgradeMessage(parsedVersion) + : supportsClaudeFable5(parsedVersion) + ? formatClaudeSonnet5UpgradeMessage(parsedVersion) + : supportsClaudeOpus48(parsedVersion) + ? formatClaudeFable5UpgradeMessage(parsedVersion) + : supportsClaudeOpus47(parsedVersion) + ? formatClaudeOpus48UpgradeMessage(parsedVersion) + : formatClaudeOpus47UpgradeMessage(parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; + const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); const slashCommands = capabilities?.slashCommands ?? []; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); @@ -940,6 +996,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models, slashCommands: dedupedSlashCommands, + skills, probe: { installed: true, version: parsedVersion, @@ -961,6 +1018,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models, slashCommands: dedupedSlashCommands, + skills, probe: { installed: true, version: parsedVersion, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3e4349cb61b..d0e4f826f0a 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1873,6 +1873,62 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("includes Claude Opus 5 on supported Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + const opus5 = status.models.find((model) => model.slug === "claude-opus-5"); + assert.strictEqual(opus5?.name, "Claude Opus 5"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.219\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides Claude Opus 5 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-5"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.218 is too old for Claude Opus 5. Upgrade to v2.1.219 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.218\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("includes Claude Fable 5 on supported Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index cbb9ec82b88..c4ae7b96694 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -12,6 +12,8 @@ import { resolveBranchToolbarValue, resolveLockedWorkspaceLabel, resolveLocalCheckoutBranchMismatch, + resolvePreviousWorktreeLabel, + resolvePreviousWorktreeSeed, shouldIncludeBranchPickerItem, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -19,6 +21,91 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); +describe("resolvePreviousWorktreeSeed", () => { + it("picks the most recently updated worktree thread", () => { + expect( + resolvePreviousWorktreeSeed({ + threads: [ + { + branch: "t3/older", + worktreePath: "/repo/.t3/worktrees/older", + updatedAt: "2026-07-20T00:00:00.000Z", + }, + { + branch: "t3/newer", + worktreePath: "/repo/.t3/worktrees/newer", + updatedAt: "2026-07-22T00:00:00.000Z", + }, + { branch: "main", worktreePath: null, updatedAt: "2026-07-23T00:00:00.000Z" }, + ], + currentWorktreePath: null, + }), + ).toEqual({ branch: "t3/newer", worktreePath: "/repo/.t3/worktrees/newer" }); + }); + + it("skips the worktree the composer already points at", () => { + expect( + resolvePreviousWorktreeSeed({ + threads: [ + { + branch: "t3/current", + worktreePath: "/repo/.t3/worktrees/current", + updatedAt: "2026-07-22T00:00:00.000Z", + }, + ], + currentWorktreePath: "/repo/.t3/worktrees/current", + }), + ).toBeNull(); + }); + + it("returns null when no thread has a worktree", () => { + expect( + resolvePreviousWorktreeSeed({ + threads: [{ branch: "main", worktreePath: null, updatedAt: "2026-07-22T00:00:00.000Z" }], + currentWorktreePath: null, + }), + ).toBeNull(); + }); + + it("ignores archived threads and threads with unparseable timestamps", () => { + expect( + resolvePreviousWorktreeSeed({ + threads: [ + { + branch: "t3/archived", + worktreePath: "/repo/.t3/worktrees/archived", + updatedAt: "2026-07-23T00:00:00.000Z", + archivedAt: "2026-07-23T01:00:00.000Z", + }, + { + branch: "t3/garbage-timestamp", + worktreePath: "/repo/.t3/worktrees/garbage", + updatedAt: "not-a-date", + }, + { + branch: "t3/live", + worktreePath: "/repo/.t3/worktrees/live", + updatedAt: "2026-07-21T00:00:00.000Z", + archivedAt: null, + }, + ], + currentWorktreePath: null, + }), + ).toEqual({ branch: "t3/live", worktreePath: "/repo/.t3/worktrees/live" }); + }); +}); + +describe("resolvePreviousWorktreeLabel", () => { + it("includes the branch when known", () => { + expect(resolvePreviousWorktreeLabel({ branch: "t3/fix-thing", worktreePath: "/wt" })).toBe( + "Previous worktree (t3/fix-thing)", + ); + expect(resolvePreviousWorktreeLabel({ branch: null, worktreePath: "/wt" })).toBe( + "Previous worktree", + ); + }); +}); + describe("resolveDraftEnvModeAfterBranchChange", () => { it("switches to local mode when returning from an existing worktree to the main worktree", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index c083c335292..8fe35fa464a 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,5 +1,6 @@ import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { toSortableTimestamp } from "../lib/threadSort"; export { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, @@ -65,6 +66,53 @@ export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): return activeWorktreePath ? "Worktree" : "Local checkout"; } +export interface PreviousWorktreeSeed { + branch: string | null; + worktreePath: string; +} + +// The most recently touched worktree in the project that the composer isn't +// already pointing at. Backs the "Previous worktree" entry in the workspace +// selector so a follow-up thread can hop back into the worktree you just +// worked in without hunting for its branch. Archived threads don't compete — +// the rest of the UI hides them, so their worktrees shouldn't resurface here. +export function resolvePreviousWorktreeSeed(input: { + threads: ReadonlyArray<{ + branch: string | null; + worktreePath: string | null; + updatedAt: string; + archivedAt?: string | null; + }>; + currentWorktreePath: string | null; +}): PreviousWorktreeSeed | null { + let latest: { branch: string | null; worktreePath: string; updatedAt: number } | null = null; + for (const thread of input.threads) { + if ( + !thread.worktreePath || + thread.worktreePath === input.currentWorktreePath || + (thread.archivedAt ?? null) !== null + ) { + continue; + } + const updatedAt = toSortableTimestamp(thread.updatedAt); + if (updatedAt === null) { + continue; + } + if (latest === null || updatedAt > latest.updatedAt) { + latest = { + branch: thread.branch, + worktreePath: thread.worktreePath, + updatedAt, + }; + } + } + return latest === null ? null : { branch: latest.branch, worktreePath: latest.worktreePath }; +} + +export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string { + return seed.branch ? `Previous worktree (${seed.branch})` : "Previous worktree"; +} + export function resolveEffectiveEnvMode(input: { activeWorktreePath: string | null; hasServerThread: boolean; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index ffab9cbdf86..e703427d4b6 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -6,12 +6,13 @@ import { FolderGit2Icon, FolderGitIcon, FolderIcon, + HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useMemo } from "react"; +import { memo, useCallback, useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; -import { useProject, useThread } from "../state/entities"; +import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; import { useIsMobile } from "../hooks/useMediaQuery"; import { type EnvMode, @@ -20,6 +21,8 @@ import { resolveEnvModeLabel, resolveEffectiveEnvMode, resolveLockedWorkspaceLabel, + resolvePreviousWorktreeLabel, + resolvePreviousWorktreeSeed, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; @@ -66,6 +69,8 @@ interface MobileRunContextSelectorProps { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; + previousWorktreeLabel: string | null; + onUsePreviousWorktree: () => void; } const MobileRunContextSelector = memo(function MobileRunContextSelector({ @@ -79,6 +84,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ effectiveEnvMode, activeWorktreePath, onEnvModeChange, + previousWorktreeLabel, + onUsePreviousWorktree, }: MobileRunContextSelectorProps) { const activeEnvironment = useMemo( () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, @@ -166,7 +173,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ Workspace onEnvModeChange(value as EnvMode)} + onValueChange={(value) => { + if (value === "previous-worktree") { + onUsePreviousWorktree(); + return; + } + onEnvModeChange(value as EnvMode); + }} > @@ -186,6 +199,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {resolveEnvModeLabel("worktree")} + {previousWorktreeLabel ? ( + + + + {previousWorktreeLabel} + + + ) : null} @@ -217,6 +238,7 @@ export const BranchToolbar = memo(function BranchToolbar({ const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread @@ -234,6 +256,40 @@ export const BranchToolbar = memo(function BranchToolbar({ }); const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); + // "Previous worktree" hops a draft into the most recently active worktree + // of this project — the "keep going where I just was" follow-up flow. Only + // drafts can hop; started server threads have their workspace pinned. + const canUsePreviousWorktree = draftThread !== null && serverThread === null && !envModeLocked; + const projectRefsForWorktreeLookup = useMemo( + () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []), + [canUsePreviousWorktree, activeProjectRef], + ); + const projectThreads = useThreadShellsForProjectRefs(projectRefsForWorktreeLookup); + const previousWorktreeSeed = useMemo( + () => + canUsePreviousWorktree + ? resolvePreviousWorktreeSeed({ + threads: projectThreads, + currentWorktreePath: activeWorktreePath, + }) + : null, + [activeWorktreePath, canUsePreviousWorktree, projectThreads], + ); + const previousWorktreeLabel = previousWorktreeSeed + ? resolvePreviousWorktreeLabel(previousWorktreeSeed) + : null; + const onUsePreviousWorktree = useCallback(() => { + if (!previousWorktreeSeed || !activeProjectRef) return; + // Same shape the branch selector writes when picking a branch that + // already lives in a worktree: point the draft at the existing tree. + setDraftThreadContext(draftId ?? threadRef, { + branch: previousWorktreeSeed.branch, + worktreePath: previousWorktreeSeed.worktreePath, + envMode: "worktree", + projectRef: activeProjectRef, + }); + }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]); + const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); @@ -248,7 +304,7 @@ export const BranchToolbar = memo(function BranchToolbar({ if (!hasActiveThread || !activeProject) return null; return ( -
+
{isMobile ? ( ) : (
@@ -280,6 +338,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} + previousWorktreeLabel={previousWorktreeLabel} + onUsePreviousWorktree={onUsePreviousWorktree} />
)} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 08ee713a932..763de56d8c4 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -731,7 +731,7 @@ export function BranchToolbarBranchSelector({ > } - className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" + className="min-w-0 max-w-full text-muted-foreground/70 hover:text-foreground/80" disabled={isInitialBranchesLoadPending || isBranchActionPending} > diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6d06882662f..e915c27312c 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -1,4 +1,4 @@ -import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; +import { FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon } from "lucide-react"; import { memo, useMemo } from "react"; import { @@ -17,11 +17,15 @@ import { SelectValue, } from "./ui/select"; +export const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; + interface BranchToolbarEnvModeSelectorProps { envLocked: boolean; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; + previousWorktreeLabel?: string | null; + onUsePreviousWorktree?: () => void; } export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ @@ -29,13 +33,19 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe effectiveEnvMode, activeWorktreePath, onEnvModeChange, + previousWorktreeLabel, + onUsePreviousWorktree, }: BranchToolbarEnvModeSelectorProps) { + const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); const envModeItems = useMemo( () => [ { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, { value: "worktree", label: resolveEnvModeLabel("worktree") }, + ...(showPreviousWorktree && previousWorktreeLabel + ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }] + : []), ], - [activeWorktreePath], + [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree], ); if (envLocked) { @@ -60,7 +70,13 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 2f865855598..57c12959ffb 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,16 +12,20 @@ import type { Thread } from "../types"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + branchMismatchKey, buildExpiredTerminalContextToastCopy, buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, + dismissBranchMismatchForSession, getStartedThreadModelChangeBlockReason, hasServerAcknowledgedLocalDispatch, + isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -292,6 +296,61 @@ describe("resolveSendEnvMode", () => { }); }); +describe("branchMismatchKey", () => { + it("builds a key from thread id and both branches", () => { + expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( + "thread-1:feat/a:feat/b", + ); + }); + + it("returns null without a thread or mismatch", () => { + expect(branchMismatchKey(null, { threadBranch: "a", currentBranch: "b" })).toBeNull(); + expect(branchMismatchKey("thread-1", null)).toBeNull(); + }); +}); + +describe("shouldShowBranchMismatchBanner", () => { + const base = { + hasMismatch: true, + isDismissed: false, + composerHasContent: false, + wasShownForCurrentMismatch: false, + }; + + it("stays hidden during passive browsing (even though the composer autofocuses)", () => { + expect(shouldShowBranchMismatchBanner(base)).toBe(false); + }); + + it("shows once the composer has draft content", () => { + expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true); + }); + + it("stays mounted after the draft clears once shown for the current mismatch", () => { + expect(shouldShowBranchMismatchBanner({ ...base, wasShownForCurrentMismatch: true })).toBe( + true, + ); + }); + + it("never shows when dismissed or without a mismatch", () => { + expect( + shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, isDismissed: true }), + ).toBe(false); + expect( + shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, hasMismatch: false }), + ).toBe(false); + }); +}); + +describe("session branch mismatch dismissal", () => { + it("tracks dismissed keys and treats other keys as active", () => { + expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false); + dismissBranchMismatchForSession("t1:a:b"); + expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(true); + expect(isBranchMismatchDismissedForSession("t1:a:c")).toBe(false); + expect(isBranchMismatchDismissedForSession(null)).toBe(false); + }); +}); + describe("reconcileMountedTerminalThreadIds", () => { it("keeps open threads and makes the active thread most recent", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 591f56ea4c7..466c9b24c87 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -289,6 +289,46 @@ export function buildExpiredTerminalContextToastCopy( }; } +export function branchMismatchKey( + threadId: string | null, + mismatch: { threadBranch: string; currentBranch: string } | null, +): string | null { + if (!threadId || !mismatch) { + return null; + } + return `${threadId}:${mismatch.threadBranch}:${mismatch.currentBranch}`; +} + +// The mismatch banner only matters when the user is about to send: passive +// reading of an old thread carries no risk (the branch picker tint already +// covers ambient awareness). Draft content is the intent signal — composer +// focus is useless here because ChatView autofocuses the composer on every +// thread open. `wasShownForCurrentMismatch` keeps the banner mounted once +// revealed so it doesn't flicker away when the draft is cleared. +export function shouldShowBranchMismatchBanner(input: { + hasMismatch: boolean; + isDismissed: boolean; + composerHasContent: boolean; + wasShownForCurrentMismatch: boolean; +}): boolean { + if (!input.hasMismatch || input.isDismissed) { + return false; + } + return input.composerHasContent || input.wasShownForCurrentMismatch; +} + +// Session-scoped (module-level so it survives ChatView remounts, e.g. route +// changes). Durable cross-device dismissal is planned as a server-side ack. +const sessionDismissedBranchMismatchKeys = new Set(); + +export function dismissBranchMismatchForSession(key: string): void { + sessionDismissedBranchMismatchKeys.add(key); +} + +export function isBranchMismatchDismissedForSession(key: string | null): boolean { + return key !== null && sessionDismissedBranchMismatchKeys.has(key); +} + export function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean( thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 671098c4345..ef281d91e78 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -25,6 +25,7 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; +import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -138,7 +139,14 @@ import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { + AlarmClockIcon, + CheckCircle2Icon, + ChevronDownIcon, + GitBranchIcon, + TriangleAlertIcon, + WifiOffIcon, +} from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -153,7 +161,8 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { useEnvironmentSettings } from "../hooks/useSettings"; +import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; +import { useNowMinute } from "../hooks/useNowMinute"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; @@ -202,6 +211,7 @@ import { useThread, useThreadProposedPlans, useThreadRefs, + useThreadShell, } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; @@ -220,6 +230,7 @@ import { shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import { resolveThreadPr } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, @@ -231,13 +242,17 @@ import { } from "./chat/draftHeroTransition"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, + dismissBranchMismatchForSession, hasServerAcknowledgedLocalDispatch, + isBranchMismatchDismissedForSession, + shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -260,6 +275,16 @@ import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { Button } from "./ui/button"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ServerUpdateAction } from "./ServerUpdateAction"; import { buildVersionMismatchDismissalKey, @@ -2352,6 +2377,7 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + const showComposerContextStrip = isGitRepo && activeProject !== null; const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; @@ -3913,52 +3939,159 @@ function ChatViewContent(props: ChatViewProps) { : null, [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], ); - const [branchRepairAction, setBranchRepairAction] = useState< - "update-thread" | "switch-checkout" | null - >(null); - const handleUpdateThreadToCheckout = useCallback(async () => { - if (!activeThread || !localCheckoutBranchMismatch || branchRepairAction !== null) { - return; - } - setBranchRepairAction("update-thread"); - const updateResult = await updateThreadMetadata({ - environmentId, - input: { - threadId: activeThread.id, - branch: localCheckoutBranchMismatch.currentBranch, - worktreePath: null, - }, + // Settled state of the open thread, resolved exactly like the sidebar + // partition (same shell, same capability gate, same PR auto-settle input) + // so the banner and the sidebar row never disagree. + const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); + const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); + const activeThreadPr = resolveThreadPr({ + threadBranch: activeThread?.branch ?? null, + gitStatus: gitStatusQuery.data ?? null, + hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, + }); + const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; + const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; + const nowMinute = useNowMinute(); + const activeThreadSnoozed = + activeThreadShell !== null && + supportsSnooze && + effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() }); + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); + useEffect(() => { + void snoozeWakeTick; + if (!activeThreadSnoozed) return; + const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); + if (!Number.isFinite(wakeAtMs)) return; + const id = window.setTimeout( + () => bumpSnoozeWakeTick((tick) => tick + 1), + Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647), + ); + return () => window.clearTimeout(id); + }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]); + const activeThreadSettled = useMemo(() => { + if (activeThreadShell === null || !supportsSettlement) return false; + return effectiveSettled(activeThreadShell, { + now: `${nowMinute}:00.000Z`, + autoSettleAfterDays, + changeRequestState: activeThreadPr?.state ?? null, }); - setBranchRepairAction(null); - if (updateResult._tag === "Failure" && !isAtomCommandInterrupted(updateResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to update thread branch", - description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)), - }), - ); - return; - } - scheduleComposerFocus(); }, [ - activeThread, - branchRepairAction, - environmentId, - localCheckoutBranchMismatch, - scheduleComposerFocus, - updateThreadMetadata, + activeThreadPr?.state, + activeThreadShell, + autoSettleAfterDays, + nowMinute, + supportsSettlement, ]); + const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { + reportFailure: false, + }); + // Keyed by thread, not a boolean: the pending state must follow the thread + // it belongs to across navigation, and a request resolving for thread A + // must never clear (or re-enable) thread B's button. + const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null); + const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; + const handleUnsettleActiveThread = useCallback(async () => { + if (!activeThreadRef) return; + const threadKey = scopedThreadKey(activeThreadRef); + setUnsettlingThreadKey(threadKey); + try { + const result = await unsettleThreadMutation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, reason: "user" }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to un-settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } finally { + setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); + } + }, [activeThreadRef, unsettleThreadMutation]); + const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { + reportFailure: false, + }); + const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); + const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; + const handleUnsnoozeActiveThread = useCallback(async () => { + if (!activeThreadRef) return; + const threadKey = scopedThreadKey(activeThreadRef); + setUnsnoozingThreadKey(threadKey); + try { + const result = await unsnoozeThreadMutation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, reason: "user" }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to wake thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } finally { + setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); + } + }, [activeThreadRef, unsnoozeThreadMutation]); + const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); + const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); + // Once revealed for a given mismatch, the banner stays mounted until the + // mismatch changes or resolves, so clearing the draft doesn't flicker it. + const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState(null); + // Dismissal lives in a module-level set (survives remounts); this tick just + // forces a re-render so the banner leaves immediately. + const [, setBranchMismatchDismissTick] = useState(0); + const composerHasDraftContent = useComposerDraftStore((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + return Boolean( + draft && + (draft.prompt.trim().length > 0 || + draft.images.length > 0 || + draft.terminalContexts.length > 0 || + draft.elementContexts.length > 0 || + draft.previewAnnotations.length > 0 || + draft.reviewComments.length > 0), + ); + }); + const activeBranchMismatchKey = branchMismatchKey( + activeThread?.id ?? null, + localCheckoutBranchMismatch, + ); + const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ + hasMismatch: localCheckoutBranchMismatch !== null, + isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), + composerHasContent: composerHasDraftContent, + wasShownForCurrentMismatch: + revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, + }); + useEffect(() => { + setRevealedBranchMismatchKey((revealed) => { + if (showBranchMismatchBanner) { + return activeBranchMismatchKey; + } + // Hysteresis is scoped to an uninterrupted mismatch: reset when the + // mismatch resolves or changes so a recurrence re-gates on intent. + return revealed !== null && revealed !== activeBranchMismatchKey ? null : revealed; + }); + }, [activeBranchMismatchKey, showBranchMismatchBanner]); const handleSwitchCheckoutToThread = useCallback(async () => { if ( !activeProjectCwd || !activeThread || !localCheckoutBranchMismatch || - branchRepairAction !== null + isRestoringThreadBranch ) { return; } - setBranchRepairAction("switch-checkout"); + setIsRestoringThreadBranch(true); const checkoutResult = await switchGitRef({ environmentId, input: { @@ -3967,7 +4100,7 @@ function ChatViewContent(props: ChatViewProps) { }, }); if (checkoutResult._tag === "Failure") { - setBranchRepairAction(null); + setIsRestoringThreadBranch(false); if (!isAtomCommandInterrupted(checkoutResult)) { toastManager.add( stackedThreadToast({ @@ -3987,7 +4120,7 @@ function ChatViewContent(props: ChatViewProps) { input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, }); if (updateResult._tag === "Failure") { - setBranchRepairAction(null); + setIsRestoringThreadBranch(false); if (!isAtomCommandInterrupted(updateResult)) { toastManager.add( stackedThreadToast({ @@ -4002,76 +4135,125 @@ function ChatViewContent(props: ChatViewProps) { } } gitStatusQuery.refresh(); - setBranchRepairAction(null); + setIsRestoringThreadBranch(false); scheduleComposerFocus(); }, [ activeProjectCwd, activeThread, - branchRepairAction, environmentId, gitStatusQuery, + isRestoringThreadBranch, localCheckoutBranchMismatch, scheduleComposerFocus, switchGitRef, updateThreadMetadata, ]); + // The stack renders items[0] front-most and tucks the rest behind hover, so + // ordering is priority: system banners, then the branch-mismatch notice, + // and the informational parked-thread banner last — it must never cover another. + const parkedThreadBannerItem = useMemo(() => { + if (!activeThreadSnoozed && !activeThreadSettled) { + return null; + } + const isSnoozed = activeThreadSnoozed; + return { + id: `thread-${isSnoozed ? "snoozed" : "settled"}:${activeThread?.id ?? "unknown"}`, + variant: "info", + icon: isSnoozed ? : , + title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`, + description: isSnoozed + ? "Sending a message wakes it and moves it back to Active in the sidebar." + : "Sending a message moves it back to Active in the sidebar.", + actions: ( + + ), + }; + }, [ + activeThread?.id, + activeThreadSettled, + activeThreadSnoozed, + handleUnsnoozeActiveThread, + handleUnsettleActiveThread, + isUnsnoozing, + isUnsettling, + ]); + const handleRestoreThreadBranch = useCallback(() => { + if (gitStatusQuery.data?.hasWorkingTreeChanges) { + setBranchRestoreConfirmOpen(true); + return; + } + void handleSwitchCheckoutToThread(); + }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { - if (!localCheckoutBranchMismatch) { - return systemComposerBannerItems; + const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; + if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { + return [...systemComposerBannerItems, ...parkedThreadItems]; } - const isRepairingBranch = branchRepairAction !== null; return [ ...systemComposerBannerItems, { - id: `branch-mismatch:${activeThread?.id ?? "unknown"}:${localCheckoutBranchMismatch.threadBranch}:${localCheckoutBranchMismatch.currentBranch}`, - variant: "warning", - icon: , - title: "You're on a different branch", - className: - "text-base sm:text-sm [&>div]:items-start max-sm:[&>div]:flex-wrap max-sm:[&>div>div:last-child]:w-full max-sm:[&>div>div:last-child]:self-start dark:shadow-none", - actionClassName: - "max-sm:w-full max-sm:border-t max-sm:border-border/60 max-sm:pt-2 max-sm:pl-6 sm:border-l sm:border-border/60 sm:pl-3", - description: ( -

- This thread is on{" "} - - {localCheckoutBranchMismatch.threadBranch} - - , but you're currently checked out at{" "} - - {localCheckoutBranchMismatch.currentBranch} - - . Sending a message will update the thread. -

+ id: `branch-mismatch:${activeBranchMismatchKey}`, + variant: "info", + icon: , + title: ( + + Branch changed — was + + + {localCheckoutBranchMismatch.threadBranch} + + } + /> + + This thread last ran on {localCheckoutBranchMismatch.threadBranch}. Sending will + continue on {localCheckoutBranchMismatch.currentBranch}. + + + ), + className: "dark:shadow-none", actions: ( - <> - - - + ), + dismissLabel: "Dismiss branch change notice", + onDismiss: () => { + dismissBranchMismatchForSession(activeBranchMismatchKey); + setBranchMismatchDismissTick((tick) => tick + 1); + }, }, + ...parkedThreadItems, ]; }, [ - activeThread?.id, - branchRepairAction, - handleSwitchCheckoutToThread, - handleUpdateThreadToCheckout, + activeBranchMismatchKey, + handleRestoreThreadBranch, + isRestoringThreadBranch, localCheckoutBranchMismatch, + parkedThreadBannerItem, + showBranchMismatchBanner, systemComposerBannerItems, ]); @@ -5701,119 +5883,128 @@ function ChatViewContent(props: ChatViewProps) { : undefined } > -
-
- +
+
+
+ +
-
-
-
- {isGitRepo && ( -
- -
- )} +
+
+ {showComposerContextStrip && ( +
+ +
+ )} +
+ + + + + Switch to{" "} + + {localCheckoutBranchMismatch?.threadBranch ?? ""} + + ? + + + You have uncommitted changes. They'll carry over to the other branch, or block + the switch if they conflict. + + + + }>Cancel + + + + + {pullRequestDialogState ? ( codeViewFiles.map((file) => file.fileKey), [codeViewFiles]); + const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); useEffect(() => { if (!selectedFilePath) return; @@ -496,6 +502,18 @@ export default function DiffPanel({ [collapseScopeKey], ); + const toggleDiffFileCollapse = useCallback(() => { + setCollapsedDiffFiles((current) => { + const currentKeys = + current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS; + + return { + scopeKey: collapseScopeKey, + fileKeys: toggleAllDiffFiles(diffFileKeys, currentKeys), + }; + }); + }, [collapseScopeKey, diffFileKeys]); + const selectTurn = (turnId: TurnId) => { if (!routeThreadRef) return; useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId); @@ -695,6 +713,30 @@ export default function DiffPanel({ )}
+ {codeViewFiles.length > 0 && ( + + + } + > + {allDiffFilesCollapsed ? ( + + ) : ( + + )} + + + {allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"} + + + )} { }); }); -describe("resolveSidebarNewThreadEnvMode", () => { - it("uses the app default when the caller does not request a specific mode", () => { - expect( - resolveSidebarNewThreadEnvMode({ - defaultEnvMode: "worktree", - }), - ).toBe("worktree"); - }); - - it("preserves an explicit requested mode over the app default", () => { - expect( - resolveSidebarNewThreadEnvMode({ - requestedEnvMode: "local", - defaultEnvMode: "worktree", - }), - ).toBe("local"); - }); -}); - -describe("resolveSidebarNewThreadSeedContext", () => { - it("prefers the default worktree mode over active thread context", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-1", - defaultEnvMode: "worktree", - activeThread: { - projectId: "project-1", - branch: "feature/existing", - worktreePath: "/repo/.t3/worktrees/existing", - }, - activeDraftThread: { - projectId: "project-1", - branch: "feature/draft", - worktreePath: "/repo/.t3/worktrees/draft", - envMode: "worktree", - startFromOrigin: true, - }, - }), - ).toEqual({ - envMode: "worktree", - }); - }); - - it("inherits the active server thread context when creating a new thread in the same project", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-1", - defaultEnvMode: "local", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: null, - }), - ).toEqual({ - branch: "effect-atom", - worktreePath: null, - envMode: "local", - }); - }); - - it("prefers the active draft thread context when it matches the target project", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-1", - defaultEnvMode: "local", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: { - projectId: "project-1", - branch: "feature/new-draft", - worktreePath: "/repo/worktree", - envMode: "worktree", - startFromOrigin: true, - }, - }), - ).toEqual({ - branch: "feature/new-draft", - worktreePath: "/repo/worktree", - envMode: "worktree", - startFromOrigin: true, - }); - }); - - it("falls back to the default env mode when there is no matching active thread context", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-2", - defaultEnvMode: "worktree", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: null, - }), - ).toEqual({ - envMode: "worktree", - }); - }); -}); - describe("orderItemsByPreferredIds", () => { it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { const ordered = orderItemsByPreferredIds({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e7082ca8ada..7aee3100d0e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -18,7 +18,6 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; -export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; title: string; @@ -246,62 +245,6 @@ export function isTrailingDoubleClick(detail: number): boolean { return detail > 1; } -export function resolveSidebarNewThreadEnvMode(input: { - requestedEnvMode?: SidebarNewThreadEnvMode; - defaultEnvMode: SidebarNewThreadEnvMode; -}): SidebarNewThreadEnvMode { - return input.requestedEnvMode ?? input.defaultEnvMode; -} - -export function resolveSidebarNewThreadSeedContext(input: { - projectId: string; - defaultEnvMode: SidebarNewThreadEnvMode; - activeThread?: { - projectId: string; - branch: string | null; - worktreePath: string | null; - } | null; - activeDraftThread?: { - projectId: string; - branch: string | null; - worktreePath: string | null; - envMode: SidebarNewThreadEnvMode; - startFromOrigin: boolean; - } | null; -}): { - branch?: string | null; - worktreePath?: string | null; - envMode: SidebarNewThreadEnvMode; - startFromOrigin?: boolean; -} { - if (input.defaultEnvMode === "worktree") { - return { - envMode: "worktree", - }; - } - - if (input.activeDraftThread?.projectId === input.projectId) { - return { - branch: input.activeDraftThread.branch, - worktreePath: input.activeDraftThread.worktreePath, - envMode: input.activeDraftThread.envMode, - startFromOrigin: input.activeDraftThread.startFromOrigin, - }; - } - - if (input.activeThread?.projectId === input.projectId) { - return { - branch: input.activeThread.branch, - worktreePath: input.activeThread.worktreePath, - envMode: input.activeThread.worktreePath ? "worktree" : "local", - }; - } - - return { - envMode: input.defaultEnvMode, - }; -} - export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts new file mode 100644 index 00000000000..4a4518a2591 --- /dev/null +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel } from "./Sidebar.snooze"; + +// Local-time constructor so preset math is timezone-stable in tests. +function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date { + return new Date(year, month - 1, day, hour, minute, 0, 0); +} + +describe("resolveSnoozePresets", () => { + it("offers hour, evening, tomorrow, next week in the morning", () => { + // Wednesday 2026-04-08 10:00 local. + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10)); + expect(presets.map((preset) => preset.id)).toEqual([ + "hour", + "evening", + "tomorrow", + "next-week", + ]); + const evening = presets.find((preset) => preset.id === "evening"); + expect(new Date(evening!.snoozedUntil).getHours()).toBe(18); + const tomorrow = presets.find((preset) => preset.id === "tomorrow"); + const tomorrowDate = new Date(tomorrow!.snoozedUntil); + expect(tomorrowDate.getDate()).toBe(9); + expect(tomorrowDate.getHours()).toBe(9); + const nextWeek = presets.find((preset) => preset.id === "next-week"); + const nextWeekDate = new Date(nextWeek!.snoozedUntil); + expect(nextWeekDate.getDay()).toBe(1); + expect(nextWeekDate.getDate()).toBe(13); + }); + + it("whenLabel complements the label instead of repeating it", () => { + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10)); + for (const preset of presets) { + // Day words live in the label column; the time column is time-only + // (plus a weekday for next week, which names a different day). + expect(preset.whenLabel.toLowerCase()).not.toContain("tomorrow"); + } + const tomorrow = presets.find((preset) => preset.id === "tomorrow"); + expect(tomorrow!.whenLabel).toMatch(/9/); + const nextWeek = presets.find((preset) => preset.id === "next-week"); + expect(nextWeek!.whenLabel).toMatch(/Mon/); + }); + + it("drops the evening preset once evening is near or past", () => { + expect(resolveSnoozePresets(localDate(2026, 4, 8, 17, 30)).map((preset) => preset.id)).toEqual([ + "hour", + "tomorrow", + "next-week", + ]); + expect(resolveSnoozePresets(localDate(2026, 4, 8, 21)).map((preset) => preset.id)).toEqual([ + "hour", + "tomorrow", + "next-week", + ]); + }); + + it("puts next week a full week out when today is Monday", () => { + // Monday 2026-04-06. + const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10)); + const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil); + expect(nextWeek.getDay()).toBe(1); + expect(nextWeek.getDate()).toBe(13); + }); +}); + +describe("snoozeWakeLabel", () => { + const now = localDate(2026, 4, 8, 10); + + it("formats minutes, hours, and days, rounding up", () => { + expect(snoozeWakeLabel(new Date(now.getTime() + 30 * 60_000).toISOString(), now)).toBe("30m"); + expect(snoozeWakeLabel(new Date(now.getTime() + 90 * 60_000).toISOString(), now)).toBe("2h"); + expect(snoozeWakeLabel(new Date(now.getTime() + 26 * 3_600_000).toISOString(), now)).toBe("2d"); + }); + + it("reports now for past and malformed wake times", () => { + expect(snoozeWakeLabel(new Date(now.getTime() - 1000).toISOString(), now)).toBe("now"); + expect(snoozeWakeLabel("not-a-date", now)).toBe("now"); + }); +}); + +describe("snoozeWakeDescription", () => { + const now = localDate(2026, 4, 8, 10); + + it("uses bare time today, 'tomorrow' next day, weekday within the week", () => { + expect(snoozeWakeDescription(localDate(2026, 4, 8, 18).toISOString(), now)).not.toContain( + "tomorrow", + ); + expect(snoozeWakeDescription(localDate(2026, 4, 9, 9).toISOString(), now)).toContain( + "tomorrow", + ); + expect(snoozeWakeDescription(localDate(2026, 4, 13, 9).toISOString(), now)).toMatch(/Mon/); + }); +}); diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts new file mode 100644 index 00000000000..dd56cec7290 --- /dev/null +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -0,0 +1,127 @@ +/** + * Snooze preset resolution for the sidebar snooze menu. Pure functions so + * the preset math (evening/tomorrow/next-week boundaries) is unit-testable + * without a DOM. + * + * Presets deliberately skew short: agent-thread rhythms are hours (a CI + * run, a teammate review, the next work session), not days. + */ +import { parseTimestampDate } from "../timestampFormat"; + +type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week"; + +export interface SnoozePreset { + readonly id: SnoozePresetId; + readonly label: string; + /** Menu-row time column. Complements the label instead of repeating it: + "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ + readonly whenLabel: string; + /** ISO wake time. */ + readonly snoozedUntil: string; +} + +function timeOfDayLabel(date: Date): string { + return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); +} + +const EVENING_HOUR = 18; +const MORNING_HOUR = 9; +const HOUR_MS = 60 * 60 * 1_000; +const DAY_MS = 24 * HOUR_MS; + +function atHour(base: Date, hour: number): Date { + const next = new Date(base); + next.setHours(hour, 0, 0, 0); + return next; +} + +// Calendar-day advance instead of adding DAY_MS: fixed millisecond offsets +// land on the wrong local day across DST transitions (a spring-forward day +// is 23 hours, so 23:30 + 24h skips the whole next day). +function addDays(base: Date, days: number): Date { + const next = new Date(base); + next.setDate(next.getDate() + days); + return next; +} + +/** + * Presets for "snooze until", computed against local time. "This evening" + * only appears while it is still meaningfully before evening; after that + * the list starts at "Tomorrow". + */ +export function resolveSnoozePresets(now: Date): ReadonlyArray { + const inAnHour = new Date(now.getTime() + HOUR_MS); + const presets: SnoozePreset[] = [ + { + id: "hour", + label: "In 1 hour", + whenLabel: timeOfDayLabel(inAnHour), + snoozedUntil: inAnHour.toISOString(), + }, + ]; + + const evening = atHour(now, EVENING_HOUR); + // Suppress the evening preset once it is within an hour (or past): it + // would duplicate "In 1 hour" or point at the past. + if (evening.getTime() - now.getTime() > HOUR_MS) { + presets.push({ + id: "evening", + label: "This evening", + whenLabel: timeOfDayLabel(evening), + snoozedUntil: evening.toISOString(), + }); + } + + const tomorrow = atHour(addDays(now, 1), MORNING_HOUR); + presets.push({ + id: "tomorrow", + label: "Tomorrow", + whenLabel: timeOfDayLabel(tomorrow), + snoozedUntil: tomorrow.toISOString(), + }); + + // Next Monday 9:00 (a week out when today is Monday). + const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; + const nextWeek = atHour(addDays(now, daysUntilMonday), MORNING_HOUR); + presets.push({ + id: "next-week", + label: "Next week", + whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${timeOfDayLabel(nextWeek)}`, + snoozedUntil: nextWeek.toISOString(), + }); + + return presets; +} + +/** + * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes + * round up so a snooze never reads "0m" while still hidden. + */ +export function snoozeWakeLabel(snoozedUntil: string, now: Date): string { + const wake = parseTimestampDate(snoozedUntil); + if (wake === null) return "now"; + const remainingMs = wake.getTime() - now.getTime(); + if (remainingMs <= 0) return "now"; + if (remainingMs < HOUR_MS) return `${Math.max(1, Math.ceil(remainingMs / 60_000))}m`; + if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`; + return `${Math.ceil(remainingMs / DAY_MS)}d`; +} + +/** + * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00", + * "17:30" (today). + */ +export function snoozeWakeDescription(snoozedUntil: string, now: Date): string { + const wake = parseTimestampDate(snoozedUntil); + if (wake === null) return ""; + const time = wake.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + const startOfToday = new Date(now); + startOfToday.setHours(0, 0, 0, 0); + const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS); + if (dayDelta === 0) return time; + if (dayDelta === 1) return `tomorrow ${time}`; + const weekday = wake.toLocaleDateString(undefined, { weekday: "short" }); + if (dayDelta < 7) return `${weekday} ${time}`; + const date = wake.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return `${date}, ${time}`; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 014e629fda7..a1d95eaa734 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -177,8 +177,6 @@ import { isContextMenuPointerDown, isTrailingDoubleClick, resolveProjectStatusIndicator, - resolveSidebarNewThreadSeedContext, - resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -193,7 +191,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerKeybindingsAtom, primaryServerSettingsAtom } from "../state/server"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -1103,7 +1101,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec (settings) => settings.confirmThreadArchive, ); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); @@ -1875,61 +1872,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const createThreadForProjectMember = useCallback( (member: SidebarProjectGroupMember) => { - const currentRouteParams = - router.state.matches[router.state.matches.length - 1]?.params ?? {}; - const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); - const currentActiveThread = - currentRouteTarget?.kind === "server" - ? readThreadShell(currentRouteTarget.threadRef) - : null; - const draftStore = useComposerDraftStore.getState(); - const currentActiveDraftThread = - currentRouteTarget?.kind === "server" - ? (draftStore.getDraftThread(currentRouteTarget.threadRef) ?? null) - : currentRouteTarget?.kind === "draft" - ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null) - : null; - const seedContext = resolveSidebarNewThreadSeedContext({ - projectId: member.id, - // The default env mode is a user preference stored on the primary - // environment's settings.json; remote environments never carry it. - defaultEnvMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: primaryServerSettings.defaultThreadEnvMode, - }), - activeThread: - currentActiveThread && currentActiveThread.projectId === member.id - ? { - projectId: currentActiveThread.projectId, - branch: currentActiveThread.branch, - worktreePath: currentActiveThread.worktreePath, - } - : null, - activeDraftThread: - currentActiveDraftThread && currentActiveDraftThread.projectId === member.id - ? { - projectId: currentActiveDraftThread.projectId, - branch: currentActiveDraftThread.branch, - worktreePath: currentActiveDraftThread.worktreePath, - envMode: currentActiveDraftThread.envMode, - startFromOrigin: currentActiveDraftThread.startFromOrigin, - } - : null, - }); if (isMobile) { setOpenMobile(false); } void (async () => { + // No options: branch, worktree, and env mode come from the user's + // configured defaults, never from the currently viewed thread. const result = await settlePromise(() => - handleNewThread(scopeProjectRef(member.environmentId, member.id), { - ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), - ...(seedContext.worktreePath !== undefined - ? { worktreePath: seedContext.worktreePath } - : {}), - envMode: seedContext.envMode, - ...(seedContext.startFromOrigin !== undefined - ? { startFromOrigin: seedContext.startFromOrigin } - : {}), - }), + handleNewThread(scopeProjectRef(member.environmentId, member.id)), ); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); @@ -1943,7 +1893,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } })(); }, - [handleNewThread, isMobile, primaryServerSettings.defaultThreadEnvMode, router, setOpenMobile], + [handleNewThread, isMobile, setOpenMobile], ); const handleCreateThreadClick = useCallback( @@ -2164,6 +2114,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; const clicked = await api.contextMenu.show( [ + ...(thread.branch + ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] + : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, @@ -2173,6 +2126,30 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec position, ); + if (clicked === "new-thread-on-branch") { + // Explicit branch carry-over: reuse the thread's worktree when it + // has one, otherwise its branch on the local checkout. + const result = await settlePromise(() => + handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId), { + branch: thread.branch, + worktreePath: thread.worktreePath, + envMode: thread.worktreePath ? "worktree" : "local", + startFromOrigin: false, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + if (clicked === "rename") { startThreadRename(threadKey, thread.title); return; @@ -2229,6 +2206,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyPathToClipboard, copyThreadIdToClipboard, deleteThread, + handleNewThread, markThreadUnread, memberProjectByScopedKey, project.workspaceRoot, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 361c3460bfd..8c5891ebe7e 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,6 +1,11 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import { + canSnooze, + effectiveSettled, + effectiveSnoozed, + threadWokeAt, +} from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopeProjectRef, @@ -9,11 +14,14 @@ import { } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { + AlarmClockIcon, + AlarmClockOffIcon, CheckIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, + ClockIcon, CopyIcon, FolderIcon, FolderPlusIcon, @@ -36,6 +44,7 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, + type ReactNode, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; @@ -78,6 +87,7 @@ import { openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; @@ -87,11 +97,12 @@ import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; -import { formatRelativeTimeLabel } from "../timestampFormat"; +import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { formatWorkingDurationLabel, + firstValidTimestampMs, hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -105,7 +116,17 @@ import { sortThreadsForSidebarV2, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; -import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; +import { + prStatusIndicator, + resolveThreadPr, + settledPrHoverColorClass, +} from "./ThreadStatusIndicators"; +import { + resolveSnoozePresets, + snoozeWakeDescription, + snoozeWakeLabel, + type SnoozePreset, +} from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; @@ -129,6 +150,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./u import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { useComposerDraftStore } from "../composerDraftStore"; @@ -217,7 +239,11 @@ function SidebarV2ThreadTooltip({ side="right" align="start" sideOffset={8} - className="dropdown-glass max-w-80 border-0! bg-[color-mix(in_srgb,var(--background)_var(--glass-opacity),transparent)] text-left whitespace-normal shadow-lg/10 before:hidden dark:shadow-none" + className="dropdown-glass max-w-80 border-0! text-left whitespace-normal shadow-lg/10 before:hidden dark:shadow-none" + style={{ + background: + "color-mix(in srgb, var(--popover) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))", + }} >
{thread.title}
@@ -274,15 +300,74 @@ function SidebarV2ThreadTooltip({ ); } +/** + * Hover entry point for snooze: a clock button opening the preset menu. + * Controlled by the row (which also uses the open state to pin its hover + * actions while the menu is up). + */ +function SnoozePopoverButton(props: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSnooze: (preset: SnoozePreset) => void; +}) { + const { open, onOpenChange, onSnooze } = props; + // Presets resolve at open time so "In 1 hour" is relative to the click, + // not to when the row mounted. + const presets = useMemo(() => (open ? resolveSnoozePresets(new Date()) : []), [open]); + return ( + + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" + /> + } + > + + + + {presets.map((preset) => ( + + ))} + + + ); +} + const SidebarV2Row = memo(function SidebarV2Row(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; // Slim rows are either settled (action: un-settle) or merely quiet // (seen Ready threads — action: settle). - variantAction: "settle" | "unsettle"; + variantAction: "settle" | "unsettle" | "unsnooze"; // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; + // Same contract for thread.snooze/unsnooze. + snoozeSupported: boolean; + // Compact wake countdown ("2h") for rows in the snoozed shelf. + snoozeWakeLabelText: string | null; + // When a snooze ended (timer or early wake); drives the Woke pill until + // the user visits the thread. + wokeAt: string | null; isActive: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; @@ -301,6 +386,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; onSettle: (threadRef: ScopedThreadRef) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; + onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; + onUnsnooze: (threadRef: ScopedThreadRef) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { @@ -311,10 +398,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onContextMenu, onRenameTitleChange, onSettle, + onSnooze, onStartRename, onThreadActivate, onThreadClick, onUnsettle, + onUnsnooze, renamingTitle, thread, variant, @@ -333,15 +422,25 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // flag must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarV2Status(thread); + // A woken thread reappears at its original position (the sort is + // deliberately static), so the pill has to carry the weight. Snoozing is + // an explicit act, so unlike Done, a never-visited woke thread still + // shows the pill; visiting clears it. An unparseable visit timestamp + // counts as never-visited — corrupt local data must not eat the wake + // signal. + const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); + const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); + const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate); // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for - // rows that need a human — done (unread), read-but-unsettled, and failed. - // The status label keeps its hue, so waiting rows stay findable. In-flight - // rows recede the same as read-ready ones (inbox-zero: working threads - // aren't your problem yet) — only the colored status label stands out. + // rows that need a human — done (unread), read-but-unsettled, failed, and + // freshly woken. The status label keeps its hue, so waiting rows stay + // findable. In-flight rows recede the same as read-ready ones (inbox-zero: + // working threads aren't your problem yet) — only the colored status label + // stands out. const isInFlight = status === "working" || status === "approval" || status === "input"; const shouldRecede = - (status === "ready" || isInFlight) && !isUnread && !props.isActive && !isSelected; + (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; // Status hues follow the system-wide convention set by sidebar v1 and the // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. @@ -371,13 +470,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { icon: null, className: "text-red-700 dark:text-red-300", } - : isUnread + : isWoke ? { - label: "Done", - icon: "done" as const, - className: "text-emerald-700 dark:text-emerald-300", + label: "Woke", + icon: "woke" as const, + className: "text-amber-700 dark:text-amber-300", } - : null; + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "text-emerald-700 dark:text-emerald-300", + } + : null; const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( @@ -400,6 +505,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; // Report the PR state up: the parent partitions rows with effectiveSettled, // and a merged/closed PR auto-settles a thread — data only rows have. const prState = pr?.state ?? null; @@ -506,6 +612,36 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onUnsettle, threadRef], ); + const handleUnsnoozeClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnsnooze(threadRef); + }, + [onUnsnooze, threadRef], + ); + const handleSnoozePreset = useCallback( + (preset: SnoozePreset) => { + onSnooze(threadRef, preset); + }, + [onSnooze, threadRef], + ); + // While the snooze popover is open the pointer leaves the row, which + // would fade the hover actions out from under the open menu; pin them. + const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); + // Snooze is offered only where it can succeed: capability-gated and never + // on blocked-on-you work or queued turns (the server rejects both). + const showSnoozeButton = + props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() }); + // If the thread becomes blocked while the popover is open, the button + // unmounts without firing onOpenChange(false). Deriving the flag keeps a + // stale true from permanently hiding the status label / pinning the + // hover actions, and the effect clears the raw state so the popover + // doesn't resurrect if the button later remounts. + const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; + useEffect(() => { + if (!showSnoozeButton) setSnoozeMenuOpen(false); + }, [showSnoozeButton]); const handlePrClick = useCallback( (event: ReactMouseEvent) => { if (pr?.url) openPrLink(event, pr.url); @@ -519,7 +655,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // content; surface is reserved for interaction (hover, multi-select, route). const rowSurfaceClassName = cn( "group/v2-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", - variant === "card" && "backdrop-blur-[16px]", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" : isSelected @@ -554,7 +689,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { variant === "card" ? cn( "truncate", - isUnread + isUnread || isWoke ? "text-foreground" : shouldRecede ? "text-muted-foreground/80" @@ -564,7 +699,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : cn( "truncate group-hover/v2-row:text-foreground", - props.isActive + props.isActive || isWoke ? "text-foreground" : isUnread ? "text-muted-foreground" @@ -586,7 +721,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { variant === "slim" && variantAction === "unsettle" ? props.isActive ? "text-muted-foreground/70" - : "text-muted-foreground/35 transition-colors group-hover/v2-row:text-muted-foreground/65" + : cn("text-muted-foreground/35 transition-colors", settledPrHoverClass) : prStatus.colorClass, )} aria-label={prStatus.tooltip} @@ -639,13 +774,43 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {prBadge} - - {variantAction === "unsettle" - ? settledTimeLabel(thread) - : threadTimeLabel(thread)} - + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + // Snoozed rows show when they come BACK, not when they were + // last touched — the return ticket is the row's whole story. + + {props.snoozeWakeLabelText} + + ) : isWoke ? ( + // A wake can land straight in the settled tail (e.g. PR + // merged while snoozed); the signal must survive the trip. + + + Woke + + ) : ( + + {variantAction === "unsettle" + ? settledTimeLabel(thread) + : threadTimeLabel(thread)} + + )} - {!props.settlementSupported ? null : variantAction === "unsettle" ? ( + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( + + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + {showSnoozeButton ? ( + + ) : null} + {props.settlementSupported ? ( + + ) : null} + ) : null}
@@ -818,7 +1006,8 @@ export default function SidebarV2() { const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const { settleThread, unsettleThread, deleteThread } = useThreadActions(); + const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } = + useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -952,14 +1141,13 @@ export default function SidebarV2() { // now is quantized to the minute so effectiveSettled memoization doesn't // churn on every render; auto-settle thresholds are day-granular anyway. - const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); - useEffect(() => { - const id = window.setInterval( - () => setNowMinute(new Date().toISOString().slice(0, 16)), - 60_000, - ); - return () => window.clearInterval(id); - }, []); + const nowMinute = useNowMinute(); + // Snooze wake times are second-precise, so classifying with the quantized + // minute would hold a woken thread on the shelf for up to a minute. The + // tick is a plain counter bumped exactly at the next wake boundary (armed + // below, after the partition knows the boundary); the partition reads a + // fresh clock whenever it recomputes. + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); // PR states stream in per-row (rows own the VCS subscriptions); a merged or // closed PR auto-settles its thread on the next partition. @@ -1171,8 +1359,14 @@ export default function SidebarV2() { // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. const serverConfigs = useAtomValue(environmentServerConfigsAtom); - const { activeThreads, settledThreads } = useMemo(() => { + const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { const now = `${nowMinute}:00.000Z`; + // Snooze classification uses a REAL clock, not the quantized minute: + // wake times are second-precise and a woken thread must not linger on + // the shelf for the rest of the minute. snoozeWakeTick re-runs this + // memo exactly at the next wake boundary. + void snoozeWakeTick; + const preciseNow = new Date().toISOString(); const visible = threads.filter( (thread) => thread.archivedAt === null && @@ -1180,6 +1374,7 @@ export default function SidebarV2() { scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); const active: EnvironmentThreadShell[] = []; + const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; for (const thread of visible) { // Threads on servers without the settlement capability (old server, @@ -1188,9 +1383,16 @@ export default function SidebarV2() { // strand rows in a tail with no working affordances. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; + const supportsSnooze = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; - if ( + // Snooze outranks settled classification: an explicitly snoozed thread + // belongs to the shelf even if it would also auto-settle (the shelf's + // wake time is a stronger statement about when it matters again). + if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + snoozed.push(thread); + } else if ( supportsSettlement && effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) ) { @@ -1201,7 +1403,14 @@ export default function SidebarV2() { } return { activeThreads: sortThreadsForSidebarV2(active), + // Soonest wake first: "what comes back next" is the shelf's question. + snoozedThreads: snoozed.toSorted( + (left, right) => + firstValidTimestampMs(left.snoozedUntil ?? null) - + firstValidTimestampMs(right.snoozedUntil ?? null), + ), settledThreads: sortSettledThreadsForSidebarV2(settled), + snoozeNow: preciseNow, }; }, [ autoSettleAfterDays, @@ -1209,9 +1418,28 @@ export default function SidebarV2() { nowMinute, scopedProjectKeys, serverConfigs, + snoozeWakeTick, threads, ]); + // Arm a timeout for the earliest upcoming wake so the shelf empties the + // moment a snooze expires instead of on the next minute tick. Sorted + // soonest-first, so entry 0 is the boundary. + useEffect(() => { + const nextWakeAtMs = + snoozedThreads.length > 0 && snoozedThreads[0]?.snoozedUntil != null + ? Date.parse(snoozedThreads[0].snoozedUntil) + : Number.NaN; + if (Number.isNaN(nextWakeAtMs)) return; + // setTimeout delays are signed 32-bit: anything larger overflows and + // fires immediately, turning a far-future wake (event-condition snoozes + // synced from elsewhere) into a tight re-arm loop. Clamped, the timer + // just re-arms every ~24.8 days until the wake is in range. + const delayMs = Math.min(Math.max(0, nextWakeAtMs - Date.now()) + 50, 2_147_483_647); + const id = window.setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); + return () => window.clearTimeout(id); + }, [snoozedThreads]); + // The settled tail renders in pages: history shouldn't dominate the // sidebar, and the common lookups are recent. Expansion resets when the // filter context changes so a scope/search flip never inherits a deep @@ -1223,19 +1451,62 @@ export default function SidebarV2() { lastSettledResetKeyRef.current = settledResetKey; setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); } - const hiddenSettledCount = Math.max(0, settledThreads.length - settledVisibleCount); - const visibleSettledThreads = useMemo( - () => (hiddenSettledCount > 0 ? settledThreads.slice(0, settledVisibleCount) : settledThreads), - [hiddenSettledCount, settledThreads, settledVisibleCount], - ); + const visibleSettledThreads = useMemo(() => { + if (settledThreads.length <= settledVisibleCount) return settledThreads; + const visible = settledThreads.slice(0, settledVisibleCount); + // The open thread must never hide under "Show more": navigating into a + // deep settled thread (search, deep link) pulls its row into the visible + // tail so the highlight and the un-settle affordance stay reachable. + if (routeThreadKey !== null) { + const routeThread = settledThreads + .slice(settledVisibleCount) + .find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + if (routeThread !== undefined) visible.push(routeThread); + } + return visible; + }, [routeThreadKey, settledThreads, settledVisibleCount]); + const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length; const showMoreSettled = useCallback( () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), [], ); + const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); + const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); + const renderedSettledThreads = useMemo(() => { + if (settledShelfExpanded) return visibleSettledThreads; + if (routeThreadKey === null) return []; + const routeThread = visibleSettledThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + return routeThread === undefined ? [] : [routeThread]; + }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + + // The snoozed shelf is collapsed by default: out of the way, never gone. + // Collapsed threads don't render (and so don't participate in jump + // shortcuts or multi-select), matching the settled tail's paging model. + const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); + const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); + const visibleSnoozedThreads = useMemo(() => { + if (snoozedShelfExpanded) return snoozedThreads; + // The open thread must never vanish behind the collapsed shelf: a + // snoozed thread reached by route (deep link, open before snoozing + // elsewhere) keeps its row — with highlight and wake affordance — same + // exception the settled tail's "Show more" makes. + if (routeThreadKey === null) return []; + const routeThread = snoozedThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + return routeThread === undefined ? [] : [routeThread]; + }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); const orderedThreads = useMemo( - () => [...activeThreads, ...visibleSettledThreads], - [activeThreads, visibleSettledThreads], + () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], + [activeThreads, visibleSnoozedThreads, renderedSettledThreads], ); const orderedThreadKeys = useMemo( () => @@ -1280,6 +1551,17 @@ export default function SidebarV2() { ); const settledThreadKeysRef = useRef(settledThreadKeys); settledThreadKeysRef.current = settledThreadKeys; + const snoozedThreadKeys = useMemo( + () => + new Set( + snoozedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + [snoozedThreads], + ); + const snoozedThreadKeysRef = useRef(snoozedThreadKeys); + snoozedThreadKeysRef.current = snoozedThreadKeys; const jumpLabelByKey = useMemo(() => { const mapping = new Map(); @@ -1375,6 +1657,36 @@ export default function SidebarV2() { // A settle per thread at a time: double clicks and repeated menu picks // must not dispatch a second settle that fails and toasts a false error. const settlingThreadKeysRef = useRef(new Set()); + // Parking the thread you're looking at (settle or snooze) moves you + // forward: the next remaining card (never a settled or snoozed row, never + // one leaving in the same batch), or a fresh draft in this project when it + // was the last active one. Callers snapshot the plan BEFORE the command + // mutates the partition; background parks never navigate (null plan). + const planForwardNavigation = useCallback( + (threadKey: string, coParkingKeys?: ReadonlySet): (() => void) | null => { + if (routeThreadKeyRef.current !== threadKey) return null; + const shell = threadByKeyRef.current.get(threadKey); + const orderedKeys = orderedThreadKeysRef.current; + const settledKeys = settledThreadKeysRef.current; + const snoozedKeys = snoozedThreadKeysRef.current; + const currentIndex = orderedKeys.indexOf(threadKey); + const nextCardKey = + currentIndex === -1 + ? null + : ([...orderedKeys.slice(currentIndex + 1), ...orderedKeys.slice(0, currentIndex)].find( + (key) => !settledKeys.has(key) && !snoozedKeys.has(key) && !coParkingKeys?.has(key), + ) ?? null); + const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; + return nextThread + ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) + : shell + ? () => + void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId)) + : () => void router.navigate({ to: "/" }); + }, + [navigateToThread, router], + ); + const attemptSettle = useCallback( (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { void (async () => { @@ -1382,34 +1694,7 @@ export default function SidebarV2() { if (settlingThreadKeysRef.current.has(threadKey)) return; settlingThreadKeysRef.current.add(threadKey); try { - // Settling the thread you're looking at moves you forward: the next - // remaining card (never a settled row, never one settling in the - // same batch), or a fresh draft in this project when it was the - // last active one. Snapshot the target before the settle mutates - // the partition. Background settles never navigate. - const shell = threadByKeyRef.current.get(threadKey); - let navigateAfterSettle: (() => void) | null = null; - if (routeThreadKey === threadKey) { - const orderedKeys = orderedThreadKeysRef.current; - const settledKeys = settledThreadKeysRef.current; - const currentIndex = orderedKeys.indexOf(threadKey); - const nextCardKey = - currentIndex === -1 - ? null - : ([ - ...orderedKeys.slice(currentIndex + 1), - ...orderedKeys.slice(0, currentIndex), - ].find((key) => !settledKeys.has(key) && !opts.coSettlingKeys?.has(key)) ?? null); - const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; - navigateAfterSettle = nextThread - ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) - : shell - ? () => - void handleNewThreadRef.current( - scopeProjectRef(shell.environmentId, shell.projectId), - ) - : () => void router.navigate({ to: "/" }); - } + const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); const result = await settleThread(threadRef); if (result._tag === "Failure") { // Never navigate away from a thread that did not settle. @@ -1435,7 +1720,7 @@ export default function SidebarV2() { } })(); }, - [navigateToThread, routeThreadKey, router, settleThread], + [planForwardNavigation, settleThread], ); const attemptUnsettle = useCallback( (threadRef: ScopedThreadRef) => { @@ -1455,6 +1740,80 @@ export default function SidebarV2() { }, [unsettleThread], ); + const attemptUnsnooze = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await unsnoozeThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to wake thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [unsnoozeThread], + ); + // One snooze per thread at a time — same double-dispatch guard as settle. + const snoozingThreadKeysRef = useRef(new Set()); + const attemptSnooze = useCallback( + ( + threadRef: ScopedThreadRef, + preset: SnoozePreset, + opts: { coSnoozingKeys?: ReadonlySet } = {}, + ) => { + void (async () => { + const threadKey = scopedThreadKey(threadRef); + if (snoozingThreadKeysRef.current.has(threadKey)) return; + snoozingThreadKeysRef.current.add(threadKey); + try { + // Snoozing the open thread moves you forward, same as settle — + // both park the thread you're done with for now. + const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure") { + // Never navigate away from a thread that did not snooze. + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + // Snooze hides the row, so the toast is the only confirmation — + // and the Undo is the escape hatch for a mis-click. + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date())}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => attemptUnsnooze(threadRef), + }, + }), + ); + // Only move forward if the user is still on the snoozed thread — + // a navigation made during the await wins over ours. + if (routeThreadKeyRef.current === threadKey) { + navigateAfterSnooze?.(); + } + } finally { + snoozingThreadKeysRef.current.delete(threadKey); + } + })(); + }, + [attemptUnsnooze, planForwardNavigation, snoozeThread], + ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( @@ -1470,10 +1829,35 @@ export default function SidebarV2() { ); if (threadKeys.length === 0) return; const count = threadKeys.length; + // Snooze (N) is offered when every selected thread can actually take + // it — a mixed selection with blocked-on-you work would half-apply. + const selectionNow = new Date().toISOString(); + const snoozableThreads = threadKeys.flatMap((threadKey) => { + const thread = threadByKeyRef.current.get(threadKey); + return thread ? [thread] : []; + }); + const canSnoozeSelection = snoozableThreads.every( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && + canSnooze(thread, { now: selectionNow }), + ); + const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( [ { id: "settle", label: `Settle (${count})` }, + ...(canSnoozeSelection + ? [ + { + id: "snooze", + label: `Snooze (${count})`, + children: snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${preset.whenLabel})`, + })), + }, + ] + : []), { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", label: `Delete (${count})`, destructive: true }, ], @@ -1481,6 +1865,23 @@ export default function SidebarV2() { ), ); if (clicked._tag === "Failure") return; + if (clicked.value?.startsWith("snooze:")) { + const preset = snoozePresets.find( + (candidate) => `snooze:${candidate.id}` === clicked.value, + ); + if (preset) { + // Post-snooze navigation must skip threads snoozing in this same + // batch — they are all leaving the card block together. + const coSnoozingKeys = new Set(threadKeys); + for (const thread of snoozableThreads) { + attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { + coSnoozingKeys, + }); + } + clearSelection(); + } + return; + } if (clicked.value === "settle") { // Post-settle navigation must skip threads settling in this same // batch — they are all leaving the card block together. Rows that @@ -1545,11 +1946,13 @@ export default function SidebarV2() { }, [ attemptSettle, + attemptSnooze, clearSelection, confirmThreadDelete, deleteThread, markThreadUnread, removeFromSelection, + serverConfigs, ], ); @@ -1573,10 +1976,23 @@ export default function SidebarV2() { const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; + const supportsSnooze = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const isSettled = settledThreadKeysRef.current.has(threadKey); + const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); + // Presets resolve at menu-open time (same as the popover). + const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( [ + ...(thread.branch + ? [ + { + id: "new-thread-on-branch", + label: `New thread on ${thread.branch}`, + }, + ] + : []), ...(supportsSettlement ? [ isSettled @@ -1584,6 +2000,21 @@ export default function SidebarV2() { : { id: "settle", label: "Settle thread" }, ] : []), + ...(supportsSnooze + ? [ + isSnoozed + ? { id: "unsnooze", label: "Wake thread" } + : { + id: "snooze", + label: "Snooze", + disabled: !canSnooze(thread, { now: new Date().toISOString() }), + children: snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${preset.whenLabel})`, + })), + }, + ] + : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, @@ -1592,13 +2023,46 @@ export default function SidebarV2() { ), ); if (clicked._tag === "Failure") return; + if (clicked.value?.startsWith("snooze:")) { + const preset = snoozePresets.find( + (candidate) => `snooze:${candidate.id}` === clicked.value, + ); + if (preset) attemptSnooze(threadRef, preset); + return; + } switch (clicked.value) { + case "new-thread-on-branch": { + // Explicit branch carry-over: reuse the thread's worktree when it + // has one, otherwise its branch on the local checkout. + const result = await settlePromise(() => + handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), { + branch: thread.branch, + worktreePath: thread.worktreePath, + envMode: thread.worktreePath ? "worktree" : "local", + startFromOrigin: false, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } case "settle": attemptSettle(threadRef); return; case "unsettle": attemptUnsettle(threadRef); return; + case "unsnooze": + attemptUnsnooze(threadRef); + return; case "rename": startThreadRename(threadRef, thread.title); return; @@ -1638,7 +2102,9 @@ export default function SidebarV2() { }, [ attemptSettle, + attemptSnooze, attemptUnsettle, + attemptUnsnooze, confirmThreadDelete, deleteThread, handleMultiSelectContextMenu, @@ -1892,7 +2358,7 @@ export default function SidebarV2() {
) : null} - +
    - {orderedThreads.flatMap((thread, threadIndex) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const isSettledRow = settledThreadKeys.has(threadKey); - // Settled is the ONLY thing that collapses a row: every - // not-settled thread is a full card. Density comes from users - // (or the auto rules) actually settling work, not from the - // sidebar second-guessing what still matters. - const isCard = !isSettledRow; - const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; - const previousWasCard = - previousThread != null && - !settledThreadKeys.has( - scopedThreadKey( - scopeThreadRef(previousThread.environmentId, previousThread.id), - ), + {(() => { + const renderThreadRow = ( + thread: EnvironmentThreadShell, + section: "active" | "snoozed" | "settled", + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), ); - const showSettledGap = !isCard && previousWasCard; - const row = ( - + // Settled and snoozed are the ONLY things that collapse a + // row: every other thread is a full card. Density comes + // from users (or the auto rules) actually parking work, + // not from the sidebar second-guessing what still matters. + const isCard = section === "active"; + const rowVariant = isCard ? "card" : "slim"; + return ( + + ); + }; + const items: ReactNode[] = activeThreads.map((thread) => + renderThreadRow(thread, "active"), ); - if (!showSettledGap) return [row]; - // The divider is its own keyed list item (not part of the first - // settled row): it keeps one stable DOM node at the boundary, - // so settling a thread slides it instead of teleporting it - // along with whichever row happens to be first in the tail — - // and row heights stay independent of neighbor classification. - return [ -
  • -
    - Settled - -
    -
  • , - row, - ]; - })} - {hiddenSettledCount > 0 ? ( + // Snoozed shelf: between the inbox and Settled — out of the + // way, never gone. The header always renders while anything + // is snoozed (the count is the whole footprint when + // collapsed); rows only when expanded. Vanishes entirely at + // count 0. + if (snoozedThreads.length > 0) { + items.push( +
  • + +
  • , + ); + for (const thread of visibleSnoozedThreads) { + items.push(renderThreadRow(thread, "snoozed")); + } + } + if (settledThreads.length > 0) { + items.push( +
  • + +
  • , + ); + } + for (const thread of renderedSettledThreads) { + items.push(renderThreadRow(thread, "settled")); + } + return items; + })()} + {settledShelfExpanded && hiddenSettledCount > 0 ? (
  • + {expanded ? ( + + + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} + + + ) : null} - } - > - {allDirectoriesExpanded ? ( - - ) : ( - - )} - - - {allDirectoriesExpanded ? "Collapse all" : "Expand all"} - - - - onOpenTurnDiff(turnId, files[0]?.path)} /> } > + Open diff - View diff + Open the full diff
- + {expanded ? ( + + ) : compactPreviewVisible ? ( +
+

+ {scopeSummary.map((scope, index) => ( + + {index > 0 ? : null} + {scope.label} + + {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"} + + + ))} +

+
+ {previewFiles.map((file) => ( + + ))} + +
+
+ ) : null}
); }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index cda5bc475e8..6eba26ae70b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -154,19 +154,8 @@ import { Button } from "../ui/button"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; -import { - BotIcon, - CircleAlertIcon, - ListTodoIcon, - PencilRulerIcon, - type LucideIcon, - LockIcon, - LockOpenIcon, - PenLineIcon, - ShieldIcon, - SparklesIcon, - XIcon, -} from "lucide-react"; +import { BotIcon, CircleAlertIcon, ListTodoIcon, PencilRulerIcon, XIcon } from "lucide-react"; +import { getRuntimeModeConfig, getRuntimeModeOptions } from "./runtimeModePresentation"; import { proposedPlanTitle } from "../../proposedPlan"; import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; import { @@ -194,38 +183,6 @@ import type { ReviewCommentContext } from "../../reviewCommentContext"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; -const runtimeModeConfig: Record< - RuntimeMode, - { label: string; description: string; icon: LucideIcon } -> = { - "approval-required": { - label: "Supervised", - description: "Ask before commands and file changes.", - icon: LockIcon, - }, - "auto-accept-edits": { - label: "Auto-accept edits", - description: "Auto-approve edits, ask before other actions.", - icon: PenLineIcon, - }, - auto: { - label: "Auto", - description: "An AI reviewer approves routine actions; risky ones still ask.", - icon: SparklesIcon, - }, - "medium-access": { - label: "Medium access", - description: "Allow reversible commands, ask before riskier actions.", - icon: ShieldIcon, - }, - "full-access": { - label: "Full access", - description: "Allow commands and edits without prompts.", - icon: LockOpenIcon, - }, -}; - -const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; const COMPOSER_FLOATING_LAYER_SELECTOR = [ '[data-slot="popover-popup"]', '[data-slot="menu-popup"]', @@ -267,6 +224,7 @@ function isInsideComposerFloatingLayer(element: Element): boolean { } const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { + provider: ProviderDriverKind; showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; @@ -277,6 +235,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop onRuntimeModeChange: (mode: RuntimeMode) => void; onTogglePlanSidebar: () => void; }) { + const runtimeModeConfig = getRuntimeModeConfig(props.provider); + const runtimeModeOptions = getRuntimeModeOptions(props.provider); const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; const interactionModeTooltip = @@ -2679,6 +2639,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {isComposerFooterCompact ? ( ) : null} void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { + const runtimeModeConfig = getRuntimeModeConfig(props.provider); + const runtimeModeOptions = getRuntimeModeOptions(props.provider); + return ( - Supervised - Auto-accept edits - Auto - Full access + {runtimeModeOptions.map((mode) => ( + + {runtimeModeConfig[mode].label} + + ))} {props.activePlan ? ( <> diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx index 8fd7e90e381..6787a8df196 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -31,6 +31,8 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: { <> {showParentheses && (} - +{formatCompactDiffCount(additions)} - -{formatCompactDiffCount(deletions)} + + {showParentheses && )} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c0b41527fa5..83ca7d3e952 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -245,6 +245,12 @@ describe("MessagesTimeline", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain('class="sticky top-2 z-10'); + expect(markup).toContain("sticky top-2 z-10"); expect(markup).not.toContain("self-start"); expect(markup).toContain("whitespace-nowrap"); expect(markup).toContain("!size-[22px]"); expect(markup).toContain("size-3"); - expect(markup).toContain('aria-label="Collapse all"'); - expect(markup).toContain('aria-label="View diff"'); + expect(markup).toContain('aria-label="Collapse all folders"'); + expect(markup).toContain('aria-label="Open diff"'); expect(markup).toContain("1 changed file"); }); @@ -428,6 +434,7 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("Show full message"); expect(markup).toContain('data-user-message-collapsible="false"'); + expect(markup).toContain("rounded-2xl bg-accent p-3"); }); it("renders inline terminal labels with the composer chip UI", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 225d2a53177..a429b54deaf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -32,7 +32,6 @@ import { workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; -import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import { getRenderablePatch, resolveDiffThemeName, @@ -44,11 +43,8 @@ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, - ChevronsDownUpIcon, - ChevronsUpDownIcon, CircleAlertIcon, EyeIcon, - FileDiffIcon, GlobeIcon, HammerIcon, MessageCircleIcon, @@ -65,8 +61,8 @@ import { import { Button } from "../ui/button"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; -import { ChangedFilesTree } from "./ChangedFilesTree"; -import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; +import { ChangedFilesCard } from "./ChangedFilesTree"; +import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, @@ -145,6 +141,7 @@ interface TimelineRowActivityState { isWorking: boolean; isRevertingCheckpoint: boolean; activeTurnInProgress: boolean; + latestTurnId: TurnId | null; } const TimelineRowCtx = createContext(null!); @@ -454,8 +451,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, isRevertingCheckpoint, activeTurnInProgress, + latestTurnId: latestTurn?.turnId ?? null, }), - [activeTurnInProgress, isRevertingCheckpoint, isWorking], + [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId], ); // Stable renderItem — no closure deps. Row components read shared state @@ -892,7 +890,7 @@ function UserTimelineRow({ row }: { row: Extract -
+
{regularImages.length > 0 && (
{regularImages.map((image: NonNullable[number]) => ( @@ -1278,83 +1276,32 @@ function AssistantChangedFilesSectionInner({ resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { - const allDirectoriesExpanded = useUiStateStore( - (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, + const activity = use(TimelineRowActivityCtx); + const isLatestTurn = activity.latestTurnId === turnSummary.turnId; + const persistedExpanded = useUiStateStore( + (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); - const summaryStat = summarizeTurnDiffStats(checkpointFiles); + const [autoExpanded] = useState(() => + shouldAutoExpandChangedFiles(checkpointFiles, isLatestTurn), + ); + const [allDirectoriesExpanded, setAllDirectoriesExpanded] = useState(autoExpanded); + const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded); return ( -
-
-

- - {checkpointFiles.length} changed file{checkpointFiles.length === 1 ? "" : "s"} - - {hasNonZeroStat(summaryStat) && ( - - )} -

-
- - - setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) - } - /> - } - > - {allDirectoriesExpanded ? ( - - ) : ( - - )} - - - {allDirectoriesExpanded ? "Collapse all" : "Expand all"} - - - - onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)} - /> - } - > - - - View diff - -
-
- -
+ + setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded) + } + onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)} + onOpenTurnDiff={onOpenTurnDiff} + /> ); } diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index c62e0639db8..2ec4be70994 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -51,7 +51,7 @@ export const ModelListRow = memo(function ModelListRow(props: { contentClassName="flex w-full items-center gap-3" className={cn( "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2.5 transition-[background-color,box-shadow,color]", - "data-highlighted:bg-muted/56 data-selected:bg-foreground/[0.08] data-selected:text-foreground data-selected:ring-0", + "hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-highlighted:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-selected:bg-foreground/[0.08] data-selected:text-foreground data-selected:ring-0 [&[data-highlighted][data-selected]]:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))]", props.disabledReason && "data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent", )} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 8af23b530e5..bbdbd8bd9d9 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -521,7 +521,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
{/* Sidebar */} @@ -568,18 +568,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { handleModelSelect(slug, instanceId); }} > -
+
{/* Search bar */}
-
+
+ } value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index fe2b840ca8f..36a608888b2 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -97,7 +97,10 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { }, [props.instanceEntries, props.selectedInstanceId, showFavorites]); return ( -
+
+
handleSelect("favorites")} type="button" @@ -169,7 +172,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {