Skip to content

Merge upstream/main: performance, PR workflows, and UI improvements#5

Merged
aaditagrawal merged 62 commits intomainfrom
merge/upstream-main-2
Mar 10, 2026
Merged

Merge upstream/main: performance, PR workflows, and UI improvements#5
aaditagrawal merged 62 commits intomainfrom
merge/upstream-main-2

Conversation

@aaditagrawal
Copy link
Copy Markdown
Owner

@aaditagrawal aaditagrawal commented Mar 10, 2026

Summary

  • Integrates 58 upstream commits from pingdotgg/t3code main branch
  • Resolves 20 merge conflicts across server, web, and contracts packages while preserving all fork-specific multi-provider functionality

Upstream additions integrated:

  • Performance: Debounced store persistence, throttled domain event processing (100ms batch), background provider health checks
  • PR/Git workflow: Pull request thread dialog, worktree support, GitHub CLI Effect Schema decoders, branch picker refinements
  • UI/UX: Drag-and-drop project reordering, shift-click multi-select for sidebar threads, compact composer footer, toast stacking stabilization, deferred plan rendering
  • Contracts: New git/WS/orchestration schemas, removed deprecated ProviderServiceTier
  • Tooling: fmt:check CI script, pinned Vite, crypto.randomUUID polyfill

Fork features preserved:

  • Multi-provider adapters (Copilot, Claude, OpenCode, Amp, GeminiCli, Kilo)
  • Provider icons/logos in sidebar threads
  • Provider-specific settings UI (accent colors, logo appearance)
  • Extended editor support, assistant segment management, provider usage bars

Post-merge fixes:

  • Removed stale ProviderServiceTier import from ProviderCommandReactor
  • Fixed appSettings schema parsing to use makeUnsafe for migration compatibility
  • Added missing Select component imports in settings page
  • Fixed terminalStateStore test localStorage mock with vi.hoisted()
  • Fixed store.test.ts structural sharing test data alignment
  • Cleaned up dead service tier code from appSettings.ts

Test plan

  • bun typecheck passes across all 7 packages
  • bun lint passes with 0 errors
  • Web tests: 400/400 pass
  • Server tests: pre-existing @github/copilot-sdk module resolution failures only (not merge-related)

Summary by CodeRabbit

  • New Features

    • Pull Request dialog + PR resolve/prepare/checkout workflow.
    • Drag‑and‑drop project reordering in the sidebar.
    • Provider logo appearance setting (original, accent, grayscale).
    • Compact composer/footer modes for narrow layouts.
    • Debounced composer draft persistence (safer, performant saves).
  • UI/UX Improvements

    • Dynamic terminal font resolution and icon monochrome support.
    • Enhanced thread status pills, multi‑select/thread selection, and long‑plan collapse/expand behavior.
    • Optional hideable scrollbars and improved toast layout.
  • Code Quality

    • CI adds an enforced formatting check (fmt:check).

binbandit and others added 30 commits March 9, 2026 08:29
- add `shouldOpenProjectFolderPickerImmediately` to centralize Sidebar behavior
- trigger folder browse directly when adding a project in desktop Electron
- keep manual path entry for mobile layouts and non-Electron, with tests for all cases
- remove mobile-width gating from sidebar project picker behavior
- simplify picker logic/tests to depend only on Electron runtime
- Replace `shouldOpenProjectFolderPickerImmediately` with direct `isElectron` usage
- Remove obsolete `Sidebar.logic.ts` and its tests
- Use a toast error for add-project failures during immediate folder browse
- Keep inline add-project error state for non-immediate flows
- Include `shouldBrowseForProjectImmediately` in callback dependencies
- add `Sidebar.logic.ts` to centralize thread status pill resolution
- show `Awaiting Input` when plan mode is blocked on user responses
- add unit tests for unseen completion and status-priority behavior
- prevent plan collapse/expand clicks from triggering chat scroll anchoring
- add "Plan Ready" sidebar status when a plan-mode turn is settled with a proposed plan
- update sidebar logic tests to cover the new status and expanded thread inputs
- Drop `serviceTier` from orchestration/provider contracts and server dispatch paths
- Remove service-tier app setting/UI and related resolver logic
- Keep fast badge behavior tied to explicit fast-mode boolean
- delete `shouldShowFastTierIcon` and its test
- remove Zap icon badges from slash model commands and provider model picker
- simplify related ChatView props and memo dependencies
- derive toast indices and Y offsets from the filtered visible toasts
- pass frontmost height and per-toast layout vars to the viewport/item styles
- add unit tests for visible toast layout calculations and height normalization
- add `min-w-0` to chat/flex containers to prevent composer/sidebar overflow
- move `BranchToolbar` into the chat column so layout stays aligned with plan sidebar
- extract sidebar inset base classes to shared logic and add a regression test for `min-w-0`
- add shared footer layout breakpoint helper with tests
- switch chat composer footer to compact menu when width is constrained
- preserve plan sidebar toggle behavior while consolidating footer actions
- Replace `SIDEBAR_INSET_BASE_CLASSNAME` with an inline class string in `sidebar.tsx`
- Delete the now-unused `sidebar.logic.ts` helper and its focused unit test
Co-authored-by: codex <codex@users.noreply.github.com>
- Measure composer width from the form element in initial and resize paths
- Avoid relying on ResizeObserver contentRect width for compact footer layout decisions
- Add composer footer/action data attributes for targeted width measurement
- Enforce a minimum composer width before allowing inline diff sidebar
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>

Applied via @cursor push command
Every keystroke triggered full JSON serialization of all composer drafts
(including base64 image attachments) and a synchronous localStorage
write. At normal typing speed this caused 5+ writes/sec, blocking the
main thread and creating noticeable input lag.

Wrap the Zustand persist storage with a 300ms debounce. In-memory state
updates remain immediate; only the serialization and storage write are
deferred. A beforeunload handler flushes pending writes to prevent data
loss. The removeItem method cancels any pending setItem to avoid
resurrecting cleared drafts.

Adds unit tests for the DebouncedStorage utility covering debounce
timing, rapid writes, removeItem cancellation, flush, and edge cases.
The useStore subscriber called persistState on every state mutation,
triggering JSON.stringify + localStorage.setItem synchronously. It also
ran 8 localStorage.removeItem calls for legacy keys on every fire.

Wrap the subscriber with a 500ms debounce so rapid state changes batch
into a single write. Move legacy key cleanup behind a one-time flag so
it runs only once per page load. Add a beforeunload handler to flush
the final state.
During active sessions, every domain event triggered a full
syncSnapshot (IPC fetch + state rebuild + React re-render cascade) and
sometimes a provider query invalidation. Events fire in rapid bursts
during AI turns.

Replace per-event processing with a throttle-first pattern: schedule a
flush on the first event, absorb subsequent events within a 100ms
window, then sync once. Provider query invalidation is batched via a
flag. Since syncSnapshot fetches the complete snapshot, no events are
lost by skipping intermediate syncs.
The ProviderHealth layer blocked server startup with two sequential CLI
spawns (codex --version + codex login status), each with a 4-second
timeout, delaying startup by up to 8 seconds.

Run health checks in the background via Effect.runPromise so the layer
resolves immediately with a placeholder status. Add an onReady callback
to ProviderHealthShape so wsServer can push the resolved statuses to
connected clients once checks complete, preventing early-connecting
clients from showing "Checking..." indefinitely.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Drop the unused `onReady` hook from `ProviderHealthShape`
- Keep startup health status access focused on `getStatuses`
- Replace manual timeout debounce logic with `@tanstack/react-pacer`'s `Debouncer`
- Persist updates via `maybeExecute` to reduce localStorage write thrashing
- Flush pending persistence on `beforeunload` to avoid losing recent state
- Replace manual timeout-based domain event batching with `Throttler`
- Keep provider query invalidation batched with trailing 100ms flushes
- Cancel throttler and reset invalidation flag during EventRouter cleanup
- Replace manual timeout/pending-value debounce logic with `@tanstack/react-pacer` `Debouncer`
- Keep `removeItem`/`flush` behavior while simplifying and standardizing persistence timing
Remove the hard max-w-28 (112px) cap and shrink-0 on the project name
badge so it uses available flex space instead of aggressively truncating.
- wire project list to dnd-kit sortable with vertical drag constraints
- prevent accidental expand/collapse clicks during drag gestures
- update store logic/tests and add dnd-kit dependencies
- prevent accidental project toggles after drag operations
- switch project rows to Collapsible for stable expand/collapse animation
- add optional hidden scrollbar mode to ScrollArea and apply it in SidebarContent
- Reset post-drag click suppression when drag is canceled
- Consume only the synthetic click after drag release, then clear suppression
juliusmarminge and others added 12 commits March 9, 2026 22:50
- Replace manual JSON parsing with shared schema-based decoding helpers
- Normalize PR and repository payloads after decoding for safer typed outputs
- Improve invalid JSON error mapping with operation-specific GitHubCliError details
- create a unique local branch name for cross-repo PR worktrees (`pr/<number>-<head>`)
- fetch/set upstream against that local branch instead of overwriting local `main`
- add regression tests for fork PRs where head branch is `main`
- Treat remotes that only differ by trailing slash after `.git` as the same remote
- Add a GitCore regression test for trailing-slash remote reuse
- Refactor `GitHubCli` tests to `@effect/vitest` layer-based effect tests with reset cleanup
- Wire `it.layer` to `GitHubCliLive` in `GitHubCli.test.ts`
- Remove redundant `Effect.provide(GitHubCliLive)` calls in individual tests
- Return `initialBranch` from test repo setup helper
- Replace hardcoded `main` checkout/push/assertions with detected branch name
- Change local PR worktree branch format from `pr/<number>-<head>` to `t3code/pr-<number>/<head>`
- Update GitManager tests to assert the new branch naming convention
- Replace the inline composer PR menu with a dedicated `PullRequestThreadDialog`
- Remove `/checkout-pr` from composer slash-command parsing and tests
- Fix branch selector virtualization sizing for the checkout-PR row
…g#651)

* feat(web): add shift-click multi-select for sidebar threads

Add support for selecting multiple threads in the sidebar via
Cmd/Ctrl+Click (toggle) and Shift+Click (range select), with
bulk context menu actions for Delete and Mark Unread.

- New threadSelectionStore (Zustand) for ephemeral selection state
  with anchor-based range selection and no-op guards
- Platform-aware modifier detection (Cmd on Mac, Ctrl elsewhere)
  matching existing isMacPlatform pattern in terminal-links
- Extracted deleteThread helper from handleThreadContextMenu for
  reuse by bulk delete, with deletedThreadIds set for correct
  fallback navigation during batch operations
- Selected threads share the active thread highlight style
- Escape key clears selection; plain click clears and navigates
- Right-click on selected thread shows bulk menu; right-click on
  unselected thread clears selection and shows single-thread menu
- 22 unit tests covering toggle, range select, clear, remove,
  and edge cases (cross-project fallback, anchor stability)

* fix(web): filter deleted threads from worktree orphan check during bulk delete

When bulk-deleting threads that share a worktree, the stale threads
closure caused getOrphanedWorktreePathForThread to see the other
threads-being-deleted as still alive, suppressing the orphaned
worktree cleanup prompt. Filter out deletedThreadIds (keeping the
current thread) so the check correctly identifies worktrees that
will have no surviving references.

* fix(web): fix shift-click range selection, distinct selection styling, and deselect on click away

* Preserve thread multi-select on safe sidebar control clicks

- avoid clearing selection on mousedown for thread items and marked safe controls
- centralize the selection-safe selector and add logic tests for clear vs preserve behavior
- simplify thread selection store typing by merging state/actions interface

---------

Co-authored-by: Julius Marminge <julius0216@outlook.com>
* Add  formatting conventions

* Add a fmt lint check

* rely on defaults

---------

Co-authored-by: Julius Marminge <julius0216@outlook.com>
Integrates 58 upstream commits including performance improvements
(debounced persistence, throttled domain events, background health checks),
PR/git workflow features (worktree support, PR thread dialog), and UI
enhancements (drag-and-drop projects, multi-select threads, compact
composer footer). Preserves fork's multi-provider adapters, provider
icons, settings UI, and orchestration extensions. Fixes test
compatibility for localStorage mocks, schema migrations, and structural
sharing assertions.
Remove unused APP_SERVICE_TIER_OPTIONS, AppServiceTier,
AppServiceTierSchema, and MODELS_WITH_FAST_SUPPORT that were
leftover from the upstream merge where service tier was removed.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 10, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c6e2d435-6969-40e6-9a8d-84ba995025b9

📥 Commits

Reviewing files that changed from the base of the PR and between f23e5e0 and d1e41ed.

📒 Files selected for processing (2)
  • apps/web/src/components/ChatView.tsx
  • apps/web/src/components/Sidebar.tsx

📝 Walkthrough

Walkthrough

Adds a CI format-check step and formatter config tweaks; introduces pull-request resolve/prepare APIs across contracts, WS, server Git layers, and web UI; removes serviceTier fields; refactors provider adapters/health; adds many UI features (Sidebar DnD, PR dialog, icons, terminal fonts), utilities, and tests.

Changes

Cohort / File(s) Summary
CI & Formatter
\.github/workflows/ci.yml, package.json, .oxfmtrc.json
Added CI "Format" step (runs fmt:check), new fmt:check script, removed duplicate scripts, renamed formatter key and added ignore pattern.
Contracts / IPC (Git & WS)
packages/contracts/src/git.ts, packages/contracts/src/ws.ts, packages/contracts/src/ipc.ts, packages/contracts/...
Added PR-related schemas/types and WS methods (git.resolvePullRequest, git.preparePullRequestThread); removed ProviderServiceTier and serviceTier fields from orchestration/provider schemas.
Server: Git core & manager
apps/server/src/git/..., apps/server/src/wsServer.ts
Implemented PR worktree support (ensureRemote, fetchPullRequestBranch, fetchRemoteBranch, setBranchUpstream), added resolve/prepare PR handlers, extensive tests, and WS wiring.
Server: GitHub CLI & GitManager tests
apps/server/src/git/Layers/GitHubCli.ts, .../GitManager.*
Schema-driven gh JSON decoding, new GitHub types, checkoutPullRequest implementation, enriched fake-GH test harness and many PR/worktree scenarios.
Server: Provider & health
apps/server/src/provider/*, apps/server/src/provider/Layers/copilotCliPath.ts
Added Copilot desktop env sanitation, sanitized CLI path resolution, fiber-based health checks, lastTurnId fallback in Claude adapter, Codex retry->warning mapping, and test harness expansions.
Server: Test harness & integrations
apps/server/integration/TestProviderAdapter.integration.ts
Major expansion of TestProviderAdapter harness: session lifecycle, queued responses, new helper APIs for tests.
Web: Pull request UX & Git flows
apps/web/src/components/PullRequestThreadDialog.tsx, apps/web/src/pullRequestReference.ts, apps/web/src/lib/gitReactQuery.ts, apps/web/src/wsNativeApi.ts
Added PR dialog, PR parsing, resolve/prepare PR query+mutation options, native API wiring to new WS methods.
Web: Sidebar, selection & DnD
apps/web/src/components/Sidebar.tsx, apps/web/src/components/Sidebar.logic.ts, apps/web/src/threadSelectionStore.ts
Replaced project reorder with @dnd-kit sortable, added thread multi-selection store (anchor/range), status-pill logic, and related tests.
Web: Chat/Composer/UI refinements
apps/web/src/components/ChatView.tsx, apps/web/src/components/*, apps/web/src/components/ui/*
Large ChatView and composer refactor (compact mode, plan sidebar, composer footer), toast layout logic, scroll-area hideScrollbars, and many tests.
Web: Icons & terminal fonts
apps/web/src/components/Icons.tsx, apps/web/src/lib/terminalFont.ts, apps/web/src/components/*Terminal*.tsx
Added terminal font resolution utility and replaced hard-coded fonts; introduced monochrome IconProps and monochrome rendering support; updated consumers.
Web: State, persistence & utils
apps/web/src/store.ts, apps/web/src/composerDraftStore.ts, apps/web/src/lib/utils.ts
Renamed moveProjectreorderProjects; added structural-sharing/dedup for read-model mapping; debounced composer draft storage with flush; added randomUUID() fallback and draft defaults resolver.
Many tests & docs/plans
apps/**/tests, .plans/*
Added and updated numerous tests (PR flows, composer, toast, provider), plus many plan/documentation updates and widespread formatting refactors.

Sequence Diagram(s)

sequenceDiagram
    participant WebClient as Web Client
    participant WSServer as WS Server
    participant GitManager as GitManager
    participant GitCore as GitCore
    participant FS as FileSystem/Worktree

    WebClient->>WSServer: git.preparePullRequestThread({ cwd, reference, mode })
    activate WSServer
    WSServer->>GitManager: resolvePullRequest({ cwd, reference })
    activate GitManager
    GitManager->>GitCore: ensureRemote / fetchPullRequestBranch / fetchRemoteBranch / setBranchUpstream
    activate GitCore
    GitCore->>FS: run git fetch / worktree add / branch ops
    GitCore-->>GitManager: { branch, worktreePath, pullRequest }
    deactivate GitCore
    GitManager-->>WSServer: prepared result ({ pullRequest, branch, worktreePath })
    deactivate GitManager
    WSServer-->>WebClient: success response with prepared thread info
    deactivate WSServer
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

"🐰 I nibbled a diff, snug in my patch,
New branches and dialogs stitched into the hatch.
I hopped through WS, Git, UI and test—
Left tiny carrot commits, then curled up to rest. 🥕"

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch merge/upstream-main-2

@github-actions github-actions bot added the vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. label Mar 10, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/src/components/ComposerPromptEditor.tsx (1)

632-652: ⚠️ Potential issue | 🟠 Major

This short-circuit can leave the editor out of sync with value/cursor.

On Lines 645-648, the effect returns as soon as the incoming props match externalSnapshotRef, even if snapshotRef.current was already changed by a local edit. If the parent rejects or sanitizes that edit and re-renders with the last accepted value/cursor, the editor will keep showing the stale prompt/caret instead of reconciling back to props.

.plans/14-server-authoritative-event-sourcing-cleanup.md (1)

118-129: ⚠️ Potential issue | 🟠 Major

Scope command_id idempotency explicitly.

A plain unique constraint on command_id is only safe if those IDs are globally unique across all aggregates and retry sources. Otherwise a duplicate on one thread can alias a command from another thread and return the wrong prior event. Please spell out whether the key is global, or scope the uniqueness check to the aggregate and reject payload mismatches.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.plans/14-server-authoritative-event-sourcing-cleanup.md around lines 118 -
129, The unique constraint on command_id must be scoped to the aggregate (and
aggregate type if applicable) to avoid cross-aggregate collisions: update the
migration that adds the DB constraint (referencing the new 00x_* migration) to
make the unique index on (aggregate_id, command_id) [and aggregate_type if your
schema has it], and update the append logic in
Services/OrchestrationEvents.append (and Layers/OrchestrationEvents.append) so
that on duplicate key it fetches the existing event for that
aggregate+command_id, compares the stored command payload/metadata to the
incoming payload, and only return the prior event when they match; otherwise
return a deterministic rejection/error indicating a command_id collision with
mismatched payloads.
🟡 Minor comments (10)
apps/web/src/components/ui/scroll-area.tsx-26-28 (1)

26-28: ⚠️ Potential issue | 🟡 Minor

Prevent gutter spacing when hideScrollbars is enabled.

If a caller passes both scrollbarGutter and hideScrollbars, the viewport still reserves pe-2.5/pb-2.5 even though Lines 34-40 remove the scrollbar UI. That leaves a blank strip on the right/bottom edge.

Suggested fix
-          scrollbarGutter && "data-has-overflow-y:pe-2.5 data-has-overflow-x:pb-2.5",
+          scrollbarGutter &&
+            !hideScrollbars &&
+            "data-has-overflow-y:pe-2.5 data-has-overflow-x:pb-2.5",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/ui/scroll-area.tsx` around lines 26 - 28, The gutter
spacing is still applied when both scrollbarGutter and hideScrollbars are true;
update the conditional that adds the "data-has-overflow-y:pe-2.5
data-has-overflow-x:pb-2.5" classes so it only applies when scrollbarGutter is
true AND hideScrollbars is false (e.g., change the expression to scrollbarGutter
&& !hideScrollbars) inside the ScrollArea component where those class strings
are assembled; this ensures the pe-2.5/pb-2.5 gutter is not reserved when the
scrollbar UI is hidden.
apps/web/src/index.css-123-139 (1)

123-139: ⚠️ Potential issue | 🟡 Minor

Quote Consolas and Menlo in this stack.

Lines 136 and 138 hit the current Stylelint value-keyword-case rule because those family names are unquoted. Quoting them preserves the intended font names and clears the lint error.

💡 Proposed fix
   --terminal-font-family:
     "Symbols Nerd Font Mono",
     "Symbols Nerd Font",
     "JetBrainsMono Nerd Font Mono",
     "JetBrainsMonoNL Nerd Font Mono",
     "Hack Nerd Font Mono",
     "SauceCodePro Nerd Font Mono",
     "FiraCode Nerd Font Mono",
     "MesloLGS NF",
     "CaskaydiaMono Nerd Font Mono",
     "Geist Mono",
     "SF Mono",
     "SFMono-Regular",
-    Consolas,
+    "Consolas",
     "Liberation Mono",
-    Menlo,
+    "Menlo",
     monospace;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/index.css` around lines 123 - 139, The --terminal-font-family
CSS variable contains unquoted family names "Consolas" and "Menlo" which
triggers Stylelint's value-keyword-case rule; update the font-family list in the
--terminal-font-family declaration to wrap Consolas and Menlo in quotes
(preserve the existing order, commas and other entries) so those font family
names are treated as strings and the lint error is resolved.
apps/web/src/lib/terminalFont.ts-1-29 (1)

1-29: ⚠️ Potential issue | 🟡 Minor

The fallback now applies the dark terminal stack in light mode too.

--terminal-font-family is only defined under the dark variant in apps/web/src/index.css, so Lines 25-29 fall back to DEFAULT_TERMINAL_FONT_FAMILY whenever light mode is active. Because that default is the same Nerd-font-first stack as the dark override, light-theme terminals pick up the new font list as well. Keep the fallback aligned with the non-dark stack, or define a :root value and override it only in dark mode.

💡 One way to keep the change dark-only
 const DEFAULT_TERMINAL_FONT_FAMILY = [
-  '"Symbols Nerd Font Mono"',
-  '"Symbols Nerd Font"',
-  '"JetBrainsMono Nerd Font Mono"',
-  '"JetBrainsMonoNL Nerd Font Mono"',
-  '"Hack Nerd Font Mono"',
-  '"SauceCodePro Nerd Font Mono"',
-  '"FiraCode Nerd Font Mono"',
-  '"MesloLGS NF"',
-  '"CaskaydiaMono Nerd Font Mono"',
   '"Geist Mono"',
   '"SF Mono"',
   '"SFMono-Regular"',
   "Consolas",
   '"Liberation Mono"',
   "Menlo",
   "monospace",
 ].join(", ");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/lib/terminalFont.ts` around lines 1 - 29, The fallback constant
DEFAULT_TERMINAL_FONT_FAMILY and resolveTerminalFontFamily() currently return
the dark-mode Nerd-font-first stack when the CSS variable is absent (light
mode); change the fallback to the original non-dark stack or ensure a :root CSS
variable is defined so light mode supplies a proper value. Concretely, update
DEFAULT_TERMINAL_FONT_FAMILY to the non-dark/default terminal font list (the
stack you used before the dark update) or add a :root --terminal-font-family in
CSS and keep the dark override only in the dark variant; keep references to
DEFAULT_TERMINAL_FONT_FAMILY and resolveTerminalFontFamily() unchanged so the
function uses the corrected fallback.
.plans/16c-pr89-remediation-checklist.md-202-202 (1)

202-202: ⚠️ Potential issue | 🟡 Minor

Keep the thread IDs exact here.

The inserted * characters make these no longer match the real GitHub thread identifiers, which breaks copy/paste lookup and audit traceability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.plans/16c-pr89-remediation-checklist.md at line 202, The listed thread
identifiers (e.g., "PRRT*kwDORLtfbc5widJw", "PRRT_kwDORLtfbc5wnWv*",
"PRRT_kwDORLtfbc5w0_g7", "PRRT_kwDORLtfbc5w1C36") contain stray asterisks that
alter the actual GitHub thread IDs; update the entries to the exact IDs by
removing the inserted "*" characters so they match the real identifiers (for
example change "PRRT*kwDORLtfbc5widJw" → "PRRT_kwDORLtfbc5widJw" and
"PRRT_kwDORLtfbc5wnWv*" → "PRRT_kwDORLtfbc5wnWv") ensuring copy/paste lookup and
audit traceability work.
apps/server/src/open.test.ts-126-142 (1)

126-142: ⚠️ Potential issue | 🟡 Minor

Clean up the temporary fixture directory.

This test creates a real directory tree but never removes it, so repeated runs leave junk behind in the OS temp dir. Wrap the fixture setup in try/finally and fs.rmSync(..., { recursive: true, force: true }).

♻️ Suggested cleanup
   it.effect("uses the containing directory when terminal editors receive a file path", () =>
     Effect.gen(function* () {
       const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-open-ghostty-"));
-      const filePath = path.join(tempDir, "nested", "AGENTS.md");
-      fs.mkdirSync(path.dirname(filePath), { recursive: true });
-      fs.writeFileSync(filePath, "# test\n", "utf8");
-
-      const ghosttyFileTarget = yield* resolveEditorLaunch(
-        { cwd: `${filePath}:48`, editor: "ghostty" },
-        "linux",
-      );
-
-      assert.deepEqual(ghosttyFileTarget, {
-        command: "ghostty",
-        args: [`--working-directory=${path.dirname(filePath)}`],
-      });
+      try {
+        const filePath = path.join(tempDir, "nested", "AGENTS.md");
+        fs.mkdirSync(path.dirname(filePath), { recursive: true });
+        fs.writeFileSync(filePath, "# test\n", "utf8");
+
+        const ghosttyFileTarget = yield* resolveEditorLaunch(
+          { cwd: `${filePath}:48`, editor: "ghostty" },
+          "linux",
+        );
+
+        assert.deepEqual(ghosttyFileTarget, {
+          command: "ghostty",
+          args: [`--working-directory=${path.dirname(filePath)}`],
+        });
+      } finally {
+        fs.rmSync(tempDir, { recursive: true, force: true });
+      }
     }),
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/open.test.ts` around lines 126 - 142, The test inside the
it.effect block creates a temporary directory (tempDir, filePath) but never
deletes it; wrap the fixture setup and assertions in a try/finally so the
tempDir is removed after the test, and in the finally call fs.rmSync(tempDir, {
recursive: true, force: true }) to clean up; locate the it.effect test that
calls resolveEditorLaunch and add the try/finally around the
mkdtempSync/fs.writeFileSync/setup and the assert so cleanup always runs.
apps/web/src/components/ChatView.tsx-2347-2357 (1)

2347-2357: ⚠️ Potential issue | 🟡 Minor

The sidebar carry-over is immediately overwritten on thread switch.

The later useEffect([activeThread?.id]) below closes the sidebar again, so planSidebarOpenOnNextThreadRef never survives navigation. This makes the “open on next thread” path effectively dead unless the user reopens the sidebar manually.

Suggested consolidation
 useEffect(() => {
   setExpandedWorkGroups({});
   setPullRequestDialogState(null);
-  if (planSidebarOpenOnNextThreadRef.current) {
-    planSidebarOpenOnNextThreadRef.current = false;
-    setPlanSidebarOpen(true);
-  } else {
-    setPlanSidebarOpen(false);
-  }
+  setIsRevertingCheckpoint(false);
+  const shouldOpenPlanSidebar = planSidebarOpenOnNextThreadRef.current;
+  planSidebarOpenOnNextThreadRef.current = false;
+  setPlanSidebarOpen(shouldOpenPlanSidebar);
   planSidebarDismissedForTurnRef.current = null;
 }, [activeThread?.id]);

Then remove the later duplicate useEffect([activeThread?.id]).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/ChatView.tsx` around lines 2347 - 2357, The current
useEffect that runs on activeThread?.id sets planSidebarOpen based on
planSidebarOpenOnNextThreadRef but is then countermanded by a later duplicate
useEffect, so the "open on next thread" flag never takes effect; consolidate the
logic by removing the later duplicate useEffect([activeThread?.id]) and ensure
the remaining useEffect (the one shown) both honors
planSidebarOpenOnNextThreadRef.current (setting setPlanSidebarOpen(true) and
clearing the ref) and resets planSidebarDismissedForTurnRef.current as needed;
update references to planSidebarOpenOnNextThreadRef,
planSidebarDismissedForTurnRef, setPlanSidebarOpen, setExpandedWorkGroups, and
setPullRequestDialogState so all thread-switch cleanup and sidebar-opening
behavior live in this single effect.
apps/server/src/git/Layers/GitManager.ts-55-74 (1)

55-74: ⚠️ Potential issue | 🟡 Minor

Don't guess the fork repo from the PR URL.

For cross-repo PRs, pullRequest.url identifies the base repo, not necessarily the head repo. If headRepositoryNameWithOwner is missing, this fallback can synthesize the wrong <owner>/<repo> and wire fetch/upstream against the wrong repository. Returning null here is safer because the caller already has a PR-ref fallback path.

Safer fallback
 function resolveHeadRepositoryNameWithOwner(
   pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
 ): string | null {
   const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? "";
   if (explicitRepository.length > 0) {
     return explicitRepository;
   }
-
-  if (!pullRequest.isCrossRepository) {
-    return null;
-  }
-
-  const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? "";
-  const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url);
-  if (ownerLogin.length === 0 || !repositoryName) {
-    return null;
-  }
-
-  return `${ownerLogin}/${repositoryName}`;
+  return null;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/git/Layers/GitManager.ts` around lines 55 - 74, In
resolveHeadRepositoryNameWithOwner, stop guessing the head repo from
pullRequest.url; remove the parseRepositoryNameFromPullRequestUrl fallback and
instead return null whenever headRepositoryNameWithOwner is empty (including
cross-repo cases) or owner is missing — i.e., if explicitRepository is empty
return null, and do not synthesize `${owner}/${repo}` from pullRequest.url;
callers (which already have the PR-ref fallback) will handle the absent head
repo safely.
apps/server/src/git/Layers/GitCore.test.ts-1201-1226 (1)

1201-1226: ⚠️ Potential issue | 🟡 Minor

This test passes even if fetchPullRequestBranch() is a no-op.

feature/pr-fetch already exists locally before the method under test runs, so git branch --list feature/pr-fetch doesn't validate the fetch behavior. Delete the local branch first or assert that the ref now points at the PR head commit.

Make the assertion prove the fetch happened
         yield* git(tmp, ["push", "-u", "origin", "feature/pr-fetch"]);
+        const prHeadSha = yield* git(tmp, ["rev-parse", "HEAD"]);
         yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]);
         yield* git(tmp, ["checkout", initialBranch]);
+        yield* git(tmp, ["branch", "-D", "feature/pr-fetch"]);
 
         yield* fetchGitPullRequestBranch({
           cwd: tmp,
           prNumber: 55,
           branch: "feature/pr-fetch",
         });
 
-        const localBranches = yield* git(tmp, ["branch", "--list", "feature/pr-fetch"]);
-        expect(localBranches).toContain("feature/pr-fetch");
+        expect(yield* git(tmp, ["rev-parse", "feature/pr-fetch"])).toBe(prHeadSha);
         const currentBranch = yield* git(tmp, ["branch", "--show-current"]);
         expect(currentBranch).toBe(initialBranch);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/git/Layers/GitCore.test.ts` around lines 1201 - 1226, The
test is false-positive because feature/pr-fetch exists locally before calling
fetchGitPullRequestBranch; remove the local branch (e.g., run git(...,
["branch", "-D", "feature/pr-fetch"]) or delete via helper) before invoking
fetchGitPullRequestBranch, or instead assert the branch/refs/pull/55/head commit
changed by looking up the commit hash (use git(..., ["rev-parse",
"refs/pull/55/head"]) or git(..., ["rev-parse", "feature/pr-fetch"]) and
compare) to prove the fetch actually updated the ref; update the test around
fetchGitPullRequestBranch to perform one of these actions and then assert the
expected commit identity or branch existence.
apps/web/src/components/Sidebar.tsx-1913-1917 (1)

1913-1917: ⚠️ Potential issue | 🟡 Minor

Keep SortableContext.items aligned with the rendered projects.

During thread search you return null for projects with no matches, but items still contains every project id. That makes reorder state depend on invisible projects, so clearing the search can leave the persisted order surprising. Either disable reordering while search is active or drive both the map and items from the same visible-project list.

Also applies to: 1931-1933

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/Sidebar.tsx` around lines 1913 - 1917, The
SortableContext is being fed all project ids (via items={projects.map(p =>
p.id)}) while the render maps can return null for non-matching projects during a
thread search, causing hidden items to affect reorder state; fix by computing a
visibleProjects array (e.g. filter projects by the same search predicate used in
the render) and use items={visibleProjects.map(p => p.id)} and map over
visibleProjects for rendering, or alternatively disable reordering when a search
is active (e.g. pass an empty items list or skip SortableContext when
threadSearchActive); update references in the Sidebar component where
SortableContext and the project rendering occur so both use the same visible
list.
apps/web/src/components/Sidebar.tsx-1515-1519 (1)

1515-1519: ⚠️ Potential issue | 🟡 Minor

Use Element in the global mousedown guard.

The target for the PR/provider/terminal icons can be an SVGElement, so this HTMLElement narrow turns safe clicks inside a data-thread-item into null and clears the current multi-select unexpectedly. Switching this check to event.target instanceof Element and widening shouldClearThreadSelectionOnMouseDown accordingly avoids that false reset.

💡 Suggested fix
-      const target = event.target instanceof HTMLElement ? event.target : null;
+      const target = event.target instanceof Element ? event.target : null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/Sidebar.tsx` around lines 1515 - 1519, The mousedown
handler's target narrowing uses `event.target instanceof HTMLElement`, which
returns null for SVG elements and causes valid clicks on PR/provider/terminal
icons to be treated as misses; change the guard in the onMouseDown function to
`event.target instanceof Element` and update the signature/logic of
shouldClearThreadSelectionOnMouseDown to accept an Element (or broader) instead
of HTMLElement so SVGElement targets are handled correctly; ensure
clearSelection is only called when shouldClearThreadSelectionOnMouseDown returns
true with the new Element type.
🧹 Nitpick comments (15)
apps/server/src/provider/Layers/CodexAdapter.ts (1)

1318-1318: Remove the no-op identity map.

.pipe(Effect.map((session) => session)) is an identity transformation that adds no value. It likely remained from a previous refactor.

🧹 Proposed fix
-      }).pipe(Effect.map((session) => session));
+      });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/provider/Layers/CodexAdapter.ts` at line 1318, Remove the
no-op identity mapping in CodexAdapter by deleting the
`.pipe(Effect.map((session) => session))` call (the identity transform using
Effect.map) and directly return the preceding Effect/stream result instead;
locate the pipe chain on the session-producing expression in the CodexAdapter
implementation and remove the Effect.map identity to simplify the pipeline.
apps/web/src/threadSelectionStore.test.ts (1)

58-65: Consider adding explicit test for deselecting the anchor thread.

The test "preserves anchor when deselecting a non-anchor thread" covers deselecting a thread that isn't the anchor. It may be worth adding an explicit test case for when the anchor thread itself is deselected via toggleThread, verifying that the anchor remains (which is the current behavior based on the implementation). This would document the intentional distinction from removeFromSelection, which clears the anchor when the anchor thread is removed.

📝 Suggested test case
it("preserves anchor when deselecting the anchor thread itself", () => {
  const store = useThreadSelectionStore.getState();
  store.toggleThread(THREAD_A);
  store.toggleThread(THREAD_B); // anchor = B
  store.toggleThread(THREAD_B); // deselect B (the anchor)

  const state = useThreadSelectionStore.getState();
  expect(state.selectedThreadIds.has(THREAD_B)).toBe(false);
  expect(state.anchorThreadId).toBe(THREAD_B); // anchor preserved for shift-click continuity
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/threadSelectionStore.test.ts` around lines 58 - 65, Add an
explicit unit test to cover deselecting the anchor thread via toggleThread:
using useThreadSelectionStore.getState(), toggle THREAD_A then THREAD_B to make
B the anchor, then toggleThread(THREAD_B) to deselect the anchor and assert that
selectedThreadIds no longer contains THREAD_B but anchorThreadId still equals
THREAD_B; reference useThreadSelectionStore, toggleThread, selectedThreadIds and
anchorThreadId to locate where to add the test and distinguish this behavior
from removeFromSelection which should clear the anchor.
apps/web/src/composerDraftStore.ts (1)

29-50: Well-designed debounced storage wrapper for single-key persistence.

The implementation correctly wraps the base storage with debounced writes. One note: the shared Debouncer instance means only the last call's arguments are retained across all keys. This works perfectly for Zustand persist (which uses a single key), but could be surprising if this exported function is reused elsewhere with multiple keys.

Consider adding a brief JSDoc comment documenting this single-key semantic:

+/**
+ * Creates a storage wrapper that debounces setItem calls.
+ * Note: Uses a single shared debouncer, so only the last setItem call's
+ * arguments are retained. Designed for single-key usage (e.g., Zustand persist).
+ */
 export function createDebouncedStorage(baseStorage: StateStorage): DebouncedStorage {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/composerDraftStore.ts` around lines 29 - 50, The
createDebouncedStorage wrapper uses a single shared Debouncer instance
(Debouncer) so only the most recent maybeExecute arguments are retained across
all keys, which is fine for single-key usage (Zustand persist) but surprising
for multi-key use; add a brief JSDoc above createDebouncedStorage describing
this single-key semantic, noting that Debouncer is shared and only the last
key/value is persisted when multiple keys are used, and advise callers to not
reuse this wrapper for multi-key persistence or to construct separate debounced
storages per key.
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts (1)

847-850: Consider adding providerThreadRef(context) to the fallback providerRefs for consistency.

The normal completion path (lines 932-935) includes ...providerThreadRef(context) in providerRefs, but this fallback path omits it. If context.resumeSessionId is available, the providerThreadId would be missing from the event in the fallback case.

♻️ Suggested fix for consistency
           providerRefs: {
+            ...providerThreadRef(context),
             ...(fallbackTurnId ? { providerTurnId: String(fallbackTurnId) } : {}),
           },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/provider/Layers/ClaudeCodeAdapter.ts` around lines 847 - 850,
The fallback branch that builds providerRefs only spreads providerTurnId when
fallbackTurnId exists but omits the providerThreadRef built by
providerThreadRef(context), so events lose providerThreadId when
context.resumeSessionId is present; update the fallback providerRefs object (the
code around providerRefs in ClaudeCodeAdapter) to also spread
...providerThreadRef(context) alongside ...(fallbackTurnId ? { providerTurnId:
String(fallbackTurnId) } : {}) so the fallback path includes the same
providerThreadId logic as the normal completion path.
apps/web/src/components/ChatView.browser.tsx (1)

293-311: Keep the root snapshot timestamp aligned with the injected plan state.

This helper makes the nested thread/plan newer than snapshot.updatedAt, which creates a read-model state the app should not normally receive. Keeping the top-level timestamp in sync will make the fixture less surprising for any time/order-sensitive UI logic.

♻️ Proposed fix
   return {
     ...snapshot,
+    updatedAt: isoAt(1_001),
     threads: snapshot.threads.map((thread) =>
       thread.id === THREAD_ID
         ? Object.assign({}, thread, {
             proposedPlans: [
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/ChatView.browser.tsx` around lines 293 - 311, The
top-level snapshot.updatedAt must be kept in sync with the injected thread/plan
timestamps so the fixture doesn't create an impossible read model; when mapping
snapshot.threads and injecting the proposedPlans for THREAD_ID (with createdAt:
isoAt(1_000) and updatedAt: isoAt(1_001)), also set the returned
snapshot.updatedAt to isoAt(1_001) (or otherwise align it to the thread's
updatedAt) so the root snapshot timestamp matches the nested plan state.
apps/web/src/components/ui/toast.logic.test.ts (1)

18-63: Consider one more normalization regression case.

These tests lock in the happy path and undefined, but buildVisibleToastLayout also collapses null, negative, and non-finite heights to 0. A tiny table-driven case for those inputs would make future refactors safer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/ui/toast.logic.test.ts` around lines 18 - 63, Add a
new test case in apps/web/src/components/ui/toast.logic.test.ts that exercises
buildVisibleToastLayout with heights that should be normalized to zero (e.g.,
null, negative numbers, NaN, Infinity) using a small table-driven input array;
assert that layout.frontmostHeight === 0 and that each item’s offsetY (and
visibleIndex if relevant) collapses to the expected sequence (offsetY all zero
and visibleIndex incrementing from 0) to lock in the normalization behavior for
null/negative/non-finite values.
.plans/08-precommit-format-and-lint.md (1)

20-22: Clarify formatter tooling choice.

The CI workflow uses oxfmt (bun run fmt:check), but this plan proposes biome format --write and biome check for pre-commit hooks. If both tools are intended for different purposes (e.g., oxfmt for package.json sorting, biome for code formatting), consider documenting this distinction. Otherwise, align the tooling to avoid inconsistencies between local pre-commit checks and CI.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.plans/08-precommit-format-and-lint.md around lines 20 - 22, Clarify and
align the formatter tooling: either document why `oxfmt` (invoked by `bun run
fmt:check`) is used in CI and `biome format --write` / `biome check` are used in
pre-commit (e.g., note `oxfmt` is for package.json sorting and `biome` is for
code styles), or change the staged-file tasks to use the same command as CI
(`bun run fmt:check` / `oxfmt`) to avoid mismatches; update the plan text around
the staged tasks (`biome format --write`, `biome check`) to explicitly state the
intended roles or replace them so the pre-commit hooks and CI use the identical
formatter command.
.plans/10-unify-process-session-abstraction.md (1)

34-42: Make PTY coverage part of the done criteria.

Line 32 calls PTY behavior the main risk, but Line 37 still leaves PTY-path tests optional. That makes it too easy to ship the abstraction with only child-process coverage. Please require at least one PTY-backed write/exit contract test in the done criteria.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.plans/10-unify-process-session-abstraction.md around lines 34 - 42, Update
the Done Criteria to require PTY-path coverage: add a bullet saying at least one
PTY-backed contract test must exist that exercises write and exit behavior
(write/kill/killAll) so PTY-backed session backends are exercised; reference the
existing test harness (processManager.test.ts) as the baseline and require
adding a PTY-backed test that verifies write and kill/killAll semantics for the
Session backend before the item is considered done.
apps/web/src/routes/_chat.$threadId.tsx (1)

18-19: Centralize the composer width heuristic.

This callback now hard-codes a second layout threshold (208 + actions + gap) alongside the compact rules in apps/web/src/components/composerFooterLayout.ts. If either side changes, the resize guard and the actual footer layout can disagree. Consider moving the minimum-width calculation into the shared composer layout module so both decisions stay in sync.

Also applies to: 95-119

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/routes/_chat`.$threadId.tsx around lines 18 - 19, The hard-coded
COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX in _chat.$threadId.tsx duplicates
the layout threshold logic in apps/web/src/components/composerFooterLayout.ts;
move the minimum-width calculation into the shared composer layout module (where
composerFooterLayout lives), export a single function/constant (e.g.,
getComposerCompactMinWidth or COMPOSER_COMPACT_MIN_WIDTH) and replace the
literal in _chat.$threadId.tsx (and the other occurrences around lines 95–119)
and the resize guard to call/import that shared value so the guard and the
footer layout use the exact same heuristic. Ensure the exported symbol accounts
for actions+gap logic currently encoded in both places.
apps/web/src/pullRequestReference.ts (1)

1-3: URL pattern only supports public GitHub.

The regex is limited to github.com. If enterprise GitHub instances (e.g., github.mycompany.com) need to be supported in the future, this pattern would need adjustment.

If this is intentional (public GitHub only), consider adding a brief comment documenting this scope.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/pullRequestReference.ts` around lines 1 - 3, The URL regex
GITHUB_PULL_REQUEST_URL_PATTERN currently only matches github.com and will not
accept enterprise or self-hosted domains like github.mycompany.com; either
broaden the pattern to allow optional subdomains (e.g., permit *.github.* or any
host matching /https?:\/\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/i) or, if public
github.com is intentional, add a concise comment above
GITHUB_PULL_REQUEST_URL_PATTERN (and optionally PULL_REQUEST_NUMBER_PATTERN)
documenting that only public github.com URLs are supported and why.
apps/server/src/git/Layers/GitHubCli.test.ts (1)

125-125: Consider using toContain for clearer assertion.

The current assertion works but expect().toContain() provides clearer failure messages:

-      assert.equal(error.message.includes("Pull request not found"), true);
+      expect(error.message).toContain("Pull request not found");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/git/Layers/GitHubCli.test.ts` at line 125, Replace the
brittle assert.equal check in the test with a Jest-style containment assertion
for clearer failures: in GitHubCli.test.ts change the assertion using
assert.equal(error.message.includes("Pull request not found"), true) to use
expect(error.message).toContain("Pull request not found") (or the test
framework's equivalent) so failures report the actual message and mismatch more
clearly.
apps/web/src/components/BranchToolbar.tsx (1)

133-134: Consider simplifying optional prop forwarding.

The conditional spread pattern works but is verbose. Since React ignores undefined props, you could pass them directly:

onCheckoutPullRequestRequest={onCheckoutPullRequestRequest}
onComposerFocusRequest={onComposerFocusRequest}

However, I acknowledge this matches the existing pattern on line 134, so keeping it consistent is reasonable if this is an intentional style choice in the codebase.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/BranchToolbar.tsx` around lines 133 - 134, The
conditional prop-spread for onCheckoutPullRequestRequest and
onComposerFocusRequest is verbose; simplify by passing the props directly (e.g.,
use onCheckoutPullRequestRequest={onCheckoutPullRequestRequest} and
onComposerFocusRequest={onComposerFocusRequest}) because React ignores undefined
props—update the JSX in BranchToolbar (where those spreads appear) to remove the
{...(cond ? { prop } : {})} pattern and pass the props directly.
apps/server/src/wsServer.ts (1)

629-644: Race condition: clients connecting before health check completes receive empty providers.

The providers variable starts as an empty array and is populated asynchronously via Effect.forkIn. Clients connecting before the health check completes will receive an empty providers array in both the initial serverGetConfig response (line 1041) and any serverConfigUpdated pushes triggered by keybinding changes (line 660).

Consider initializing with a loading/pending state or awaiting the first health check before accepting connections, depending on the desired UX.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/wsServer.ts` around lines 629 - 644, The current logic
assigns providers = [] and then populates it asynchronously from
providerHealth.getStatuses (used in the Effect.flatMap that calls broadcastPush
to WS_CHANNELS.serverConfigUpdated), causing serverGetConfig responses and
subsequent pushes to send an empty providers list if clients connect before the
first health check completes; fix by treating providers as a pending state or
awaiting the first emission from providerHealth.getStatuses before accepting or
responding to connections: either initialize providers to a sentinel (e.g., null
or {status: "loading"}) and update serverGetConfig and the broadcastPush
payloads to convey a loading state, or block/await the first
providerHealth.getStatuses emission (subscribe synchronously or await the first
value from providerHealth.getStatuses) before forking subscriptionsScope so
providers is populated before any broadcastPush or serverGetConfig runs.
.plans/01-shared-model-normalization.md (1)

22-28: Plan may conflict with contracts package guideline.

The proposal to add normalizeModelSlug, resolveModelSlug, and DEFAULT_MODEL to packages/contracts may conflict with the established guideline that the contracts package should remain schema-only with no runtime logic.

Consider placing these utilities in packages/shared (e.g., t3tools/shared/model) instead, keeping only the canonical model list schema in contracts.

Based on learnings: "In packages/contracts, keep this package schema-only — no runtime logic. Use explicit subpath exports in packages/shared."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.plans/01-shared-model-normalization.md around lines 22 - 28, The contracts
package must remain schema-only: remove runtime utilities normalizeModelSlug,
resolveModelSlug, and the DEFAULT_MODEL constant from the contracts model module
and keep only the canonical model list/schema there; then implement those
utilities in the shared package (create a module that defines
normalizeModelSlug, resolveModelSlug, DEFAULT_MODEL and imports the canonical
model list from the contracts module) and expose them via explicit subpath
exports from the shared package; finally update the contracts package exports to
only export the schema/canonical list and ensure all consumers import the
runtime helpers from the new shared subpath.
packages/contracts/src/git.ts (1)

19-21: Consider consolidating the duplicated PR schemas.

GitStatusPrState/GitPullRequestState and GitStatusPr/GitResolvedPullRequest now describe the same contract twice. Reusing one shared schema here would reduce the chance of the status and PR-resolution payloads drifting later.

Also applies to: 38-45

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/contracts/src/git.ts` around lines 19 - 21, There are duplicate
schemas for PR state and PR objects; replace duplicated definitions by creating
a single canonical schema (e.g., GitPullRequestState) and reuse it everywhere
instead of GitStatusPrState and GitPullRequestState, and likewise consolidate
the PR object schemas by making one shared schema (e.g., GitPullRequest /
GitResolvedPullRequest) and exporting/referencing it from GitStatusPr and
GitResolvedPullRequest locations; update all references to use the shared names
so the contracts don’t diverge (apply the same consolidation to the other
duplicates around the GitStatusPr/GitResolvedPullRequest definitions).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 80223fe9-add4-4fe0-a197-c537a11b8f9a

📥 Commits

Reviewing files that changed from the base of the PR and between 5b6eaea and 7623f4c.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (145)
  • .github/workflows/ci.yml
  • .oxfmtrc.json
  • .plans/01-shared-model-normalization.md
  • .plans/02-typed-ipc-boundaries.md
  • .plans/03-split-codex-app-server-manager.md
  • .plans/04-split-chatview-component.md
  • .plans/05-zod-persisted-state-validation.md
  • .plans/06-provider-logstream-lifecycle.md
  • .plans/07-ci-quality-gates.md
  • .plans/08-precommit-format-and-lint.md
  • .plans/09-event-state-test-expansion.md
  • .plans/10-unify-process-session-abstraction.md
  • .plans/11-effect.md
  • .plans/12-effect-new.md
  • .plans/13-provider-service-integration-tests.md
  • .plans/14-server-authoritative-event-sourcing-cleanup.md
  • .plans/15-effect-server.md
  • .plans/16-pr89-review-remediation-phases.md
  • .plans/16c-pr89-remediation-checklist.md
  • .plans/spec-1-1-cutover-plan.md
  • .plans/spec-contract-matrix.md
  • apps/desktop/scripts/dev-electron.mjs
  • apps/desktop/src/main.ts
  • apps/desktop/src/runtimeArch.ts
  • apps/desktop/src/updateMachine.test.ts
  • apps/server/integration/TestProviderAdapter.integration.ts
  • apps/server/scripts/cli.ts
  • apps/server/src/attachmentStore.ts
  • apps/server/src/codexAppServerManager.test.ts
  • apps/server/src/git/Layers/CodexTextGeneration.test.ts
  • apps/server/src/git/Layers/GitCore.test.ts
  • apps/server/src/git/Layers/GitCore.ts
  • apps/server/src/git/Layers/GitHubCli.test.ts
  • apps/server/src/git/Layers/GitHubCli.ts
  • apps/server/src/git/Layers/GitManager.test.ts
  • apps/server/src/git/Layers/GitManager.ts
  • apps/server/src/git/Services/GitCore.ts
  • apps/server/src/git/Services/GitHubCli.ts
  • apps/server/src/git/Services/GitManager.ts
  • apps/server/src/keybindings.test.ts
  • apps/server/src/open.test.ts
  • apps/server/src/open.ts
  • apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
  • apps/server/src/orchestration/Layers/CheckpointReactor.ts
  • apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
  • apps/server/src/orchestration/Layers/ProjectionPipeline.ts
  • apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
  • apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
  • apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
  • apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
  • apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
  • apps/server/src/orchestration/decider.ts
  • apps/server/src/orchestration/projector.ts
  • apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts
  • apps/server/src/projectFaviconRoute.test.ts
  • apps/server/src/provider/Layers/ClaudeCodeAdapter.ts
  • apps/server/src/provider/Layers/CodexAdapter.test.ts
  • apps/server/src/provider/Layers/CodexAdapter.ts
  • apps/server/src/provider/Layers/CopilotAdapter.ts
  • apps/server/src/provider/Layers/ProviderHealth.test.ts
  • apps/server/src/provider/Layers/ProviderHealth.ts
  • apps/server/src/provider/Layers/ProviderService.test.ts
  • apps/server/src/provider/Layers/ProviderService.ts
  • apps/server/src/provider/Layers/ProviderSessionDirectory.ts
  • apps/server/src/provider/Layers/copilotCliPath.test.ts
  • apps/server/src/provider/Layers/copilotCliPath.ts
  • apps/server/src/provider/Services/ProviderAdapter.ts
  • apps/server/src/wsServer.test.ts
  • apps/server/src/wsServer.ts
  • apps/web/package.json
  • apps/web/src/appSettings.test.ts
  • apps/web/src/appSettings.ts
  • apps/web/src/components/BranchToolbar.logic.test.ts
  • apps/web/src/components/BranchToolbar.logic.ts
  • apps/web/src/components/BranchToolbar.tsx
  • apps/web/src/components/BranchToolbarBranchSelector.tsx
  • apps/web/src/components/ChatView.browser.tsx
  • apps/web/src/components/ChatView.tsx
  • apps/web/src/components/ComposerPromptEditor.tsx
  • apps/web/src/components/DiffPanel.tsx
  • apps/web/src/components/GhosttyTerminalSplitView.tsx
  • apps/web/src/components/GitActionsControl.logic.test.ts
  • apps/web/src/components/GitActionsControl.logic.ts
  • apps/web/src/components/GitActionsControl.tsx
  • apps/web/src/components/Icons.tsx
  • apps/web/src/components/PlanSidebar.tsx
  • apps/web/src/components/ProviderLogo.tsx
  • apps/web/src/components/PullRequestThreadDialog.tsx
  • apps/web/src/components/Sidebar.logic.test.ts
  • apps/web/src/components/Sidebar.logic.ts
  • apps/web/src/components/Sidebar.tsx
  • apps/web/src/components/ThreadTerminalDrawer.tsx
  • apps/web/src/components/composerFooterLayout.test.ts
  • apps/web/src/components/composerFooterLayout.ts
  • apps/web/src/components/desktopUpdate.logic.test.ts
  • apps/web/src/components/ui/collapsible.tsx
  • apps/web/src/components/ui/scroll-area.tsx
  • apps/web/src/components/ui/sidebar.tsx
  • apps/web/src/components/ui/toast.logic.test.ts
  • apps/web/src/components/ui/toast.logic.ts
  • apps/web/src/components/ui/toast.tsx
  • apps/web/src/composer-logic.ts
  • apps/web/src/composerDraftStore.test.ts
  • apps/web/src/composerDraftStore.ts
  • apps/web/src/index.css
  • apps/web/src/lib/gitReactQuery.test.ts
  • apps/web/src/lib/gitReactQuery.ts
  • apps/web/src/lib/terminalFont.ts
  • apps/web/src/lib/threadDraftDefaults.test.ts
  • apps/web/src/lib/threadDraftDefaults.ts
  • apps/web/src/lib/turnDiffTree.test.ts
  • apps/web/src/lib/utils.ts
  • apps/web/src/pendingUserInput.ts
  • apps/web/src/proposedPlan.test.ts
  • apps/web/src/proposedPlan.ts
  • apps/web/src/pullRequestReference.test.ts
  • apps/web/src/pullRequestReference.ts
  • apps/web/src/routes/__root.tsx
  • apps/web/src/routes/_chat.$threadId.tsx
  • apps/web/src/routes/_chat.settings.tsx
  • apps/web/src/session-logic.ts
  • apps/web/src/store.test.ts
  • apps/web/src/store.ts
  • apps/web/src/terminalStateStore.test.ts
  • apps/web/src/terminalStateStore.ts
  • apps/web/src/threadSelectionStore.test.ts
  • apps/web/src/threadSelectionStore.ts
  • apps/web/src/wsNativeApi.test.ts
  • apps/web/src/wsNativeApi.ts
  • apps/web/vite.config.ts
  • package.json
  • packages/contracts/src/git.test.ts
  • packages/contracts/src/git.ts
  • packages/contracts/src/ipc.ts
  • packages/contracts/src/orchestration.ts
  • packages/contracts/src/project.ts
  • packages/contracts/src/provider.ts
  • packages/contracts/src/providerRuntime.ts
  • packages/contracts/src/ws.test.ts
  • packages/contracts/src/ws.ts
  • packages/shared/src/shell.test.ts
  • scripts/build-desktop-artifact.ts
  • scripts/dev-runner.ts
  • scripts/release-smoke.ts
  • scripts/update-release-package-versions.ts
💤 Files with no reviewable changes (3)
  • apps/server/src/provider/Layers/ProviderService.test.ts
  • apps/web/vite.config.ts
  • packages/contracts/src/provider.ts

Comment on lines +465 to +477
const persistedBindings = yield* directory.listThreadIds().pipe(
Effect.flatMap((threadIds) =>
Effect.forEach(
threadIds,
(threadId) =>
directory
.getBinding(threadId)
.pipe(Effect.orElseSucceed(() => Option.none<ProviderRuntimeBinding>())),
{ concurrency: "unbounded" },
),
Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),
);
),
Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't hide ProviderSessionDirectory failures in listSessions.

The outer Effect.orElseSucceed(() => []) turns storage errors into “no persisted bindings”, so callers get partial session data with dropped resumeCursor / runtimeMode overrides instead of a failure or warning. If per-thread lookups should be best-effort, keep the inner fallback and let listThreadIds() failures surface.

🧭 Narrow the fallback to per-binding failures only
         const persistedBindings = yield* directory.listThreadIds().pipe(
           Effect.flatMap((threadIds) =>
             Effect.forEach(
               threadIds,
               (threadId) =>
                 directory
                   .getBinding(threadId)
                   .pipe(Effect.orElseSucceed(() => Option.none<ProviderRuntimeBinding>())),
               { concurrency: "unbounded" },
             ),
           ),
-          Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),
         );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const persistedBindings = yield* directory.listThreadIds().pipe(
Effect.flatMap((threadIds) =>
Effect.forEach(
threadIds,
(threadId) =>
directory
.getBinding(threadId)
.pipe(Effect.orElseSucceed(() => Option.none<ProviderRuntimeBinding>())),
{ concurrency: "unbounded" },
),
Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),
);
),
Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),
);
const persistedBindings = yield* directory.listThreadIds().pipe(
Effect.flatMap((threadIds) =>
Effect.forEach(
threadIds,
(threadId) =>
directory
.getBinding(threadId)
.pipe(Effect.orElseSucceed(() => Option.none<ProviderRuntimeBinding>())),
{ concurrency: "unbounded" },
),
),
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/provider/Layers/ProviderService.ts` around lines 465 - 477,
The current implementation swallows failures from directory.listThreadIds by
using Effect.orElseSucceed(() => []) which masks storage errors; update the
listSessions logic in ProviderService.listSessions so you remove the outer
Effect.orElseSucceed and let directory.listThreadIds() failures propagate (keep
the inner per-thread Effect.orElseSucceed for directory.getBinding to remain
best-effort per binding), so persistedBindings is derived from failing
listThreadIds errors instead of silently returning an empty array.

- Guard against overwriting existing local branches during PR thread prep
- Return actual checked-out branch name when reusing worktrees
- Wrap canonical event logger writes to prevent pipeline failures (critical)
- Surface ProviderSessionDirectory.listThreadIds failures via logging
- Wire live typing to debounced draft sync in ChatView
- Add Cursor/Claude Code controls to compact composer footer
- Handle rejected submissions in PullRequestThreadDialog
- Deduplicate worktree cleanup during bulk thread deletion
- Use visible thread order for shift-click range selection
- Treat empty cwd/reference as unavailable in PR query helpers
- Add id and tone to activity equality check for structural sharing
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
apps/web/src/store.ts (1)

75-92: ⚠️ Potential issue | 🟠 Major

Don't overwrite saved project metadata before hydration.

readPersistedState() only primes the module-scoped cwd caches, so the first persistState(useStore.getState()) from StoreProvider still writes expandedProjectCwds: [] and projectOrderCwds: []. If startup aborts before syncServerReadModel() repopulates projects, the user's saved sidebar state is gone. Please split legacy-key cleanup from persistence, or gate writes until threadsHydrated is true.

💡 One way to avoid the startup clobber
+function cleanupLegacyPersistedState(): void {
+  if (typeof window === "undefined" || legacyKeysCleanedUp) return;
+  legacyKeysCleanedUp = true;
+  for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
+    window.localStorage.removeItem(legacyKey);
+  }
+}
+
 function persistState(state: AppState): void {
   if (typeof window === "undefined") return;
   try {
+    cleanupLegacyPersistedState();
     window.localStorage.setItem(
       PERSISTED_STATE_KEY,
       JSON.stringify({
         expandedProjectCwds: state.projects
           .filter((project) => project.expanded)
           .map((project) => project.cwd),
         projectOrderCwds: state.projects.map((project) => project.cwd),
       }),
     );
-    if (!legacyKeysCleanedUp) {
-      legacyKeysCleanedUp = true;
-      for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
-        window.localStorage.removeItem(legacyKey);
-      }
-    }
   } catch {
     // Ignore quota/storage errors to avoid breaking chat UX.
   }
 }
 
 useStore.subscribe((state, previousState) => {
-  if (state.projects === previousState.projects) {
+  if (!state.threadsHydrated || state.projects === previousState.projects) {
     return;
   }
   persistState(state);
 });

Call cleanupLegacyPersistedState() from the mount effect instead of persistState(useStore.getState()).

Also applies to: 806-813

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/store.ts` around lines 75 - 92, The persistState function is
currently writing empty project arrays before hydration and clobbering user
state; modify initialization so legacy key cleanup is separated from actual
persisted-state writes and gate writes until threadsHydrated is true (or
equivalent hydration flag). Concretely, extract the legacy cleanup loop into a
new cleanupLegacyPersistedState function and call that during the mount effect
(e.g., in StoreProvider) instead of calling persistState(useStore.getState()) on
startup; keep persistState (and any calls to it) from writing
expandedProjectCwds/projectOrderCwds unless threadsHydrated is true or projects
have been populated (syncServerReadModel completed), and ensure
LEGACY_PERSISTED_STATE_KEYS removal only runs once via legacyKeysCleanedUp.
apps/web/src/components/ChatView.tsx (2)

5637-5650: ⚠️ Potential issue | 🟠 Major

The new workgroup expansion state is wired through but never used.

expandedWorkGroups and onToggleWorkGroup are added here, but nothing in MessagesTimeline reads that state or renders a toggle. The work-row renderer still always shows every groupedEntries item, so the new collapse/expand behavior never materializes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/ChatView.tsx` around lines 5637 - 5650,
MessagesTimeline accepts expandedWorkGroups and onToggleWorkGroup but never uses
them: update the component so the work-row renderer consults expandedWorkGroups
to decide whether to render each group's entries (e.g., only render
groupedEntries[item] when expandedWorkGroups has that group id) and add a toggle
control that calls onToggleWorkGroup(groupId) to flip expansion; locate the
groupedEntries creation and the work-row renderer inside MessagesTimeline and
change the rendering loop to conditionally map entries based on
expandedWorkGroups and wire the toggle control to onToggleWorkGroup.

3848-3866: ⚠️ Potential issue | 🟠 Major

Keep composerCursor state aligned with the editor cursor.

This handler now only updates composerCursorRef, but ComposerPromptEditor still receives cursor={composerCursor} and the effect at Lines 2425-2429 only clamps the old state. After a normal edit, composerCursor can lag behind the actual caret position.

💡 Proposed fix
       promptRef.current = nextPrompt;
       promptSyncPendingRef.current = true;
       setPromptState((current) => (current === nextPrompt ? current : nextPrompt));
+      setComposerCursor((current) => (current === nextCursor ? current : nextCursor));
       composerCursorRef.current = nextCursor;
       setHasPromptText((current) => {
         const next = nextPrompt.trim().length > 0;
         return current === next ? current : next;
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/ChatView.tsx` around lines 3848 - 3866, The handler
onPromptChange updates composerCursorRef.current but not the composerCursor
state used by ComposerPromptEditor; update the state as well to keep the editor
caret in sync. After setting composerCursorRef.current = nextCursor, call the
composer cursor state setter (e.g., setComposerCursor) and only update when the
value actually changes (e.g., setComposerCursor(cur => cur === nextCursor ? cur
: nextCursor)) so the component receives the new cursor value.
apps/web/src/components/Sidebar.tsx (1)

1153-1157: ⚠️ Potential issue | 🟠 Major

Handle deleteThread failures in both context-menu paths.

deleteThread() can still reject here if the thread.delete command fails. Both callers await it without a try/catch, so a single failure turns into an unhandled rejection; the bulk path also stops mid-batch with partial work already done. Please catch the failure, toast it, and only clear selection for IDs that were actually deleted.

Also applies to: 1266-1266, 1309-1312

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/Sidebar.tsx` around lines 1153 - 1157, The calls to
deleteThread (which invoke api.orchestration.dispatchCommand with type
"thread.delete" and commandId newCommandId()) can reject and are currently
awaited without try/catch; wrap each await of deleteThread in a try/catch, show
the error via the existing toast/error UI (toast or toast.error) when
dispatchCommand fails, and only remove IDs from selection or clear selection for
the threads that actually succeeded (collect successfully-deleted threadIds from
the resolved calls in the bulk path and update selection based on that set
rather than clearing blindly). Ensure you apply this pattern to both
single-delete and bulk-delete callers (the instances around deleteThread at the
shown diff and the other occurrences mentioned).
♻️ Duplicate comments (2)
apps/server/src/provider/Layers/ProviderService.ts (1)

471-487: ⚠️ Potential issue | 🟠 Major

Let listThreadIds() failures surface instead of returning partial sessions.

Line 471 still turns a directory listing failure into [], so callers get active sessions with dropped persisted overrides like resumeCursor and runtimeMode and cannot distinguish that from “no bindings”. Keep the per-binding fallback on Line 478, but avoid swallowing the outer listing failure.

Suggested narrowing of the fallback
         const persistedBindings = yield* directory.listThreadIds().pipe(
           Effect.flatMap((threadIds) =>
             Effect.forEach(
               threadIds,
               (threadId) =>
                 directory
                   .getBinding(threadId)
                   .pipe(Effect.orElseSucceed(() => Option.none<ProviderRuntimeBinding>())),
               { concurrency: "unbounded" },
             ),
           ),
-          Effect.catchCause((cause) => {
-            return Effect.logWarning("failed to list persisted thread bindings", { cause }).pipe(
-              Effect.map(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),
-            );
-          }),
         );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/provider/Layers/ProviderService.ts` around lines 471 - 487,
The current implementation swallows failures from directory.listThreadIds() by
catching the cause and returning an empty array, causing callers to mistake a
listing error for "no bindings"; instead, remove or narrow the Effect.catchCause
that wraps directory.listThreadIds() so that listThreadIds() errors propagate,
while keeping the per-binding fallback around directory.getBinding(threadId)
(the Effect.orElseSucceed for each getBinding) to tolerate missing individual
bindings; in practice, locate the persistedBindings computation that calls
directory.listThreadIds().pipe(...) and change it to let listThreadIds()
failures surface (i.e., do not map them to []), preserving only the per-thread
getBinding fallback.
apps/web/src/components/Sidebar.tsx (1)

17-29: ⚠️ Potential issue | 🟠 Major

Add keyboard support to project reordering.

Project reordering is pointer-only. The dnd-kit library supports keyboard sorting via KeyboardSensor with sortableKeyboardCoordinates. Keyboard-only users currently cannot reorder projects.

♿ Suggested fix
 import {
   DndContext,
   type DragCancelEvent,
   type CollisionDetection,
+  KeyboardSensor,
   PointerSensor,
   type DragStartEvent,
   closestCorners,
   pointerWithin,
   useSensor,
@@ -23,6 +24,7 @@ import {
 import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
+  sortableKeyboardCoordinates,

Update the sensor setup:

   const projectDnDSensors = useSensors(
     useSensor(PointerSensor, {
       activationConstraint: { distance: 6 },
     }),
+    useSensor(KeyboardSensor, {
+      coordinateGetter: sortableKeyboardCoordinates,
+    }),
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/Sidebar.tsx` around lines 17 - 29, Project reordering
currently only registers PointerSensor so keyboard users cannot reorder items;
update the DnD sensor setup used by DndContext to include KeyboardSensor
alongside PointerSensor by importing KeyboardSensor and
sortableKeyboardCoordinates and adding useSensor(KeyboardSensor, { coordinates:
sortableKeyboardCoordinates }) to the useSensors call (keep the existing
PointerSensor entry) so DndContext uses both sensors and enables keyboard-based
sorting for the sortable list components (e.g., where DndContext, useSensor,
useSensors, PointerSensor, and verticalListSortingStrategy are used).
🧹 Nitpick comments (2)
apps/web/src/store.ts (1)

400-408: Prefer keyed sharing for activities too.

updateOrderedArrayWithStructuralSharing() recreates every later activity when the server inserts or reorders one in the middle. Since activities already have stable ids, using the keyed helper here would preserve identity better and cut avoidable rerenders.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/store.ts` around lines 400 - 408, The current mapActivities uses
updateOrderedArrayWithStructuralSharing which rebuilds later activities on
mid-list inserts; switch to the keyed variant (e.g.,
updateOrderedArrayWithKeyedStructuralSharing) so identity is preserved by key:
call that helper with incoming, previous, mapItem: normalizeActivity and a key
selector that returns each activity's stable id (e.g., item => item.id) so
reorders/inserts won't recreate unaffected activities.
apps/web/src/components/ChatView.tsx (1)

2347-2357: Merge this into the existing thread-reset effect.

This effect repeats the same expandedWorkGroups / plan-sidebar reset that's already happening later on activeThread?.id. Keeping both means two state-reset passes on every thread navigation. Folding the PR-dialog cleanup into the existing effect would keep this logic in one place.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/ChatView.tsx` around lines 2347 - 2357, Duplicate
thread-reset logic: remove this standalone useEffect and fold its cleanup
actions into the existing effect that already depends on activeThread?.id;
specifically, in the effect that currently resets expandedWorkGroups and
plan-sidebar state, also call setPullRequestDialogState(null),
setExpandedWorkGroups({}), handle planSidebarOpenOnNextThreadRef.current (set to
false and call setPlanSidebarOpen(true) or setPlanSidebarOpen(false)
accordingly), and set planSidebarDismissedForTurnRef.current = null so all
thread-reset work lives in the single effect tied to activeThread?.id.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/components/ChatView.tsx`:
- Around line 2435-2459: The issue is that promptSyncPendingRef.current can
remain true across thread switches, causing a delayed debounce to write a
previous thread's draft into the new thread; fix by resetting the pending flag
and forcing rehydration when the thread changes: update the first effect that
currently watches persistedPrompt to also include threadId in its dependencies
and, on thread change (or whenever persistedPrompt !== promptRef.current because
of a new thread), set promptSyncPendingRef.current = false, assign
promptRef.current = persistedPrompt and call setPromptState(persistedPrompt) and
setHasPromptText(persistedPrompt.trim().length > 0) so the new thread is
immediately hydrated before the debounce in the second effect (which uses
setComposerDraftPrompt, prompt, persistedPrompt, threadId) can run.

In `@apps/web/src/components/Sidebar.tsx`:
- Around line 1945-1959: The chevron rotation uses project.expanded but
Collapsible is opened by the effective state (project.expanded ||
isThreadSearchFiltering), so compute a single effectiveOpen (e.g. const
effectiveOpen = project.expanded || isThreadSearchFiltering) and use that for
both the Collapsible open prop and the chevron/icon rotation class instead of
project.expanded; update the same pattern where the icon checks expansion (also
around the code near lines 1977-1980) inside SortableProjectItem/Collapsible
render to keep visual state consistent with hasActiveThreadSearch and
expandedThreadListsByProject.
- Around line 1924-1928: The SortableContext.items list is out of sync with the
rendered project list because projects.map includes all IDs while the render
returns null for projects filtered out by thread search; update the code to
compute a single visibleProjects array (e.g., const visibleProjects =
projects.filter(p => matchesThreadSearch(p))) and use visibleProjects for both
SortableContext items and the map render (replace projects.map(...) with
visibleProjects.map(...)), or alternatively detect the thread-search-active flag
and disable reordering by not rendering SortableContext (or passing an empty
items array) while the search is active; ensure the unique symbols referenced
are SortableContext, projects.map, and the render callback so the items/order
remain identical.

---

Outside diff comments:
In `@apps/web/src/components/ChatView.tsx`:
- Around line 5637-5650: MessagesTimeline accepts expandedWorkGroups and
onToggleWorkGroup but never uses them: update the component so the work-row
renderer consults expandedWorkGroups to decide whether to render each group's
entries (e.g., only render groupedEntries[item] when expandedWorkGroups has that
group id) and add a toggle control that calls onToggleWorkGroup(groupId) to flip
expansion; locate the groupedEntries creation and the work-row renderer inside
MessagesTimeline and change the rendering loop to conditionally map entries
based on expandedWorkGroups and wire the toggle control to onToggleWorkGroup.
- Around line 3848-3866: The handler onPromptChange updates
composerCursorRef.current but not the composerCursor state used by
ComposerPromptEditor; update the state as well to keep the editor caret in sync.
After setting composerCursorRef.current = nextCursor, call the composer cursor
state setter (e.g., setComposerCursor) and only update when the value actually
changes (e.g., setComposerCursor(cur => cur === nextCursor ? cur : nextCursor))
so the component receives the new cursor value.

In `@apps/web/src/components/Sidebar.tsx`:
- Around line 1153-1157: The calls to deleteThread (which invoke
api.orchestration.dispatchCommand with type "thread.delete" and commandId
newCommandId()) can reject and are currently awaited without try/catch; wrap
each await of deleteThread in a try/catch, show the error via the existing
toast/error UI (toast or toast.error) when dispatchCommand fails, and only
remove IDs from selection or clear selection for the threads that actually
succeeded (collect successfully-deleted threadIds from the resolved calls in the
bulk path and update selection based on that set rather than clearing blindly).
Ensure you apply this pattern to both single-delete and bulk-delete callers (the
instances around deleteThread at the shown diff and the other occurrences
mentioned).

In `@apps/web/src/store.ts`:
- Around line 75-92: The persistState function is currently writing empty
project arrays before hydration and clobbering user state; modify initialization
so legacy key cleanup is separated from actual persisted-state writes and gate
writes until threadsHydrated is true (or equivalent hydration flag). Concretely,
extract the legacy cleanup loop into a new cleanupLegacyPersistedState function
and call that during the mount effect (e.g., in StoreProvider) instead of
calling persistState(useStore.getState()) on startup; keep persistState (and any
calls to it) from writing expandedProjectCwds/projectOrderCwds unless
threadsHydrated is true or projects have been populated (syncServerReadModel
completed), and ensure LEGACY_PERSISTED_STATE_KEYS removal only runs once via
legacyKeysCleanedUp.

---

Duplicate comments:
In `@apps/server/src/provider/Layers/ProviderService.ts`:
- Around line 471-487: The current implementation swallows failures from
directory.listThreadIds() by catching the cause and returning an empty array,
causing callers to mistake a listing error for "no bindings"; instead, remove or
narrow the Effect.catchCause that wraps directory.listThreadIds() so that
listThreadIds() errors propagate, while keeping the per-binding fallback around
directory.getBinding(threadId) (the Effect.orElseSucceed for each getBinding) to
tolerate missing individual bindings; in practice, locate the persistedBindings
computation that calls directory.listThreadIds().pipe(...) and change it to let
listThreadIds() failures surface (i.e., do not map them to []), preserving only
the per-thread getBinding fallback.

In `@apps/web/src/components/Sidebar.tsx`:
- Around line 17-29: Project reordering currently only registers PointerSensor
so keyboard users cannot reorder items; update the DnD sensor setup used by
DndContext to include KeyboardSensor alongside PointerSensor by importing
KeyboardSensor and sortableKeyboardCoordinates and adding
useSensor(KeyboardSensor, { coordinates: sortableKeyboardCoordinates }) to the
useSensors call (keep the existing PointerSensor entry) so DndContext uses both
sensors and enables keyboard-based sorting for the sortable list components
(e.g., where DndContext, useSensor, useSensors, PointerSensor, and
verticalListSortingStrategy are used).

---

Nitpick comments:
In `@apps/web/src/components/ChatView.tsx`:
- Around line 2347-2357: Duplicate thread-reset logic: remove this standalone
useEffect and fold its cleanup actions into the existing effect that already
depends on activeThread?.id; specifically, in the effect that currently resets
expandedWorkGroups and plan-sidebar state, also call
setPullRequestDialogState(null), setExpandedWorkGroups({}), handle
planSidebarOpenOnNextThreadRef.current (set to false and call
setPlanSidebarOpen(true) or setPlanSidebarOpen(false) accordingly), and set
planSidebarDismissedForTurnRef.current = null so all thread-reset work lives in
the single effect tied to activeThread?.id.

In `@apps/web/src/store.ts`:
- Around line 400-408: The current mapActivities uses
updateOrderedArrayWithStructuralSharing which rebuilds later activities on
mid-list inserts; switch to the keyed variant (e.g.,
updateOrderedArrayWithKeyedStructuralSharing) so identity is preserved by key:
call that helper with incoming, previous, mapItem: normalizeActivity and a key
selector that returns each activity's stable id (e.g., item => item.id) so
reorders/inserts won't recreate unaffected activities.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4f6928f5-0f8e-4808-be12-d11b045effab

📥 Commits

Reviewing files that changed from the base of the PR and between 7623f4c and f23e5e0.

📒 Files selected for processing (7)
  • apps/server/src/git/Layers/GitManager.ts
  • apps/server/src/provider/Layers/ProviderService.ts
  • apps/web/src/components/ChatView.tsx
  • apps/web/src/components/PullRequestThreadDialog.tsx
  • apps/web/src/components/Sidebar.tsx
  • apps/web/src/lib/gitReactQuery.ts
  • apps/web/src/store.ts

Comment on lines +1924 to +1928
<SortableContext
items={projects.map((project) => project.id)}
strategy={verticalListSortingStrategy}
>
{projects.map((project) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

wc -l apps/web/src/components/Sidebar.tsx

Repository: aaditagrawal/t3code

Length of output: 103


🏁 Script executed:

sed -n '1900,1950p' apps/web/src/components/Sidebar.tsx

Repository: aaditagrawal/t3code

Length of output: 2610


🏁 Script executed:

sed -n '1937,1960p' apps/web/src/components/Sidebar.tsx

Repository: aaditagrawal/t3code

Length of output: 1496


🏁 Script executed:

# Let's see more context around the entire map function to understand the structure better
sed -n '1924,1980p' apps/web/src/components/Sidebar.tsx

Repository: aaditagrawal/t3code

Length of output: 3518


🌐 Web query:

dndkit sortable context items alignment with rendered items documentation

💡 Result:

In dnd-kit Sortable, the items prop you pass to <SortableContext> must be the same set of item IDs you render, and in the exact same order you render them—otherwise sorting math can break and you’ll get “unexpected results.” This is explicitly called out in the docs: “It’s important that the items prop … be sorted in the same order in which the items are rendered.” [1]

What to do (per docs pattern):

  • Keep a single ordered list of IDs in state (e.g. const [items, setItems] = useState([...ids]))
  • Render by mapping that same items array
  • Pass that same items array to <SortableContext items={items}> [1]

Also, when using the React hook API, useSortable uses an item’s position in the list (via index) to determine ordering/placement. So if your rendered order and “context order” diverge (filtering, grouping, conditional rendering, different sort), things won’t align. [2]

Docs: Sortable preset / SortableContext [1] and useSortable API (id + index) [2].

[1] dnd-kit docs – Sortable preset / SortableContext (ordering requirement)
[2] dnd-kit docs – useSortable (uses index for list position)


Keep SortableContext.items aligned with the rendered project list.

When thread search is active, projects with no matching threads return null from the render function, but SortableContext.items still includes all project IDs. dndkit requires items and rendered sortables to be the same set in the same order; misalignment breaks index/collision calculations and produces unexpected sorting behavior. Either create a single visibleProjects list for both SortableContext.items and the map render, or disable reordering while thread search is active.

Also applies to: 1937-1943

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/Sidebar.tsx` around lines 1924 - 1928, The
SortableContext.items list is out of sync with the rendered project list because
projects.map includes all IDs while the render returns null for projects
filtered out by thread search; update the code to compute a single
visibleProjects array (e.g., const visibleProjects = projects.filter(p =>
matchesThreadSearch(p))) and use visibleProjects for both SortableContext items
and the map render (replace projects.map(...) with visibleProjects.map(...)), or
alternatively detect the thread-search-active flag and disable reordering by not
rendering SortableContext (or passing an empty items array) while the search is
active; ensure the unique symbols referenced are SortableContext, projects.map,
and the render callback so the items/order remain identical.

- Reset promptSyncPendingRef on thread change to prevent cross-thread
  draft leakage during debounce window
- Disable SortableContext during thread search to keep items aligned
  with rendered projects
- Use effective open state for project chevron when search forces
  collapsible open
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.