diff --git a/.codex/artifacts/qa/codex-auth-refresh-and-send.md b/.codex/artifacts/qa/codex-auth-refresh-and-send.md new file mode 100644 index 0000000000..e320c6130c --- /dev/null +++ b/.codex/artifacts/qa/codex-auth-refresh-and-send.md @@ -0,0 +1,126 @@ +# Codex Auth Refresh And Send QA + +Date: 2026-04-07 +Repo: `/Users/canal/.codex/worktrees/1cce/t3code` +Desktop state: `~/.t3/userdata` +Build under test: local desktop rebuild from this worktree + +## Scope + +Verify the reported auth-refresh/send failure: + +1. The visible provider refresh control should complete instead of spinning forever. +2. A resend on a previously errored Codex thread should recover instead of inheriting the stale `Quota exceeded` session state. +3. A brand-new Codex thread should still send and receive a response normally. + +## Inventory + +- Visible settings refresh on the running desktop app +- Existing errored thread recovery via the live orchestration websocket +- Fresh thread send via the live orchestration websocket + +## Environment + +- Desktop app launched with `bun run start:desktop:main-state` +- Live ports from the patched launch: + - `http://127.0.0.1:61902` + - `http://127.0.0.1:61903` +- Codex provider reported `Authenticated · ChatGPT Pro Subscription` + +## Results + +### 1. Visible refresh control + +Status: Passed + +Steps: + +1. Opened `http://127.0.0.1:61903/settings/general` +2. Clicked `Refresh provider status` +3. Waited for the checked timestamp to update + +Evidence: + +- Page text updated to `Checked just now` +- Provider row still showed `Codex v0.118.0` +- Auth label still showed `Authenticated · ChatGPT Pro Subscription` +- Refresh button was re-enabled after completion + +### 2. Existing errored thread recovery + +Status: Passed + +Target thread: + +- Thread id: `a7172a15-7002-4de4-8942-221f1ce58f9c` +- Existing title: `test123` +- Previous persisted state before retry: + - session status: `error` + - last error: `Quota exceeded. Check your plan and billing details.` + +Action: + +- Dispatched a new `thread.turn.start` against that same thread with: + - user text: `Reply with exactly OK. QA_AUTH_RETRY_1775590365845` + +Observed outcome: + +- Provider log showed a fresh session restart: + - `session/connecting` + - `session/threadOpenRequested` with `Attempting to resume thread 019d6909-55e7-7e00-adb6-ef516eea229c.` + - `session/threadOpenResolved` +- Persisted session row moved to: + - status: `ready` + - last_error: `NULL` + - updated_at: `2026-04-07T19:32:56.517Z` +- Persisted messages now include: + - user: `Reply with exactly OK. QA_AUTH_RETRY_1775590365845` + - assistant: `OK` + +Interpretation: + +- This is the exact stale-session recovery path we needed. +- The old errored thread no longer stays poisoned after a resend. + +### 3. Fresh thread send + +Status: Passed + +Target thread: + +- Project id: `2dd348e7-e576-411c-98ba-dd161318420a` (`t3code`) +- New thread id: `c244983d-5fb8-44dc-9941-0f63d57429d4` +- Title: `QA_AUTH_FRESH_1775590432926` + +Action: + +1. Created a new thread in `t3code` +2. Sent: + - `Reply with exactly OK. QA_AUTH_FRESH_1775590432926` + +Observed outcome: + +- Provider log showed a fresh Codex thread start +- Persisted session row ended in: + - status: `ready` + - last_error: `NULL` + - updated_at: `2026-04-07T19:34:02.480Z` +- Persisted messages now include: + - user: `Reply with exactly OK. QA_AUTH_FRESH_1775590432926` + - assistant: `OK` + +## Ship Readiness + +Passed: + +- Visible provider refresh control +- Existing errored-thread resend recovery +- Fresh thread send/response + +Not directly verified: + +- The exact same click path inside the Electron sidebar/composer chrome, because this desktop shell was still rendering the empty `No projects yet` sidebar state in automation even while the underlying state DB and websocket server were healthy. + +Residual risk: + +- There may still be a separate Electron/sidebar state hydration issue, but the auth refresh path and the Codex send/recovery path both behaved correctly against the patched backend/runtime. diff --git a/.codex/artifacts/qa/codex-import-durable-thread.md b/.codex/artifacts/qa/codex-import-durable-thread.md new file mode 100644 index 0000000000..e2508f60f8 --- /dev/null +++ b/.codex/artifacts/qa/codex-import-durable-thread.md @@ -0,0 +1,53 @@ +# Codex Import Durable Thread QA + +Date: 2026-04-17 +Branch: `codex/rebuild-feature-rollout` + +## Environment + +- Branch-backed local dev server in Chrome via the Computer Use plugin +- Target project: `t3-qa-codex-import-project` +- Full verification gate rerun on this checkpoint: + - `bun fmt` + - `bun lint` + - `bun typecheck` + - `bun run test` + - `bun run build` + - `bun run build:desktop` + +## Targeted automated coverage + +- `cd apps/web && bun x vitest run --config vitest.browser.config.ts src/components/ChatView.browser.tsx -t "imports a Codex transcript into a durable thread from the global shortcut"` +- `cd apps/server && bun x vitest run src/codexImport/Layers/CodexImport.test.ts` + +## Manual QA + +### Scenario 1: Import a local Codex session into a durable ClayCode thread + +1. Opened the branch-backed app in Chrome on a fresh project-backed thread context. +2. Triggered `Cmd+Shift+I`. +3. Verified the `Import from Codex` dialog opened and loaded live local Codex sessions. +4. Selected a real local Codex session. +5. Chose the target project `t3-qa-codex-import-project`. +6. Confirmed the import. +7. Verified the app navigated to a durable thread route (`/$environmentId/$threadId`) instead of a `/draft/...` route. +8. Verified the imported transcript content rendered in the message timeline. +9. Verified the thread activity showed the import provenance for the Codex session. + +Result: pass + +### Scenario 2: Reopen the import dialog after import + +1. Reopened `Import from Codex` on the imported thread. +2. Verified the imported session row showed the `Imported` pill in the list. +3. Verified the preview pane showed `Import status: Already imported`. +4. Verified the primary action label changed to `Open imported thread`. +5. Confirmed the action and verified the app stayed on the already-imported durable thread rather than creating a duplicate thread. + +Result: pass + +## Observations + +- The rebuild now matches the intended durable-history model: importing creates a real local thread instead of just prefilling a draft. +- Repeat imports are idempotent at the UX level: the UI clearly marks the existing imported thread and reopens it instead of duplicating content. +- The earlier stale React Query state bug is fixed. After importing, the list row, preview pane, and action button all update consistently without requiring a full dialog refresh. diff --git a/.codex/artifacts/qa/deep-search-and-project-search.md b/.codex/artifacts/qa/deep-search-and-project-search.md new file mode 100644 index 0000000000..d20b05a62f --- /dev/null +++ b/.codex/artifacts/qa/deep-search-and-project-search.md @@ -0,0 +1,56 @@ +# Deep Search + Project Search QA + +Date: 2026-04-17 +Branch: `codex/rebuild-feature-rollout` + +## Terminal gate + +- `bun fmt` +- `bun lint` +- `export PATH="$HOME/.nvm/versions/node/v24.13.1/bin:$PATH" && bun typecheck` +- `export PATH="$HOME/.nvm/versions/node/v24.13.1/bin:$PATH" && bun run test` +- `export PATH="$HOME/.nvm/versions/node/v24.13.1/bin:$PATH" && bun run build` +- `export PATH="$HOME/.nvm/versions/node/v24.13.1/bin:$PATH" && bun run build:desktop` + +Result: + +- passed +- `bun lint` still reports the same 8 pre-existing warnings and 0 errors + +## Targeted automated coverage + +- `cd apps/web && bun run test -- keybindings.test.ts globalThreadSearch.test.ts projectFolderSearch.test.ts quickThreadSearch.test.ts` +- `cd apps/web && bun run test:browser -- src/components/GlobalThreadSearchDialog.browser.tsx src/components/ProjectFolderSearchDialog.browser.tsx src/components/QuickThreadSearchDialog.browser.tsx` +- `cd apps/web && bun run test:browser -- src/components/GlobalThreadSearchDialog.browser.tsx src/components/ProjectFolderSearchDialog.browser.tsx src/components/QuickThreadSearchDialog.browser.tsx src/components/ChatView.browser.tsx -t "global shortcut"` + +Result: + +- passed + +## Computer Use QA + +Environment: + +- Chrome against local dev server at `http://localhost:5737` + +Checks: + +1. Opened the command palette from the live app. +2. Confirmed `Search all threads` was present as a top-level action. +3. Opened `Search All Threads`. +4. Searched for `new` and verified the current `New thread` result appeared with title highlighting. +5. Closed the dialog and reopened the command palette. +6. Confirmed `Search project folders` was present as a top-level action. +7. Opened `Search Project Folders`. +8. Verified the live sidebar project appeared as a selectable result. +9. Selected the result and confirmed the app navigated to a fresh draft-thread route (`/draft/...`). + +Result: + +- passed for both dialog flows and the project-selection navigation path + +Notes: + +- Direct shortcut injection through Computer Use remained browser-sensitive, so the authoritative live verification came from opening the dialogs through the app surface rather than relying only on raw modifier chords. +- The live command palette showed older shortcut hints for `threads.searchAll` / `projects.search`. That was not a checked-in code regression: this machine has saved overrides in `~/.t3/userdata/keybindings.json` and `~/.t3/dev/keybindings.json` mapping those commands to older shortcuts. The checked-in defaults, docs, and tests now expect `Cmd/Ctrl+Alt+F` and `Cmd/Ctrl+Alt+P`. +- Queue-row `Alt+Up/Down` parity was already covered in `apps/web/src/components/QueuedFollowUpsPanel.browser.tsx`; a fresh manual live pass for that interaction was blocked here because the local Codex provider on this dev server timed out before a runnable queued-follow-up state could be created. diff --git a/.codex/artifacts/qa/electron-desktop-rebuild.md b/.codex/artifacts/qa/electron-desktop-rebuild.md new file mode 100644 index 0000000000..53f987483c --- /dev/null +++ b/.codex/artifacts/qa/electron-desktop-rebuild.md @@ -0,0 +1,65 @@ +# Electron Desktop QA + +Date: 2026-04-17 +Branch: `codex/rebuild-feature-rollout` +App: `ClayCode (Alpha)` built desktop bundle +State dir: `/tmp/t3-electron-qa-state` + +## Build + launch + +- Passed `bun run build:desktop` +- Passed `bun run test:desktop-smoke` +- Launched the built Electron app via `apps/desktop` `bun run start` + +## QA inventory + +- Desktop app launches from the built Electron bundle +- Project onboarding works in Electron +- Draft-thread creation works in Electron +- Snippet picker opens and inserts a snippet into the composer +- Quick thread search opens from the keyboard shortcut and navigates to a thread +- Desktop Connections settings render the Tailnet access row and network-access confirmation dialog +- Identify any Electron-specific blockers for provider-backed turn flows + +## Passed + +- Added the repo as a project from the desktop `Add project` flow using `/Users/canal/.codex/worktrees/28c4/t3code` +- Verified the app created a fresh draft thread route on project open +- Switched the provider selector from unavailable Codex to `Claude Sonnet 4.6` +- Opened the snippet picker from the composer and confirmed built-in snippets render in the dialog +- Filtered/selected the built-in `Write Tests` snippet and verified it inserted into the composer +- Opened Quick Thread Search with `Cmd+Shift+F` +- Verified the Quick Thread Search dialog rendered with the expected search/help copy +- Searched for the existing thread and confirmed navigation back into that thread route +- Created another new draft thread from the sidebar and verified the route changed to a new `/draft/` +- Opened `Settings -> Connections` +- Verified the `Tailnet access` row rendered with the live Tailnet hostname and IP +- Toggled `Network access` and verified the confirmation dialog opened with `Cancel` and `Restart and enable` +- Cancelled the dialog successfully and confirmed the settings page returned to its prior state + +## Hotkeys + +- `Cmd+Shift+F`: passed. Opened Quick Thread Search and navigated back into the existing thread route from a fresh draft thread. +- `Cmd+Shift+S`: passed. Opened the snippet picker in Electron and exposed the built-in snippet list; I also verified snippet insertion into the composer in the same desktop run. +- `Cmd+[` / `Cmd+]`: attempted against real draft/thread route history, but no route change was observed in the later Electron session. Because Computer Use key injection became inconsistent for command shortcuts in that session, I am treating this result as inconclusive rather than claiming a definite product regression. +- `Tab` while a turn is running: passed. I typed `Then list the exact Queue + Steer functions and where each one is called.` during an active `GPT-5.4` run, pressed `Tab`, and verified the queued panel appeared with `1 queued follow-up`, `Ready to dispatch in order.`, and the expected queued row actions. +- `Enter` while a turn is running: passed. I then typed `Also explain how Steer with Enter differs from Queue with Tab.` during the same live run, pressed `Enter`, and verified it posted immediately as a new live user turn instead of going back into the queue. +- `Shift+Tab` composer mode toggle: not separately re-verified in Electron once shortcut delivery became inconsistent; this still needs a clean follow-up pass if we want explicit desktop-only evidence for every shortcut. + +## Blocked / not fully verified + +- Codex provider-backed turn execution is blocked in this Electron run because the desktop app reports `Codex CLI is not authenticated. Run \`codex login\` and try again.` +- Claude-backed turn execution is also blocked for full happy-path validation because the attempted message failed immediately with `Credit balance is too low` +- I was able to re-verify live-response behavior in Electron with `GPT-5.4`, including: + - successful response rendering from a live provider-backed turn + - queueing a follow-up during an active response with `Tab` + - steering immediately during an active response with `Enter` + - queued follow-up auto-dispatch after the active turn settled +- I did not re-verify queued follow-up save-from-queue behavior in this Electron pass. + +## Notes + +- The Electron shell, routing, project onboarding, settings surfaces, snippet picker, quick thread search, and draft-thread navigation all behaved correctly in the built desktop app +- Live provider-backed turns are workable in Electron through `GPT-5.4`, even though the Codex and Claude providers remained blocked in this environment for separate auth/quota reasons +- In the successful queue/steer pass, the queued panel disappeared after dispatch and both follow-up user turns were visible in-thread while the assistant continued working, which is the expected user-facing behavior +- Command-shortcut delivery through Computer Use became inconsistent later in the session, so the sidebar-history hotkeys need one cleaner follow-up check before I would mark them definitively passed or failed in Electron diff --git a/.codex/artifacts/qa/final-electron-qa-2026-05-02.md b/.codex/artifacts/qa/final-electron-qa-2026-05-02.md new file mode 100644 index 0000000000..fc0bf3f03c --- /dev/null +++ b/.codex/artifacts/qa/final-electron-qa-2026-05-02.md @@ -0,0 +1,55 @@ +# Final Electron QA - 2026-05-02 + +Branch: `codex/rebuild-feature-rollout` + +Workspace: `/Users/canal/.codex/worktrees/28c4/t3code` + +Electron QA state: + +- App launched from branch with `T3CODE_HOME=/tmp/t3code-electron-qa-home-final-2`. +- QA project: `/tmp/qa-project-claycode-replay`. +- App title and sidebar masthead rendered as `ClayCode (Alpha)` / `ClayCode ALPHA`. +- Electron rebuild completed before the final manual pass, so the visible app used current branch assets. + +Manual QA performed with Computer Use: + +- Added `/tmp/qa-project-claycode-replay` from the desktop Add project flow in a clean profile. +- Sent a live GPT-5.4 thread seed and verified a rendered model response. +- Verified `cmd+k`, `cmd+shift+s`, `cmd+shift+k`, `cmd+shift+f`, `cmd+alt+f`, and `cmd+alt+p` in Electron after the physical-key fallback fix for Option-modified macOS keys. +- Verified sidebar traversal hotkeys in Electron: `cmd+shift+]`, `cmd+shift+[`, `alt+Down`, `alt+Up`, `alt+shift+Down`, and `alt+shift+Up`. +- Verified Settings rebrand text and Tailscale/network-access confirmation copy in Electron. +- Verified Queue + Steer in Electron with a controlled `sleep 60` run: + - A running turn showed the stop button and active working state. + - The composer displayed `Steer` and `Queue` actions while the turn was active. + - Clicking `Queue` created the `1 queued follow-up` panel with `Alt+Up/Down` and `Alt+Shift+Up/Down` row guidance. + - The queued follow-up auto-dispatched after the running turn settled and rendered `queued follow-up processed`. + - Clicking `Steer` inserted a live steer message during the active turn; the model acknowledged it before the queued follow-up ran. +- Verified controlled command execution path during Queue + Steer: model ran `sleep 20` and `sleep 60`, then rendered the requested completions. + +Issues found and fixed during this QA pass: + +- Sidebar masthead still showed legacy `T3 Code`; fixed by rendering `APP_BASE_NAME` in `Sidebar`. +- macOS Option-modified direct hotkeys such as `cmd+alt+f` and `cmd+alt+p` did not resolve reliably; fixed by adding `event.code` letter aliases in keybinding resolution. +- Disconnect/reconnect toast copy still hardcoded `T3 Server`; fixed to use `ClayCode Server` via `APP_BASE_NAME`. +- Electron launcher attempted the renamed launcher path during local start; fixed by only using the renamed launcher when `T3CODE_USE_RENAMED_ELECTRON_LAUNCHER=1`. + +Additional gap QA performed after the final Electron session: + +- Verified GitHub PR pill rendering in Electron with a local project on branch `codex/fake-pr-qa`, a fake GitHub remote, a local upstream tracking ref, and a temporary fake `gh` shim that returned open PR `#4242`. The header action changed to `View PR`, and the sidebar thread row exposed `#4242 PR open: QA fake PR pill`. +- Verified disconnect state in Electron by repeatedly killing the embedded server process. The visible toast and accessibility tree both showed `Disconnected from ClayCode Server`, retry countdown text, and a `Retry now` button. +- Found and fixed a reconnect-success edge case: the success toast could be skipped if WebSocket recovery passed through an intermediate `connecting` state before `connected`. The recovered toast now keys off a remembered prior disconnect timestamp instead of only the immediate previous UI state. +- Rebuilt and relaunched Electron from the patched branch, repeated the server-kill recovery test, and visually verified `Reconnected to ClayCode Server` with the disconnect/reconnect timestamps. + +Residual risks / not fully re-exercised manually: + +- GitHub PR pills were verified with a controlled local `gh` shim rather than a live GitHub API response, so the UI path is covered but live credential/network behavior remains dependent on the real `gh` environment. + +Automated gates to run after this artifact: + +- `bun fmt` +- `bun lint` +- `bun typecheck` +- `bun run test` +- `bun run build` +- `bun run build:desktop` +- `bun run test:desktop-smoke` diff --git a/.codex/artifacts/qa/queue-steer-deep-2026-05-02.md b/.codex/artifacts/qa/queue-steer-deep-2026-05-02.md new file mode 100644 index 0000000000..9148a3f581 --- /dev/null +++ b/.codex/artifacts/qa/queue-steer-deep-2026-05-02.md @@ -0,0 +1,53 @@ +# Queue + Steer Deep QA - 2026-05-02 + +## Scope + +Exhaustive-ish Electron QA for queue and steer behavior on `codex/rebuild-feature-rollout`, focused on active-turn queueing, queue row controls, keyboard shortcuts, steer behavior, and a reproduced send-now gating bug. + +## Environment + +- App: Electron production build from `bun run start:desktop` +- Profile: `/tmp/t3code-electron-qa-home-queue-steer-deep` before the fix, `/tmp/t3code-electron-qa-home-queue-steer-fixed` after the fix, `/tmp/t3code-electron-qa-home-queue-steer-clear` for final clear-all QA +- QA project: `/tmp/qa-project-queue-steer-deep` +- Browser backend: Computer Use driving the Electron window + +## Manual Scenarios + +| Scenario | Result | Evidence | +| ------------------------------------------------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Queue via `Tab` during an active `sleep 90` turn | Failed before fix | Panel showed `Ready to dispatch in order.` and enabled `Send queued follow-up now` while the turn was still running. Clicking it sent the queued item immediately instead of waiting. | +| FIFO auto-dispatch after active turn settles | Pass | During `sleep 75`, queued A by `Tab` and B by Queue button. After `SECOND_BASELINE_DONE`, A dispatched and returned `A_DONE`, then B dispatched and returned `B_DONE`. | +| Row move buttons | Pass | During `sleep 120`, queued C and D, moved D up via row button, and observed D become `Next up`. | +| `Alt+Up` / `Alt+Down` row focus | Pass | Focus moved between queued rows in the Electron accessibility tree. | +| `Alt+Shift+Up` / `Alt+Shift+Down` reorder | Pass | Focused row reordered up/down and the visible `Next up` row changed accordingly. | +| Edit queued item | Pass | Edited C to `Queue item C edited: reply exactly C_EDITED_DONE.` and saved; edited text remained in the queued row. | +| Save queued item as snippet | Pass | `Save queued follow-up as snippet` displayed `Saved to snippets`. | +| Remove queued item | Pass | Removed D, queue count dropped from 2 to 1, and only edited C remained. | +| Edited queued item auto-dispatch | Pass | After `THIRD_BASELINE_DONE`, only edited C dispatched and returned `C_EDITED_DONE`; removed D did not dispatch. | +| Fixed running-turn send-now gate | Pass after fix | During `sleep 45`, queued follow-up panel showed `Waiting for the current turn to settle.` and `Send queued follow-up now` was disabled. After `FIXED_BASELINE_DONE`, the queued item dispatched and returned `FIXED_QUEUE_DONE`. | +| Steer during active turn | Pass | During `sleep 60`, sent steering message as a normal user bubble while the turn was Working; no queue panel appeared, and final response used steered marker `CLEAN_STEER_DONE`. | +| Clear all during active turn | Pass | During `sleep 180`, queued A and B, observed `2 queued follow-ups` and disabled send-now buttons, clicked `Clear all`, and the queue panel disappeared while the baseline was still Working. After settle, only `CLEAR_BASELINE_DONE` appeared; neither `BAD_CLEAR_A` nor `BAD_CLEAR_B` dispatched. | + +## Reproduced Bug + +Before the fix, the queue panel used a weaker `canSendNow` predicate than auto-dispatch. While a turn was `running`, the panel still said `Ready to dispatch in order.` and the `Send queued follow-up now` button was enabled. Clicking it consumed the queued item and dispatched it immediately, effectively turning a queued follow-up into a steer-like message. + +## Fix Verification + +- `ChatView` now gates queued dispatch on the same active-turn blockers used by auto-dispatch: running phase, local send busy, reconnecting, send-in-flight, active queue dispatch, pending approval, pending user input, and pending progress. +- `dispatchQueuedTurn` also refuses to dispatch while the thread is running or blocked by pending approval/input/progress. +- Browser regression test now asserts that a running turn shows `Waiting for the current turn to settle.` and disables `Send queued follow-up now`. +- Browser regression test now covers clearing multiple queued follow-ups during a running turn, settling the thread, and verifying that no cleared follow-up dispatches. + +## Automated Checks + +- `bun run --cwd apps/web test:browser src/components/ChatView.browser.tsx -t "shows queue controls during a running turn"`: passed +- `bun run --cwd apps/web test:browser src/components/ChatView.browser.tsx -t "queued follow-ups"`: passed +- `bun fmt`: passed +- `bun lint`: passed with pre-existing warnings only +- `bun typecheck`: passed +- `bun run test`: passed, 9 turbo tasks successful + +## Residual Risks + +- I did not repeat the full WiFi/server-kill universal regression suite in this pass; this QA pass was intentionally scoped to queue and steer behavior. diff --git a/.codex/artifacts/qa/queue-steer-switching-2026-05-02.md b/.codex/artifacts/qa/queue-steer-switching-2026-05-02.md new file mode 100644 index 0000000000..2b0a257810 --- /dev/null +++ b/.codex/artifacts/qa/queue-steer-switching-2026-05-02.md @@ -0,0 +1,72 @@ +# Queue + Steer Switching QA - 2026-05-02 + +## Scope + +Electron QA for queue and steer behavior under fast context switching: multiple threads, switching away mid-run, queue isolation, returning after settle, and steer targeting. + +## Environment + +- App: Electron production build from `bun run start:desktop` +- Profile: `/tmp/t3code-electron-qa-home-queue-switching` +- Follow-up profile: `/tmp/t3code-electron-qa-home-queue-steer-final` +- QA projects: `/tmp/qa-project-queue-switch-a`, `/tmp/qa-project-queue-switch-b` +- Follow-up QA projects: `/tmp/qa-project-queue-final-a`, `/tmp/qa-project-queue-final-b` +- Backend: Computer Use driving Electron +- Branch: `codex/rebuild-feature-rollout` + +## QA Inventory + +- Queue items in thread A while thread A is running, switch to thread B, and verify A queue does not appear in B. +- Start thread B while thread A is still running, queue B follow-ups, and verify B queue stays isolated from A. +- Return to thread A before and after settle; verify queued A follow-ups dispatch only in thread A and in FIFO order. +- Return to thread B before and after settle; verify queued B follow-ups dispatch only in thread B and in FIFO order. +- Send a steer message while one thread is running, switch away immediately, and verify the steer affects only the originating active thread. +- Switch projects while a thread has queued follow-ups and verify project/sidebar context does not leak queue UI. +- Reload or relaunch with queued follow-ups pending if time allows, verifying localStorage queue hydration. + +## Manual Scenarios + +| Scenario | Result | Evidence | +| ----------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Queue A while A is running, then switch to B | Pass | Started thread A with `sleep 75`; queued `A_QUEUE_ONE_DONE` and `A_QUEUE_TWO_DONE`; switched to project/thread B while A was still running. B showed no A queue panel or A queued text. | +| Queue B while A has queued work in the background | Pass | Started thread B with `sleep 90`; queued `B_QUEUE_ONE_DONE`. B showed exactly `1 queued follow-up`, with B text only. A's queued items stayed scoped to A. | +| Return to A after A settles | Pass | On returning to A, the app showed `A_BASE_DONE`, auto-dispatched A's first queued follow-up in A, kept A's second queued follow-up disabled while A's first was active, then completed `A_QUEUE_ONE_DONE` followed by `A_QUEUE_TWO_DONE`. | +| Draft blocks queue auto-dispatch without losing queue | Pass | In B, after `B_BASE_DONE`, a composer draft `B queued second: reply exactly B_QUEUE_TWO_DONE.` was present while the B queue had `B_QUEUE_ONE_DONE` ready. The queued item did not auto-dispatch until manually sent, and the draft stayed intact. | +| Send queued follow-up now while draft exists | Pass | Clicked `Send queued follow-up now` for B's queued first item. It dispatched only `B_QUEUE_ONE_DONE`, preserved the separate B draft, and did not clear or send the draft out of order. | +| Switch away and back with preserved draft | Pass | Switched from B to A and back after queue work completed. B's draft was still present and no A queue state leaked into B. Sending the draft then produced `B_QUEUE_TWO_DONE`. | +| Fast switch between projects with queued work | Pass | During the A/B overlap, sidebar project switching did not leak queued panel state across projects; thread headers and composer state updated to the active project/thread. | +| Steer while switching via Computer Use `set_value` | Blocked | Two attempted live steer-switch runs (`sleep 45` and `sleep 20`) were invalidated because the Computer Use accessibility `set_value` operation on the running composer coincided with a real `thread.turn.interrupt` command in server traces. The command process continued and later completed, but the turn was already marked interrupted, so no user-facing steer result could be trusted. | + +## Follow-up Manual Scenarios + +| Scenario | Result | Evidence | +| ----------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Keyboard-style live steer with immediate switch | Pass | In fresh profile `/tmp/t3code-electron-qa-home-queue-steer-final`, started thread A with `sleep 30` and baseline marker `BASELINE_KEYBOARD_SHOULD_NOT_APPEAR`, typed the steer message using Computer Use `type_text`, clicked `Steer`, immediately switched to project/thread B, returned after settle, and saw final response `STEER_KEYBOARD_SWITCH_DONE`. | +| Verify no hidden interrupt during steer | Pass | Searched the fresh profile logs for `thread.turn.interrupt`; no matches. Provider trace showed the steer user message attached to the original active turn and the turn completed normally with `STEER_KEYBOARD_SWITCH_DONE`. | +| Pending queue survives Electron reload | Pass | Started thread B with `sleep 75`, typed a queued follow-up using Computer Use `type_text`, clicked `Queue`, verified the UI showed `1 queued follow-up`, then used Electron View > Reload while the base turn was still running. After reconnect, the queued item remained pending and auto-dispatched after `QUEUE_PERSIST_BASE_DONE`, producing `QUEUE_PERSIST_AFTER_RELOAD_DONE`. | +| Queue trace after reload | Pass | Provider trace showed base turn completed with `QUEUE_PERSIST_BASE_DONE`, then a second turn started immediately with the queued user message and completed with `QUEUE_PERSIST_AFTER_RELOAD_DONE`. No `thread.turn.interrupt` entries appeared in the fresh profile. | + +## Automated Checks + +| Check | Result | Notes | +| ------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Provider trace review | Pass | Confirmed queue outcomes in Electron UI and confirmed the failed `set_value` steer attempts were interrupted turns, not normal completions. Fresh keyboard-style follow-up pass had no `thread.turn.interrupt` entries and completed normally. | +| `bun run --cwd apps/web test:browser src/components/ChatView.browser.tsx -t "steers immediately"` | Pass | 2 tests passed, 85 skipped. Covers Enter and mod+Enter steer behavior during an active turn. | +| `bun run --cwd apps/web test:browser src/components/ChatView.browser.tsx -t "queues with Tab"` | Pass | 1 test passed, 86 skipped. Covers queueing with Tab during an active turn and auto-dispatch after settle. | +| `bun run --cwd apps/web test:browser src/components/QueuedFollowUpsPanel.browser.tsx` | Pass | 3 tests passed. Covers queue row actions, disabled send-now state, and `Alt+Up`/`Alt+Down` row focus behavior. | +| `bun fmt` | Pass | `oxfmt` completed successfully on 817 files. | +| `bun lint` | Pass | 0 errors; 8 pre-existing warnings remained. | +| `bun typecheck` | Pass | 8 Turbo typecheck tasks successful. | +| `bun run test` | Pass | 9 Turbo test tasks successful; server suite reported 81 files passed / 800 tests passed / 2 skipped, web suite reported 88 files passed / 919 tests passed. | + +## Findings + +- No queue isolation, FIFO, or draft-preservation regressions found in this pass. +- Live steer + immediate project/thread switch works when exercised through keyboard-style input and the real `Steer` button. +- Queue persistence across Electron reload works for a pending follow-up while the base turn is still running. +- Computer Use accessibility `set_value` on the running composer is not a valid QA path for live steer because it can trigger a real interrupt. Future manual QA should use `type_text` plus the real `Steer` / `Queue` buttons instead. + +## Residual Risks + +- The stale `Codex provider status` warning banner (`Codex CLI is installed but failed to run. Timed out while running command.`) was visible in earlier sessions, even while provider turns were otherwise succeeding. It was not reproduced as a queue/steer failure in the fresh follow-up profile. +- The fresh throwaway QA git repos intentionally had no remote, so server traces included expected git remote / PR lookup failures unrelated to queue or steer behavior. diff --git a/.codex/artifacts/qa/queue-steer.md b/.codex/artifacts/qa/queue-steer.md new file mode 100644 index 0000000000..81fc34979c --- /dev/null +++ b/.codex/artifacts/qa/queue-steer.md @@ -0,0 +1,51 @@ +# Queue + Steer QA + +Date: 2026-04-16 +Branch: `codex/rebuild-feature-rollout` + +## Environment + +- Dev server launched with isolated state via `T3CODE_HOME=/tmp/t3-qa-queue-steer-keTEtW` +- Browser QA executed in Google Chrome through the Computer Use plugin +- Terminal gate passed: + - `bun fmt` + - `bun lint` + - `bun typecheck` + - `bun run test` + - `bun run build` + - `bun run build:desktop` + +## Manual QA + +### Scenario 1: queue, edit, and auto-dispatch a follow-up + +1. Opened a fresh thread in the isolated local app. +2. Sent `Summarize queued follow-up handling in ChatView.` +3. While the first response was still running, typed `Then list the exact functions involved.` and pressed `Tab`. +4. Verified the queued panel appeared above the composer with `1 queued follow-up`. +5. Edited the queued item in place to `List the exact Queue + Steer functions and where each one is called.` and saved it. +6. Waited for the active turn to settle. +7. Verified the queued item auto-dispatched without any extra click and appeared as the next user turn in the thread. + +Result: pass + +### Scenario 2: steer immediately during an active run + +1. While the auto-dispatched queued turn was still running, typed `Also explain how Steer with Enter differs from Queue with Tab.` +2. Pressed `Enter`. +3. Verified the message posted immediately as a live user turn instead of being added back into the queue. + +Result: pass + +## Observations + +- The queue panel correctly exposed edit, send-now, remove, and clear-all controls during the queued state. +- Keyboard-first behavior was validated for the marquee shortcuts: + - `Tab` queued a follow-up while a turn was running + - `Enter` steered immediately while a turn was running +- I did not run a full manual tab-order accessibility sweep in this pass; the dedicated browser tests remain the broader regression backstop there. + +## Environment note + +- Launching the app against the default persisted local state under `~/.t3` failed before QA with `SQLiteError: no such column: latest_user_message_at`. +- Queue + Steer feature QA itself passed on the isolated state directory, so this looks like an existing local-state migration issue rather than a regression introduced by this feature. diff --git a/.codex/artifacts/qa/sidebar-recent-and-codex-import.md b/.codex/artifacts/qa/sidebar-recent-and-codex-import.md new file mode 100644 index 0000000000..19df69527c --- /dev/null +++ b/.codex/artifacts/qa/sidebar-recent-and-codex-import.md @@ -0,0 +1,50 @@ +# Sidebar Recent + Codex Import QA + +Date: 2026-04-17 +Branch: `codex/rebuild-feature-rollout` + +## Environment + +- Branch-backed local dev server on `http://localhost:5763` +- Safari QA executed through the Computer Use plugin +- Targeted automated coverage passed: + - `cd apps/web && bun run test -- src/components/Sidebar.logic.test.ts src/localApi.test.ts` + - `cd apps/web && bun run test:browser -- src/components/ChatView.browser.tsx -t "imports a Codex transcript into a new draft thread from the global shortcut"` + +## Manual QA + +### Scenario 1: Sidebar grouped/recent parity + +1. Opened the local branch-backed app in Safari. +2. Verified the sidebar exposes the `Grouped` / `Recent` toggle beside the Projects header. +3. Switched from `Grouped` to `Recent`. +4. Verified the sidebar changed to a recency-bucketed list with a `TODAY` heading. +5. Verified the recent row showed the project label `server` beneath the thread title. +6. Switched back to `Grouped`. +7. Verified the grouped project tree returned with the `server` project row and nested thread row. + +Result: pass + +### Scenario 2: Codex import dialog discovery + session loading + +1. Triggered `Cmd+Shift+I` in Safari. +2. Verified the `Import from Codex` dialog opened. +3. Waited for the dialog to finish loading. +4. Verified the dialog loaded live local Codex history and reported `50 sessions`. +5. Verified the target-project picker defaulted to the current project (`server`). + +Result: pass + +### Scenario 3: Codex import end-to-end through Safari accessibility + +1. Tried selecting a loaded Codex session through the Safari accessibility tree. +2. Tried closing and reopening the dialog, keyboard interaction, and direct row clicks. + +Result: inconclusive + +## Observations + +- The sidebar recent-mode parity is back: date buckets render and the recent row now includes the project label. +- The Codex import dialog is wired and loading real local sessions through the running app. +- The final click-through import step was flaky specifically under Safari + Computer Use because the accessibility tree kept a hidden dialog layer around after interaction, so row selection and dialog dismissal were unreliable in that session. +- The browser test still covers the actual import-to-draft flow end to end, and it passed in the same code state. diff --git a/.codex/artifacts/qa/sidebar-rename-hotkey.md b/.codex/artifacts/qa/sidebar-rename-hotkey.md new file mode 100644 index 0000000000..21887fe8e6 --- /dev/null +++ b/.codex/artifacts/qa/sidebar-rename-hotkey.md @@ -0,0 +1,38 @@ +## Sidebar rename hotkey + +Date: 2026-04-17 +Branch: `codex/rebuild-feature-rollout` + +### Scope + +Verified the restored `Cmd+Shift+R` sidebar rename shortcut on the branch-local Electron dev app. + +### Environment + +- Branch-local desktop dev app launched from `/Users/canal/.codex/worktrees/28c4/t3code` +- Isolated desktop state rooted at `/tmp/t3-sidebar-rename-qa` +- Computer Use attached to the branch-local `ClayCode (Dev)` window after closing an older dev instance from a different worktree that shared the same bundle id + +### Steps + +1. Launched the branch-local desktop dev app with a fresh `T3CODE_HOME`. +2. Added `/Users/canal/.codex/worktrees/28c4/t3code` as a project. +3. Created a new thread by sending `rename hotkey qa`. +4. Pressed `Cmd+Shift+R`. +5. Confirmed the active thread row switched into inline rename mode with the existing title selected. +6. Replaced the title with `Sidebar rename hotkey pass`. +7. Pressed `Enter` to commit the rename. + +### Result + +Pass. + +- `Cmd+Shift+R` opened inline rename on the active sidebar thread +- the current title was selected and editable immediately +- pressing `Enter` committed the rename +- the new title propagated to both the sidebar row and the thread header + +### Notes + +- A direct browser-based shortcut pass was misleading because Chrome reserves `Cmd+Shift+R` for hard reload. The reliable manual verification path for this shortcut is the desktop app. +- An older dev Electron app from another worktree was initially stealing the Computer Use attachment because both apps shared the same bundle id. Closing that older process fixed the QA environment. diff --git a/.codex/artifacts/qa/skill-picker.md b/.codex/artifacts/qa/skill-picker.md new file mode 100644 index 0000000000..433cceb5f0 --- /dev/null +++ b/.codex/artifacts/qa/skill-picker.md @@ -0,0 +1,38 @@ +# Skill Picker QA + +Date: 2026-04-17 +Branch: `codex/rebuild-feature-rollout` + +## Scope + +Manual QA for the rebuilt composer skill picker using Computer Use on the local dev app. + +## Environment + +- Local dev app: `http://127.0.0.1:5736` +- Server: `http://127.0.0.1:13776` +- Browser used for final pass: Google Chrome + +## Result + +Passed. + +## What I verified + +- Opened the command palette with `Cmd+K` and confirmed the new `Open skills` action was present with shortcut hint `Cmd+Shift+K`. +- Selected `Open skills` from the command palette and confirmed the dedicated `Skills` dialog opened. +- Confirmed the dialog loaded real skill results from Codex home and showed the count banner (`50 skills (truncated)` in this environment). +- Filtered the dialog by typing `skill-picker-qa` and confirmed the result narrowed to the temporary QA skill. +- Selected the filtered skill result and confirmed the composer was populated with the expected reference block: + - `## Use skill: skill-picker-qa-home` + - description line + - blank line + - `Read the full instructions from: ...` +- Reopened the picker directly with `Cmd+Shift+K` from the composer and confirmed the shortcut opened the dialog again. +- Pressed `Esc` and confirmed the dialog visually dismissed back to the composer. + +## Notes + +- Safari could open the flow, but its accessibility tree became stale around the modal, so I switched to Chrome for the final evidence. +- I also verified the backend search logic directly from the branch-local server module, which returned both workspace and Codex-home skills for the current repo cwd. +- Temporary QA-only skills were created for this pass and removed afterward, so they are not part of the committed feature diff. diff --git a/.codex/artifacts/qa/snippet-picker.md b/.codex/artifacts/qa/snippet-picker.md new file mode 100644 index 0000000000..b60ff43082 --- /dev/null +++ b/.codex/artifacts/qa/snippet-picker.md @@ -0,0 +1,38 @@ +# Snippet Picker QA + +Date: 2026-04-16 +Branch: `codex/rebuild-feature-rollout` + +## Environment + +- Desktop dev app launched with local project `/Users/canal/.codex/worktrees/28c4/t3code` +- Computer Use QA executed against `ClayCode (Dev)` +- Targeted browser coverage passed: + - `bun run test:browser -- -t "renders queued turns and wires row actions" src/components/QueuedFollowUpsPanel.browser.tsx` + - `bun run test:browser -- -t "saves a queued follow-up into the snippet picker" src/components/ChatView.browser.tsx` + +## Manual QA + +### Scenario 1: Queue panel can save a queued follow-up as a snippet + +1. Added the local `t3code` project inside the desktop app. +2. Started a real thread and sent a message so the agent was actively working. +3. Typed a second prompt while the first turn was still running. +4. Clicked **Queue** and verified the queued follow-up panel appeared. +5. Verified the queued row exposed the new **Save queued follow-up as snippet** action. +6. Clicked that action and observed the `Saved to snippets` toast. + +Result: pass + +### Scenario 2: Saved queued follow-up appears in the snippet picker immediately + +1. With the queued follow-up still visible, opened the snippet picker from the composer controls. +2. Verified the newly saved snippet appeared at the top of the list with the queued follow-up text. +3. Verified the picker still included the built-in snippets below it. + +Result: pass + +## Observations + +- The queued-follow-up bookmark flow is now back in parity with the earlier implementation. +- Saving from the queue uses the same saved-snippet library as the composer picker, so the newly created snippet is visible immediately without a refresh. diff --git a/.codex/artifacts/qa/tailscale-remote-access.md b/.codex/artifacts/qa/tailscale-remote-access.md new file mode 100644 index 0000000000..ec0ca11fd0 --- /dev/null +++ b/.codex/artifacts/qa/tailscale-remote-access.md @@ -0,0 +1,66 @@ +# Tailscale Remote Access QA + +Date: 2026-04-16 +Branch: `codex/rebuild-feature-rollout` + +## Environment + +- Desktop dev app launched with isolated state via `T3CODE_HOME=/tmp/t3-qa-tailscale-fix-keF5gM` +- Computer Use QA executed against `ClayCode (Dev)` +- Terminal gate passed before the final manual QA rerun: + - `bun fmt` + - `bun lint` + - `bun typecheck` + - `bun run test` + - `bun run build` + - `bun run build:desktop` + +## Manual QA + +### Scenario 1: Tailnet status is visible before enabling remote exposure + +1. Opened the desktop app on a fresh isolated state directory. +2. Opened **Settings** → **Connections** through the desktop UI. +3. Verified a new **Tailnet access** row appeared under **Manage local backend**. +4. Verified the row rendered the live local Tailnet identity: + - hostname: `clays-macbook-pro.tail744884.ts.net` + - IPv4: `100.97.126.33` +5. Verified the explanatory copy told the user to enable network access to generate a Tailnet URL. + +Result: pass + +### Scenario 2: Enabling network access restarts into a reachable remote-exposure state + +1. Toggled **Network access** on from the desktop settings page. +2. Verified the confirmation dialog appeared cleanly in the accessibility tree with a working **Restart and enable** action. +3. Confirmed the restart from the same desktop UI flow. +4. Verified the desktop process relaunched back into **Settings** → **Connections**. +5. Verified the exposure transition returned: + - `mode: network-accessible` + - `endpointUrl: http://192.168.86.239:13774` + - `advertisedHost: 192.168.86.239` +6. Verified the relaunched UI rendered: + - the reachable LAN endpoint + - the rewritten Tailnet URL + - working `Copy URL` and `Open URL` actions + - the authorized local desktop client entry +7. Polled the desktop dev logs after restart and did not see the earlier backend-readiness timeout warning recur once the backend was listening on `0.0.0.0:13774`. + +Result: pass + +## Expected Tailnet URL + +Given the live Tailnet hostname above and the returned exposure endpoint, the Tailnet URL resolves to: + +- `http://clays-macbook-pro.tail744884.ts.net:13774/` + +This matches the runtime rewrite covered by `ConnectionsSettings.logic.test.ts` and the browser coverage added in `SettingsPanels.browser.tsx`. + +## Observations + +- The new Tailnet access row is understandable before the user exposes the backend: it explains what is missing and shows the exact Tailnet identity that will be used. +- The exposure restart path now behaves cleanly in desktop dev mode: + - the confirmation dialog is accessible through the Computer Use tree + - the app returns to the exposed Connections state without manual console intervention + - the Tailnet URL is visible immediately after restart +- During the restart there are brief Vite proxy `ECONNREFUSED` messages while the backend is down, which is expected during process replacement and recovered automatically once the relaunched backend started listening. diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..32f8c50de0 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.13.1 diff --git a/.plans/19-feature-rebuild-rollout.md b/.plans/19-feature-rebuild-rollout.md new file mode 100644 index 0000000000..bc88bdb451 --- /dev/null +++ b/.plans/19-feature-rebuild-rollout.md @@ -0,0 +1,362 @@ +# Feature Rebuild Rollout + +This is the execution checklist for rebuilding the planned user-facing features on top of the current T3 Code baseline. + +## Goal + +Rebuild the feature set in a controlled sequence on one long-lived comparison branch. + +Use: + +- terminal checks for static/build/test verification +- the Computer Use plugin for manual QA and regression passes + +We are not landing each feature to `main` as we go. Another agent is actively working there. Instead, we will build the full rebuild stack on our own branch and compare the final result against whatever lands on `main`. + +## Starting point + +Before feature work starts, create or switch to a dedicated rebuild branch from `origin/main`. + +Why: + +- `ClayCode rebrand` is already merged on `origin/main` +- `Sidebar history shortcuts` is already merged on `origin/main` +- rebuilding on the older detached replay baseline would make us redo already-landed work +- keeping our work on one long-lived branch avoids collisions with the separate agent working on `main` + +## Global rules + +For every feature: + +1. Build on the long-lived rebuild branch +2. Implement the feature +3. Run the terminal QA gate +4. Run the Computer Use QA gate +5. Commit the feature as an isolated checkpoint +6. Continue to the next feature on the same branch +7. Periodically compare the branch against updated `main` +8. At the end, compare the final rebuild branch against the other agent's result on `main` + +## Terminal QA gate + +Run these every time before opening or updating a PR: + +- `bun fmt` +- `bun lint` +- `bun typecheck` +- `bun run test` +- `bun run build` +- `bun run build:desktop` + +When relevant: + +- `bun run test:desktop-smoke` +- feature-targeted browser tests in `apps/web` +- targeted integration or server tests for touched areas + +## Computer Use QA gate + +Run these in a fresh local project/workspace for each feature unless the feature explicitly needs existing state. + +Universal regression pass: + +- create a fresh thread and send one message +- send a second message in the same thread +- switch threads while a response is active if the feature can affect thread state +- switch projects and verify sidebar state updates cleanly +- reload the page and confirm the app reconnects without broken state +- open a second tab on the same project and verify no duplicate sends or broken projection state +- hard reload and verify state rehydrates +- clear local storage and verify the app still boots without runtime errors + +Accessibility pass for UI changes: + +- tab through all new controls +- verify visible focus on all new focusable elements +- verify `Esc` exits menus/dialogs +- verify labels are present for buttons and inputs +- verify state is not communicated by color alone + +## Execution order + +### 0. Baseline branch setup + +Tasks: + +- create a dedicated rebuild branch from `origin/main` +- verify `ClayCode rebrand` is present +- verify `Sidebar history shortcuts` are present +- confirm the branch is clean and ready for sequential feature work + +Computer Use QA: + +- verify the app branding shows `ClayCode` in the desktop/web surfaces that changed +- verify sidebar browser-history navigation works with `Cmd-[` and `Cmd-]` + +Exit criteria: + +- rebuild branch starts from latest `origin/main` + +### 1. Snippet picker + +Status: + +- completed on `codex/rebuild-feature-rollout` +- restored queued-follow-up → snippet save parity from the earlier implementation +- targeted browser coverage passed for queued-row save and snippet-picker visibility +- Computer Use QA passed on a live running thread: queued a follow-up, saved it as a snippet from the queue panel, and verified it appeared immediately in the picker +- QA notes captured in `.codex/artifacts/qa/snippet-picker.md` + +Target: + +- add a user-facing snippet picker that makes reusable prompt/code snippets easy to discover and insert + +Tasks: + +- define where snippets live and how they are loaded +- design insertion UX in the composer +- support keyboard-first filtering and insertion +- add unit coverage for parsing/filtering/insertion logic +- add browser coverage for interaction flow +- update docs if snippet authoring or usage becomes user-visible + +Computer Use QA: + +- open the picker from the composer +- search/filter snippets +- insert a snippet into an empty composer +- insert a snippet into an already-populated composer without clobbering surrounding text +- verify keyboard-only open, navigate, insert, and dismiss flows + +Exit criteria: + +- snippets can be found and inserted quickly with mouse and keyboard + +### 2. Quick thread search + +Status: + +- completed on `codex/rebuild-feature-rollout` +- rebuilt as a dedicated modal opened by `Cmd/Ctrl+Shift+F` +- terminal QA gate passed +- Computer Use QA passed on a fresh isolated state dir + +Target: + +- provide a dedicated fast way to search and jump between threads + +Tasks: + +- decide whether to extend the command palette or add a dedicated thread-search entry point +- ensure ranking is stable and useful across title, project, branch, and recency +- add shortcut and discoverability affordances +- add unit coverage for search/ranking behavior +- add browser coverage for opening, filtering, and navigating + +Computer Use QA: + +- open thread search from the intended shortcut/entry point +- search by thread title +- search by project name +- search by branch name if available +- verify archived threads do not appear unless intentionally supported +- jump into a result and verify routing/state hydration are correct + +Exit criteria: + +- thread search is noticeably faster than manual sidebar navigation + +### 3. Draft threads hardening + +Status: + +- completed on `codex/rebuild-feature-rollout` +- terminal QA gate passed +- Computer Use QA passed on a fresh isolated state dir +- verified that starting a second new draft creates a fresh `/draft/` route and that navigating back restores the earlier unsent draft content + +Target: + +- treat draft threads as a first-class feature and harden the current implementation + +Tasks: + +- audit current draft-thread behaviors already present in the app +- close gaps around creation, promotion, reuse, routing, and project/worktree context +- simplify any brittle state transitions in draft-thread logic +- expand tests around draft creation, promotion, and reuse + +Computer Use QA: + +- create a new draft thread from an empty project context +- create a draft thread from a project context +- verify draft reuse behavior when expected +- verify a promoted draft canonicalizes to the server thread route +- verify new drafts do not incorrectly reuse a promoting draft + +Exit criteria: + +- draft-thread lifecycle feels deterministic and unsurprising + +### 4. GitHub PR pills + +Status: + +- completed on `codex/rebuild-feature-rollout` +- terminal QA gate passed (`bun fmt`, `bun lint`, `bun typecheck`, `bun run test`, `bun run build`, `bun run build:desktop`) +- Computer Use QA passed in Safari on a fresh isolated state dir +- verified that a real thread mentioning `https://github.com/pingdotgg/t3code/pull/49` rendered a merged `#49` pill in the sidebar and that clicking it opened the GitHub PR in a new tab + +Target: + +- upgrade PR status from a minimal indicator into a clearer, more intentional pill treatment + +Tasks: + +- define the pill states and visual treatment for open, closed, and merged +- decide where pills appear: sidebar, thread header, or both +- preserve accessibility and avoid relying on color alone +- add unit tests for pill state resolution +- add browser tests for rendering and click/open behavior + +Computer Use QA: + +- verify open, closed, and merged states render distinctly +- verify pills remain legible in dense sidebar layouts +- verify clicking a pill opens the expected PR destination or action +- verify pills do not break thread row truncation or keyboard navigation + +Exit criteria: + +- PR state is obvious at a glance without cluttering the sidebar + +### 5. Queue + Steer + +Status: + +- completed on `codex/rebuild-feature-rollout` +- terminal QA gate passed (`bun fmt`, `bun lint`, `bun typecheck`, `bun run test`, `bun run build`, `bun run build:desktop`) +- Computer Use QA passed on a fresh isolated state dir +- verified live queueing with `Tab`, in-place queued edit, automatic FIFO dispatch after the active turn settled, and immediate steering with `Enter` during a running turn +- QA notes captured in `.codex/artifacts/qa/queue-steer.md` + +Target: + +- rebuild the queue-and-steer experience as the marquee feature + +Tasks: + +- define the exact user model for queued sends and steering queued work +- separate UI state from server/orchestration guarantees +- rebuild the composer, queue controls, and queued-turn rendering together +- add strong regression coverage for queue ordering, recovery, retries, and interruptions +- add end-to-end browser coverage for primary queue flows +- validate reconnect/reload behavior under queued work + +Computer Use QA: + +- queue a send without dispatching immediately if the design supports it +- steer or edit queued work before execution +- dispatch queued work and confirm ordering is preserved +- verify multiple queued items do not collapse into broken state +- reload during queued or active work and confirm recovery +- switch threads/projects during queued work and confirm state stays coherent +- verify error handling on failed queued dispatch is actionable + +Exit criteria: + +- queued work behaves predictably across reloads, reconnects, and rapid user input + +### 6. Tailscale remote access + +Status: + +- completed on `codex/rebuild-feature-rollout` +- terminal QA gate passed (`bun fmt`, `bun lint`, `bun typecheck`, `bun run test`, `bun run build`, `bun run build:desktop`) +- Computer Use QA passed on a fresh isolated desktop state dir +- verified that the desktop Connections settings page renders the new `Tailnet access` row with the live Tailnet hostname/IP +- verified that enabling network exposure restarts the desktop backend into `network-accessible` mode and returns a reachable endpoint used to derive the Tailnet URL +- verified that the confirmation dialog is accessible through Computer Use and that the relaunched app returns directly to the exposed Connections state without the earlier backend-readiness timeout symptom +- QA notes captured in `.codex/artifacts/qa/tailscale-remote-access.md` + +Target: + +- make remote access easier and more reliable for real usage, likely building on the existing network-access + pairing foundation + +Tasks: + +- define the Tailscale-specific user flow and assumptions +- map that flow onto current server exposure, pairing, and remote connection primitives +- add any missing UI guidance and recovery states +- add tests for the configuration and state transitions we can cover locally +- document the exact setup flow + +Computer Use QA: + +- verify the remote-access settings flow is understandable from scratch +- verify pairing-link creation and copy flows +- verify error states explain what to do next +- verify remote entry points are discoverable and not misleading when unavailable + +Exit criteria: + +- a user can understand how to expose and pair a remote environment without guesswork + +### 7. Historical parity follow-up + +Status: + +- completed on `codex/rebuild-feature-rollout` +- restored the sidebar `Grouped` / `Recent` mode toggle to the fuller historical behavior: + - recent view now buckets threads by recency (`Today`, `Yesterday`, `Earlier this week`, `Older`) + - recent rows now show project labels and reuse the same row actions as grouped mode +- restored the Codex transcript import workflow beyond preview-only: + - `Cmd/Ctrl+Shift+I` opens the import dialog + - local Codex sessions are listed and searchable + - importing creates a durable local thread with the transcript content and import provenance + - re-importing an already-imported session reopens the existing durable thread instead of duplicating it +- restored the deeper historical search surfaces that existed in the earlier rebuild work: + - `Cmd/Ctrl+Alt+F` opens `Search All Threads` for title/message/plan search across loaded threads + - `Cmd/Ctrl+Alt+P` opens `Search Project Folders` and starts a new draft thread in the selected project +- restored the sidebar rename shortcut parity: + - `Cmd/Ctrl+Shift+R` now triggers inline rename for the active sidebar thread + - the rename flow works from the global shortcut path rather than only from thread-row context menus +- targeted coverage passed: + - `apps/web/src/components/Sidebar.logic.test.ts` + - `apps/web/src/components/ChatView.browser.tsx -t "imports a Codex transcript into a durable thread from the global shortcut"` + - `apps/server/src/codexImport/Layers/CodexImport.test.ts` + - `apps/web/src/components/GlobalThreadSearchDialog.browser.tsx` + - `apps/web/src/components/ProjectFolderSearchDialog.browser.tsx` + - `apps/web/src/components/ChatView.browser.tsx -t "global thread search shortcut"` + - `apps/web/src/components/ChatView.browser.tsx -t "project folder search shortcut"` + - `apps/web/src/components/ChatView.browser.tsx -t "opens sidebar rename from the global shortcut and submits the rename"` + - `apps/server/src/keybindings.test.ts` + - `apps/web/src/keybindings.test.ts` +- Computer Use QA: + - passed for grouped/recent toggle parity + - passed in Chrome for the durable Codex-import flow, including live import into a real thread and reopening the same imported thread from the dialog + - passed for both restored search dialogs in Chrome via the command palette entry points + - passed in the branch-local Electron dev app for the restored sidebar rename shortcut, including opening inline rename from `Cmd+Shift+R` and committing the updated title with `Enter` + - the live app still displayed older shortcut hints for project/global search because this machine has saved keybindings in `~/.t3` overriding the new defaults; the checked-in defaults and tests now reflect the updated `Cmd/Ctrl+Alt+F` and `Cmd/Ctrl+Alt+P` bindings +- QA notes captured in `.codex/artifacts/qa/sidebar-recent-and-codex-import.md` +- QA notes captured in `.codex/artifacts/qa/codex-import-durable-thread.md` +- QA notes captured in `.codex/artifacts/qa/deep-search-and-project-search.md` +- QA notes captured in `.codex/artifacts/qa/sidebar-rename-hotkey.md` + +Target: + +- close the last historical parity gaps that were not part of the original feature ordering but were present in the prior rebuild work + +## Checkpoint checklist + +Before considering a feature checkpoint complete on the rebuild branch: + +- terminal QA gate is green +- Computer Use QA pass is complete +- docs are updated if behavior changed +- commit scope is feature-shaped, not a grab bag of unrelated cleanup +- known follow-ups are captured before moving to the next feature + +## Suggested branch + +- `codex/rebuild-feature-rollout` diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index b57c13032c..2432af6b24 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -27,6 +27,15 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, + { "key": "mod+shift+f", "command": "threads.search", "when": "!terminalFocus" }, + { "key": "mod+alt+f", "command": "threads.searchAll", "when": "!terminalFocus" }, + { "key": "mod+alt+p", "command": "projects.search", "when": "!terminalFocus" }, + { "key": "mod+shift+s", "command": "snippets.open", "when": "!terminalFocus" }, + { "key": "mod+shift+k", "command": "skills.open", "when": "!terminalFocus" }, + { "key": "mod+shift+[", "command": "thread.previous" }, + { "key": "mod+shift+]", "command": "thread.next" }, + { "key": "mod+[", "command": "sidebar.history.previous", "when": "!terminalFocus" }, + { "key": "mod+]", "command": "sidebar.history.next", "when": "!terminalFocus" }, { "key": "mod+o", "command": "editor.openFavorite" } ] ``` @@ -54,9 +63,35 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `commandPalette.toggle`: open or close the global command palette - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) +- `threads.search`: open quick recent-thread search +- `threads.searchAll`: deep-search all loaded threads including titles, messages, and plans +- `projects.search`: open the project folder search dialog +- `snippets.open`: open the snippet picker +- `skills.open`: open the workspace skill picker +- `thread.previous`: move to the previous visible thread in the sidebar +- `thread.next`: move to the next visible thread in the sidebar +- `sidebar.history.previous`: move backward through sidebar thread history +- `sidebar.history.next`: move forward through sidebar thread history - `editor.openFavorite`: open current project/worktree in the last-used editor - `script.{id}.run`: run a project script by id (for example `script.test.run`) +## Composer Shortcuts + +These composer shortcuts are built in and are not currently configurable through +`~/.t3/keybindings.json`: + +- `Enter`: send the current composer content +- `Shift+Enter`: insert a newline +- `Shift+Tab`: toggle between Build and Plan mode +- While a thread is actively running and the composer has sendable content: + - `Enter`: steer the active turn immediately + - `Cmd/Ctrl+Enter`: steer the active turn immediately + - `Cmd/Ctrl+Shift+Enter`: queue the current follow-up as the next queued row + - `Tab`: queue the follow-up to run after the current turn settles +- While a queued follow-up row is focused: + - `Alt+Up` / `Alt+Down`: move focus between queued rows + - `Alt+Shift+Up` / `Alt+Shift+Down`: reorder the focused queued row + ### Key Syntax Supported modifiers: diff --git a/README.md b/README.md index e4523250d6..9106102b47 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,36 @@ T3 Code is a minimal web GUI for coding agents (currently Codex and Claude, more ## Installation +## Local Development Toolchain + +This repo expects the versions declared in `.mise.toml`: + +- Node `24.13.1` +- Bun `1.3.9` + +Recommended setup: + +```bash +mise install +``` + +Then activate `mise` in your shell so entering the repo automatically uses the right versions. If you use `nvm`, there is also an `.nvmrc` with the same Node version. + +Quick verification: + +```bash +node -v +bun -v +``` + +Both should match the versions above before you run `bun run typecheck`, `bun run start:desktop:main-state`, or other repo scripts. + +The root scripts now fail fast with a direct toolchain error if your shell is on the wrong Node version. As a fallback, you can also run commands through `mise` explicitly: + +```bash +mise exec node@24.13.1 -- bun run typecheck +``` + > [!WARNING] > T3 Code currently supports Codex, Claude, and OpenCode. > Install and authenticate at least one provider before use: diff --git a/REMOTE.md b/REMOTE.md index 30dc562792..f2407cd773 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -22,8 +22,9 @@ If you are already running the desktop app and want to make it reachable from ot 1. Open **Settings** → **Connections**. 2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces. -3. The settings panel will show the address the server is reachable at (e.g. `http://192.168.x.y:3773`). -4. Use **Create Link** to generate a pairing link you can share with another device. +3. If Tailscale is installed and connected on this Mac, the same panel also shows a **Tailnet access** section with the Tailnet hostname and a private Tailnet URL for the install. +4. The settings panel will show the address the server is reachable at (e.g. `http://192.168.x.y:3773`). +5. Use **Create Link** to generate a pairing link you can share with another device. ### Option 2: Headless Server (CLI) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 34a061ffc7..c4be56c18d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -28,5 +28,5 @@ "typescript": "catalog:", "vitest": "catalog:" }, - "productName": "T3 Code (Alpha)" + "productName": "ClayCode (Alpha)" } diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 1453cbe666..8700234010 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -1,4 +1,4 @@ -// This file mostly exists because we want dev mode to say "T3 Code (Dev)" instead of "electron" +// This file mostly exists because we want dev mode to say "ClayCode (Dev)" instead of "electron" import { spawnSync } from "node:child_process"; import { @@ -17,7 +17,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); -const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; +const APP_DISPLAY_NAME = isDevelopment ? "ClayCode (Dev)" : "ClayCode (Alpha)"; const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; const LAUNCHER_VERSION = 2; @@ -170,9 +170,11 @@ export function resolveElectronPath() { return electronBinaryPath; } - // Dev launches do not need a renamed app bundle badly enough to risk breaking - // Electron helper resource lookup on macOS. - if (isDevelopment) { + // The renamed wrapper is useful for checking macOS menu/app labels, but + // copying Electron.app out of package-manager storage can break helper + // resource lookup. Keep local dev launches on Electron's raw binary unless + // we explicitly need to exercise the wrapper. + if (isDevelopment && process.env.T3CODE_USE_RENAMED_ELECTRON_LAUNCHER !== "1") { return electronBinaryPath; } diff --git a/apps/desktop/src/appBranding.test.ts b/apps/desktop/src/appBranding.test.ts index 5e3e3a5a15..1421603f30 100644 --- a/apps/desktop/src/appBranding.test.ts +++ b/apps/desktop/src/appBranding.test.ts @@ -39,9 +39,9 @@ describe("resolveDesktopAppBranding", () => { appVersion: "0.0.17-nightly.20260414.1", }), ).toEqual({ - baseName: "T3 Code", + baseName: "ClayCode", stageLabel: "Nightly", - displayName: "T3 Code (Nightly)", + displayName: "ClayCode (Nightly)", }); }); }); diff --git a/apps/desktop/src/appBranding.ts b/apps/desktop/src/appBranding.ts index 3cb1539f76..71e7506f23 100644 --- a/apps/desktop/src/appBranding.ts +++ b/apps/desktop/src/appBranding.ts @@ -2,7 +2,7 @@ import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contract import { isNightlyDesktopVersion } from "./updateChannels.ts"; -const APP_BASE_NAME = "T3 Code"; +const APP_BASE_NAME = "ClayCode"; export function resolveDesktopAppStageLabel(input: { readonly isDevelopment: boolean; diff --git a/apps/desktop/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts index 0d49842acb..c8cbe492c1 100644 --- a/apps/desktop/src/backendReadiness.test.ts +++ b/apps/desktop/src/backendReadiness.test.ts @@ -27,6 +27,25 @@ describe("waitForHttpReady", () => { ); }); + it("treats redirect responses as ready by default", async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce( + new Response(null, { + status: 302, + headers: { + location: "http://127.0.0.1:5734/", + }, + }), + ); + + await waitForHttpReady("http://127.0.0.1:3773", { + fetchImpl, + timeoutMs: 1_000, + intervalMs: 0, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + it("retries after a readiness request stalls past the per-request timeout", async () => { const fetchImpl = vi .fn() diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts index 71c28929eb..0591d40972 100644 --- a/apps/desktop/src/backendReadiness.ts +++ b/apps/desktop/src/backendReadiness.ts @@ -12,6 +12,10 @@ const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_INTERVAL_MS = 100; const DEFAULT_REQUEST_TIMEOUT_MS = 1_000; +function isDefaultReadyResponse(response: Response): boolean { + return response.ok || (response.status >= 300 && response.status < 400); +} + export class BackendReadinessAbortedError extends Error { constructor() { super("Backend readiness wait was aborted."); @@ -60,7 +64,7 @@ export async function waitForHttpReady( const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS; const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const readinessPath = options?.path ?? "/"; - const isReady = options?.isReady ?? ((response: Response) => response.ok); + const isReady = options?.isReady ?? isDefaultReadyResponse; const deadline = Date.now() + timeoutMs; for (;;) { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c5507c6fb0..03c0640059 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -57,6 +57,7 @@ import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness. import { showDesktopConfirmDialog } from "./confirmDialog.ts"; import { resolveDesktopServerExposure } from "./serverExposure.ts"; import { syncShellEnvironment } from "./syncShellEnvironment.ts"; +import { readTailnetInfo } from "./tailnetInfo.ts"; import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; @@ -102,6 +103,7 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +const GET_TAILNET_INFO_CHANNEL = "desktop:get-tailnet-info"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); @@ -792,7 +794,10 @@ function handleFatalStartupError(stage: string, error: unknown): void { console.error(`[desktop] fatal startup error (${stage})`, error); if (!isQuitting) { isQuitting = true; - dialog.showErrorBox("T3 Code failed to start", `Stage: ${stage}\n${message}${detail}`); + dialog.showErrorBox( + `${desktopAppBranding.baseName} failed to start`, + `Stage: ${stage}\n${message}${detail}`, + ); } stopBackend(); restoreStdIoCapture?.(); @@ -897,7 +902,7 @@ async function checkForUpdatesFromMenu(): Promise { void dialog.showMessageBox({ type: "info", title: "You're up to date!", - message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, + message: `${desktopAppBranding.baseName} ${updateState.currentVersion} is currently the newest version available.`, buttons: ["OK"], }); } else if (updateState.status === "error") { @@ -1417,6 +1422,10 @@ function startBackend(): void { return; } const listeningDetector = new ServerListeningDetector(); + // Dev-mode restarts can intentionally stop the child before anything awaits this + // readiness promise. Mark it as observed so expected shutdowns do not surface as + // unhandled rejections. + void listeningDetector.promise.catch(() => undefined); backendListeningDetector = listeningDetector; backendProcess = child; let backendSessionClosed = false; @@ -1669,6 +1678,9 @@ function registerIpcHandlers(): void { return nextState; }); + ipcMain.removeHandler(GET_TAILNET_INFO_CHANNEL); + ipcMain.handle(GET_TAILNET_INFO_CHANNEL, async () => readTailnetInfo()); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a675604872..e967ca0202 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -24,6 +24,7 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +const GET_TAILNET_INFO_CHANNEL = "desktop:get-tailnet-info"; contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { @@ -53,6 +54,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), + getTailnetInfo: () => ipcRenderer.invoke(GET_TAILNET_INFO_CHANNEL), pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/desktop/src/tailnetInfo.test.ts b/apps/desktop/src/tailnetInfo.test.ts new file mode 100644 index 0000000000..9049c77fcb --- /dev/null +++ b/apps/desktop/src/tailnetInfo.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { readTailnetInfo } from "./tailnetInfo.ts"; + +describe("readTailnetInfo", () => { + it("returns unavailable when the tailscale binary is missing", async () => { + const info = await readTailnetInfo(() => { + const error = Object.assign(new Error("spawn tailscale ENOENT"), { code: "ENOENT" }); + return Promise.reject(error); + }); + + expect(info).toEqual({ + available: false, + connected: false, + hostname: null, + ipv4: null, + error: null, + }); + }); + + it("returns an error when tailscale exists but fails for another reason", async () => { + const info = await readTailnetInfo(() => Promise.reject(new Error("permission denied"))); + + expect(info.available).toBe(true); + expect(info.connected).toBe(false); + expect(info.error).toMatch(/permission denied/); + }); + + it("parses a connected status with DNS and IPv4", async () => { + const info = await readTailnetInfo(() => + Promise.resolve( + JSON.stringify({ + BackendState: "Running", + Self: { + DNSName: "macbook.tail-scales.ts.net.", + Online: true, + TailscaleIPs: ["100.64.1.5", "fd7a:115c:a1e0::1"], + }, + }), + ), + ); + + expect(info).toEqual({ + available: true, + connected: true, + hostname: "macbook.tail-scales.ts.net", + ipv4: "100.64.1.5", + error: null, + }); + }); + + it("reports disconnected when BackendState is stopped and no tailnet IPs exist", async () => { + const info = await readTailnetInfo(() => + Promise.resolve( + JSON.stringify({ + BackendState: "Stopped", + Self: { + DNSName: "offline.ts.net.", + Online: false, + TailscaleIPs: [], + }, + }), + ), + ); + + expect(info.connected).toBe(false); + expect(info.hostname).toBe("offline.ts.net"); + expect(info.ipv4).toBeNull(); + }); + + it("returns an error for unparseable JSON", async () => { + const info = await readTailnetInfo(() => Promise.resolve("not-json")); + + expect(info.available).toBe(true); + expect(info.error).toMatch(/unparseable JSON/); + }); +}); diff --git a/apps/desktop/src/tailnetInfo.ts b/apps/desktop/src/tailnetInfo.ts new file mode 100644 index 0000000000..10bbeb58ad --- /dev/null +++ b/apps/desktop/src/tailnetInfo.ts @@ -0,0 +1,106 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const TAILSCALE_COMMAND = "tailscale"; + +export interface TailnetInfo { + readonly available: boolean; + readonly connected: boolean; + readonly hostname: string | null; + readonly ipv4: string | null; + readonly error: string | null; +} + +const UNAVAILABLE: TailnetInfo = { + available: false, + connected: false, + hostname: null, + ipv4: null, + error: null, +}; + +interface TailscaleStatus { + readonly BackendState?: string; + readonly Self?: { + readonly DNSName?: string; + readonly Online?: boolean; + readonly TailscaleIPs?: readonly string[]; + }; +} + +function normalizeDnsName(value: string | undefined): string | null { + if (!value) return null; + const trimmed = value.trim().replace(/\.$/, ""); + return trimmed.length > 0 ? trimmed : null; +} + +function pickIpv4(ips: readonly string[] | undefined): string | null { + if (!ips || ips.length === 0) return null; + for (const ip of ips) { + if (ip.includes(".") && !ip.includes(":")) { + return ip; + } + } + return null; +} + +function isCommandNotFound(cause: unknown): boolean { + if (typeof cause !== "object" || cause === null) return false; + const message = String((cause as { message?: unknown }).message ?? ""); + if (/ENOENT|not found|no such file/i.test(message)) return true; + const code = (cause as { code?: unknown }).code; + return code === "ENOENT"; +} + +async function runTailscale(args: readonly string[]): Promise { + const { stdout } = await execFileAsync(TAILSCALE_COMMAND, [...args], { + timeout: 5_000, + }); + return stdout; +} + +export async function readTailnetInfo( + runCommand: (args: readonly string[]) => Promise = runTailscale, +): Promise { + let raw: string; + try { + raw = await runCommand(["status", "--json"]); + } catch (cause) { + if (isCommandNotFound(cause)) { + return UNAVAILABLE; + } + return { + available: true, + connected: false, + hostname: null, + ipv4: null, + error: `tailscale status failed: ${String(cause)}`, + }; + } + + let parsed: TailscaleStatus; + try { + parsed = JSON.parse(raw) as TailscaleStatus; + } catch (cause) { + return { + available: true, + connected: false, + hostname: null, + ipv4: null, + error: `tailscale status returned unparseable JSON: ${String(cause)}`, + }; + } + + const connected = + parsed.BackendState === "Running" || + Boolean(parsed.Self?.Online && (parsed.Self.TailscaleIPs?.length ?? 0) > 0); + + return { + available: true, + connected, + hostname: normalizeDnsName(parsed.Self?.DNSName), + ipv4: pickIpv4(parsed.Self?.TailscaleIPs), + error: null, + }; +} diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index 211877e9b1..3bde085b5f 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -100,9 +100,9 @@ const makeCheckpointStore = Effect.gen(function* () { const commitEnv: NodeJS.ProcessEnv = { ...process.env, GIT_INDEX_FILE: tempIndexPath, - GIT_AUTHOR_NAME: "T3 Code", + GIT_AUTHOR_NAME: "ClayCode", GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", - GIT_COMMITTER_NAME: "T3 Code", + GIT_COMMITTER_NAME: "ClayCode", GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", }; diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 4fc23a1ded..3ecc05f0a7 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -1110,13 +1110,13 @@ const runServerCommand = ( }); const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( - Command.withDescription("Run the T3 Code server."), + Command.withDescription("Run the ClayCode server."), Command.withHandler((flags) => runServerCommand(flags)), ); const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( Command.withDescription( - "Run the T3 Code server without opening a browser and print headless pairing details.", + "Run the ClayCode server without opening a browser and print headless pairing details.", ), Command.withHandler((flags) => runServerCommand(flags, { @@ -1127,7 +1127,7 @@ const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe ); export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( - Command.withDescription("Run the T3 Code server."), + Command.withDescription("Run the ClayCode server."), Command.withHandler((flags) => runServerCommand(flags)), Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), ); diff --git a/apps/server/src/codexImport/Layers/CodexImport.test.ts b/apps/server/src/codexImport/Layers/CodexImport.test.ts new file mode 100644 index 0000000000..dc8e1fa5ef --- /dev/null +++ b/apps/server/src/codexImport/Layers/CodexImport.test.ts @@ -0,0 +1,193 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { CommandId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { Effect, Layer, ManagedRuntime } from "effect"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ServerConfig } from "../../config.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationLayerLive } from "../../orchestration/runtimeLayer.ts"; +import { CodexImport } from "../Services/CodexImport.ts"; +import { CodexImportLive } from "./CodexImport.ts"; + +async function createCodexImportSystem() { + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-codex-import-test-", + }); + const layer = Layer.mergeAll( + CodexImportLive.pipe(Layer.provide(OrchestrationLayerLive)), + OrchestrationLayerLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(NodeServices.layer), + ); + const runtime = ManagedRuntime.make(layer); + return { + codexImport: await runtime.runPromise(Effect.service(CodexImport)), + engine: await runtime.runPromise(Effect.service(OrchestrationEngineService)), + snapshotQuery: await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), + run: (effect: Effect.Effect) => runtime.runPromise(effect), + dispose: () => runtime.dispose(), + }; +} + +async function createCodexHome(sessionId: string, rawTranscript: string) { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3-codex-import-")); + const sessionsRoot = path.join(tempRoot, "sessions"); + await fs.mkdir(sessionsRoot, { recursive: true }); + await fs.writeFile(path.join(sessionsRoot, `rollout-${sessionId}.jsonl`), rawTranscript, "utf8"); + return tempRoot; +} + +describe("CodexImportLive", () => { + const cleanupPaths: string[] = []; + + afterEach(async () => { + await Promise.all( + cleanupPaths + .splice(0, cleanupPaths.length) + .map((target) => fs.rm(target, { recursive: true, force: true })), + ); + }); + + it("imports a Codex transcript into a durable thread and marks repeat imports as existing", async () => { + const sessionId = "codex-session-1"; + const codexHome = await createCodexHome( + sessionId, + [ + JSON.stringify({ + type: "turn_context", + payload: { + model: "gpt-5-codex", + sandbox_policy: { type: "danger-full-access" }, + collaboration_mode: { mode: "plan" }, + }, + }), + JSON.stringify({ + timestamp: "2026-04-01T10:00:00.000Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Please debug the release checklist." }], + }, + }), + JSON.stringify({ + timestamp: "2026-04-01T10:00:02.000Z", + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "I found the flaky checklist step." }], + }, + }), + ].join("\n"), + ); + cleanupPaths.push(codexHome); + + const system = await createCodexImportSystem(); + try { + const projectId = ProjectId.make("project-codex-import"); + await system.run( + system.engine.dispatch({ + type: "project.create", + commandId: CommandId.make(crypto.randomUUID()), + projectId, + title: "Codex Import Project", + workspaceRoot: "/tmp/codex-import-project", + defaultModelSelection: null, + createdAt: "2026-04-01T09:59:00.000Z", + }), + ); + + const sessionsBefore = await system.run( + system.codexImport.listSessions({ + homePath: codexHome, + kind: "all", + }), + ); + expect(sessionsBefore).toHaveLength(1); + expect(sessionsBefore[0]).toMatchObject({ + sessionId, + alreadyImported: false, + importedThreadId: null, + }); + + const firstImport = await system.run( + system.codexImport.importSessions({ + homePath: codexHome, + targetProjectId: projectId, + sessionIds: [sessionId], + }), + ); + expect(firstImport.results).toHaveLength(1); + expect(firstImport.results[0]?.status).toBe("imported"); + expect(firstImport.results[0]?.projectId).toBe(projectId); + + const importedThreadId = firstImport.results[0]?.threadId; + expect(importedThreadId).not.toBeNull(); + + const importedThread = await system.run( + system.snapshotQuery.getThreadDetailById(importedThreadId as ThreadId), + ); + expect(importedThread._tag).toBe("Some"); + if (importedThread._tag === "Some") { + expect(importedThread.value.title).toBe("Please debug the release checklist."); + expect(importedThread.value.runtimeMode).toBe("full-access"); + expect(importedThread.value.interactionMode).toBe("plan"); + expect(importedThread.value.messages.map((message) => message.text)).toEqual([ + "Please debug the release checklist.", + "I found the flaky checklist step.", + ]); + expect( + importedThread.value.activities.some( + (activity) => activity.kind === "codex-import.imported", + ), + ).toBe(true); + } + + const sessionsAfter = await system.run( + system.codexImport.listSessions({ + homePath: codexHome, + kind: "all", + }), + ); + expect(sessionsAfter[0]).toMatchObject({ + sessionId, + alreadyImported: true, + importedThreadId, + }); + + const secondImport = await system.run( + system.codexImport.importSessions({ + homePath: codexHome, + targetProjectId: projectId, + sessionIds: [sessionId], + }), + ); + expect(secondImport.results).toEqual([ + { + sessionId, + status: "skipped-existing", + threadId: importedThreadId, + projectId, + error: null, + }, + ]); + } finally { + await system.dispose(); + } + }); +}); diff --git a/apps/server/src/codexImport/Layers/CodexImport.ts b/apps/server/src/codexImport/Layers/CodexImport.ts new file mode 100644 index 0000000000..16d821331d --- /dev/null +++ b/apps/server/src/codexImport/Layers/CodexImport.ts @@ -0,0 +1,514 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + CommandId, + CodexImportError, + DEFAULT_MODEL_BY_PROVIDER, + EventId, + MessageId, + type CodexImportConcreteSessionKind, + type CodexImportImportSessionsInput, + type CodexImportImportSessionsResult, + type CodexImportListSessionsInput, + type CodexImportPeekSessionInput, + type CodexImportPeekSessionResult, + type CodexImportSessionSummary, + type ModelSelection, + type OrchestrationProjectShell, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { Effect, Layer, Option } from "effect"; + +import { + classifyCodexSessionKind, + parseCodexTranscript, + type ParsedCodexTranscript, +} from "../parseCodexTranscript.js"; +import { CodexImport, type CodexImportShape } from "../Services/CodexImport.js"; +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.js"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.js"; + +const DEFAULT_RECENT_DAYS = 30; +const DEFAULT_RECENT_LIMIT = 50; +const DEFAULT_PEEK_MESSAGE_COUNT = 10; +const TITLE_MAX_CHARS = 80; +const IMPORT_ACTIVITY_KIND = "codex-import.imported"; +const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex"); + +interface DiscoveredCodexRollout { + readonly filePath: string; + readonly sessionId: string; + readonly mtimeMs: number; +} + +interface ImportedSessionRef { + readonly threadId: ThreadId; + readonly importedAt: string; +} + +function defaultCodexHome(explicit: string | undefined): string { + return explicit ?? path.join(os.homedir(), ".codex"); +} + +function deriveSessionId(filename: string): string { + const base = filename.replace(/^rollout-/, "").replace(/\.jsonl$/, ""); + return base || filename; +} + +async function discoverRollouts(sessionsRoot: string): Promise { + let entries: Array<{ readonly fullPath: string; readonly filename: string }> = []; + try { + const rawEntries = await fs.readdir(sessionsRoot, { withFileTypes: true, recursive: true }); + entries = rawEntries + .filter( + (entry) => + entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl"), + ) + .map((entry) => { + const parentPath = + (entry as unknown as { parentPath?: string; path?: string }).parentPath ?? + (entry as unknown as { parentPath?: string; path?: string }).path ?? + sessionsRoot; + return { + fullPath: path.join(parentPath, entry.name), + filename: entry.name, + }; + }); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === "ENOENT") return []; + throw cause; + } + + const withStats: DiscoveredCodexRollout[] = []; + for (const { fullPath, filename } of entries) { + try { + const stat = await fs.stat(fullPath); + withStats.push({ + filePath: fullPath, + sessionId: deriveSessionId(filename), + mtimeMs: stat.mtimeMs, + }); + } catch { + // Skip files we can't stat (permissions, symlink loops, etc.). + } + } + return withStats; +} + +async function loadTranscript( + filePath: string, +): Promise<{ readonly parsed: ParsedCodexTranscript } | { readonly error: string }> { + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = parseCodexTranscript(raw); + return { parsed }; + } catch (cause) { + return { error: cause instanceof Error ? cause.message : String(cause) }; + } +} + +function firstUserMessageText(parsed: ParsedCodexTranscript): string | null { + const first = parsed.messages.find((message) => message.role === "user"); + return first ? first.text : null; +} + +function lastMessageText(parsed: ParsedCodexTranscript, role: "user" | "assistant"): string | null { + for (let index = parsed.messages.length - 1; index >= 0; index -= 1) { + const message = parsed.messages[index]; + if (message && message.role === role) return message.text; + } + return null; +} + +function truncateForTitle(input: string): string { + const normalized = input.trim().replace(/\s+/g, " "); + if (normalized.length <= TITLE_MAX_CHARS) return normalized; + return `${normalized.slice(0, TITLE_MAX_CHARS - 1)}…`; +} + +function iso(ms: number): string { + return new Date(ms).toISOString(); +} + +function readImportedSessionId(payload: unknown): string | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return null; + } + const value = (payload as { readonly sessionId?: unknown }).sessionId; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function buildImportedSessionMap( + threads: ReadonlyArray<{ + id: ThreadId; + updatedAt: string; + activities: ReadonlyArray<{ kind: string; payload: unknown; createdAt: string }>; + }>, +): ReadonlyMap { + const imported = new Map(); + const sortedThreads = threads.toSorted( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id), + ); + for (const thread of sortedThreads) { + const sortedActivities = thread.activities.toSorted((left, right) => + right.createdAt.localeCompare(left.createdAt), + ); + for (const activity of sortedActivities) { + if (activity.kind !== IMPORT_ACTIVITY_KIND) { + continue; + } + const sessionId = readImportedSessionId(activity.payload); + if (!sessionId || imported.has(sessionId)) { + continue; + } + imported.set(sessionId, { + threadId: thread.id, + importedAt: activity.createdAt, + }); + } + } + return imported; +} + +function resolveImportModelSelection( + project: OrchestrationProjectShell, + parsed: ParsedCodexTranscript, +): ModelSelection { + if (project.defaultModelSelection) { + return project.defaultModelSelection; + } + if (parsed.model && parsed.model.trim().length > 0) { + return { + instanceId: CODEX_INSTANCE_ID, + model: parsed.model.trim(), + }; + } + return { + instanceId: CODEX_INSTANCE_ID, + model: DEFAULT_MODEL_BY_PROVIDER[CODEX_DRIVER] ?? "gpt-5.4", + }; +} + +function buildSummary( + rollout: DiscoveredCodexRollout, + parsed: ParsedCodexTranscript, + importedSessions: ReadonlyMap = new Map(), +): CodexImportSessionSummary { + const firstMessage = firstUserMessageText(parsed); + const title = firstMessage ? truncateForTitle(firstMessage) : rollout.sessionId; + const kind: CodexImportConcreteSessionKind = + classifyCodexSessionKind({ source: null, messages: parsed.messages }) ?? "direct"; + const importedRef = importedSessions.get(rollout.sessionId); + const earliestMs = + parsed.messages.length > 0 + ? Math.min( + ...parsed.messages + .map((message) => Date.parse(message.createdAt)) + .filter(Number.isFinite), + ) + : rollout.mtimeMs; + const latestMs = + parsed.messages.length > 0 + ? Math.max( + ...parsed.messages + .map((message) => Date.parse(message.updatedAt)) + .filter(Number.isFinite), + ) + : rollout.mtimeMs; + + return { + sessionId: rollout.sessionId, + title, + cwd: null, + createdAt: Number.isFinite(earliestMs) ? iso(earliestMs) : iso(rollout.mtimeMs), + updatedAt: Number.isFinite(latestMs) ? iso(latestMs) : iso(rollout.mtimeMs), + model: parsed.model ?? null, + kind, + transcriptAvailable: true, + transcriptError: null, + alreadyImported: importedRef !== undefined, + importedThreadId: importedRef?.threadId ?? null, + lastUserMessage: lastMessageText(parsed, "user"), + lastAssistantMessage: lastMessageText(parsed, "assistant"), + }; +} + +const makeCodexImport = Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const loadImportedSessions = () => + projectionSnapshotQuery.getSnapshot().pipe( + Effect.map((snapshot) => buildImportedSessionMap(snapshot.threads)), + Effect.mapError( + (cause) => + new CodexImportError({ + message: `Failed to read imported-session state: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + ), + ); + + const loadTargetProject = (targetProjectId: CodexImportImportSessionsInput["targetProjectId"]) => + projectionSnapshotQuery.getProjectShellById(targetProjectId).pipe( + Effect.mapError( + (cause) => + new CodexImportError({ + message: `Failed to read target project ${targetProjectId}: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + ), + Effect.flatMap((projectOption) => + Option.isSome(projectOption) + ? Effect.succeed(projectOption.value) + : Effect.fail( + new CodexImportError({ + message: `Target project not found: ${targetProjectId}`, + }), + ), + ), + ); + + const listSessions: CodexImportShape["listSessions"] = (input: CodexImportListSessionsInput) => + Effect.gen(function* () { + const codexHome = defaultCodexHome(input.homePath); + const sessionsRoot = path.join(codexHome, "sessions"); + const importedSessions = yield* loadImportedSessions(); + const rollouts = yield* Effect.tryPromise({ + try: () => discoverRollouts(sessionsRoot), + catch: (cause) => + new CodexImportError({ + message: `Failed to scan Codex sessions at ${sessionsRoot}: ${String(cause)}`, + }), + }); + const days = input.days ?? DEFAULT_RECENT_DAYS; + const limit = input.limit ?? DEFAULT_RECENT_LIMIT; + const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000; + const recent = rollouts + .filter((rollout) => rollout.mtimeMs >= cutoffMs) + .toSorted((left, right) => right.mtimeMs - left.mtimeMs) + .slice(0, limit * 2); + + const summaries: CodexImportSessionSummary[] = []; + for (const rollout of recent) { + const loaded = yield* Effect.promise(() => loadTranscript(rollout.filePath)); + if ("error" in loaded) continue; + const summary = buildSummary(rollout, loaded.parsed, importedSessions); + if (input.kind !== "all" && summary.kind !== input.kind) continue; + if (input.query) { + const needle = input.query.toLowerCase(); + const haystack = + `${summary.title} ${summary.lastUserMessage ?? ""} ${summary.lastAssistantMessage ?? ""}`.toLowerCase(); + if (!haystack.includes(needle)) continue; + } + summaries.push(summary); + if (summaries.length >= limit) break; + } + return summaries; + }); + + const peekSession: CodexImportShape["peekSession"] = (input: CodexImportPeekSessionInput) => + Effect.gen(function* () { + const codexHome = defaultCodexHome(input.homePath); + const sessionsRoot = path.join(codexHome, "sessions"); + const importedSessions = yield* loadImportedSessions(); + const rollouts = yield* Effect.tryPromise({ + try: () => discoverRollouts(sessionsRoot), + catch: (cause) => + new CodexImportError({ message: `Failed to scan Codex sessions: ${String(cause)}` }), + }); + const match = rollouts.find((rollout) => rollout.sessionId === input.sessionId); + if (!match) { + return yield* new CodexImportError({ + message: `Codex session not found: ${input.sessionId}`, + }); + } + const loaded = yield* Effect.promise(() => loadTranscript(match.filePath)); + if ("error" in loaded) { + return yield* new CodexImportError({ + message: `Failed to read ${match.filePath}: ${loaded.error}`, + }); + } + const parsed = loaded.parsed; + const messageCount = input.messageCount ?? DEFAULT_PEEK_MESSAGE_COUNT; + const lastMessages = parsed.messages.slice(-messageCount); + const summary = buildSummary(match, parsed, importedSessions); + const result: CodexImportPeekSessionResult = { + sessionId: match.sessionId, + title: summary.title, + cwd: null, + createdAt: summary.createdAt, + updatedAt: summary.updatedAt, + model: parsed.model ?? null, + runtimeMode: parsed.runtimeMode, + interactionMode: parsed.interactionMode, + kind: summary.kind, + transcriptAvailable: true, + transcriptError: null, + alreadyImported: summary.alreadyImported, + importedThreadId: summary.importedThreadId, + messages: lastMessages.map((message) => ({ + role: message.role, + text: message.text, + createdAt: message.createdAt, + })), + }; + return result; + }); + + const importSessions: CodexImportShape["importSessions"] = ( + input: CodexImportImportSessionsInput, + ) => + Effect.gen(function* () { + const codexHome = defaultCodexHome(input.homePath); + const sessionsRoot = path.join(codexHome, "sessions"); + const project = yield* loadTargetProject(input.targetProjectId); + const rollouts = yield* Effect.tryPromise({ + try: () => discoverRollouts(sessionsRoot), + catch: (cause) => + new CodexImportError({ + message: `Failed to scan Codex sessions at ${sessionsRoot}: ${String(cause)}`, + }), + }); + const importedSessions = new Map(yield* loadImportedSessions()); + const results: Array = []; + + for (const sessionId of input.sessionIds) { + const existing = importedSessions.get(sessionId); + if (existing) { + results.push({ + sessionId, + status: "skipped-existing", + threadId: existing.threadId, + projectId: input.targetProjectId, + error: null, + }); + continue; + } + + const match = rollouts.find((rollout) => rollout.sessionId === sessionId); + if (!match) { + results.push({ + sessionId, + status: "failed", + threadId: null, + projectId: input.targetProjectId, + error: `Codex session not found: ${sessionId}`, + }); + continue; + } + + const loaded = yield* Effect.promise(() => loadTranscript(match.filePath)); + if ("error" in loaded) { + results.push({ + sessionId, + status: "failed", + threadId: null, + projectId: input.targetProjectId, + error: `Failed to read ${match.filePath}: ${loaded.error}`, + }); + continue; + } + + const parsed = loaded.parsed; + const summary = buildSummary(match, parsed, importedSessions); + const nextThreadId = ThreadId.make(crypto.randomUUID()); + const createdAt = summary.createdAt ?? new Date().toISOString(); + const importedAt = new Date().toISOString(); + const modelSelection = resolveImportModelSelection(project, parsed); + + const importResult = yield* Effect.match( + Effect.gen(function* () { + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: CommandId.make(crypto.randomUUID()), + threadId: nextThreadId, + projectId: input.targetProjectId, + title: summary.title, + modelSelection, + runtimeMode: parsed.runtimeMode, + interactionMode: parsed.interactionMode, + branch: null, + worktreePath: null, + createdAt, + }); + + for (const message of parsed.messages) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.append", + commandId: CommandId.make(crypto.randomUUID()), + threadId: nextThreadId, + message: { + messageId: MessageId.make(crypto.randomUUID()), + role: message.role, + text: message.text, + }, + createdAt: message.createdAt, + }); + } + + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make(crypto.randomUUID()), + threadId: nextThreadId, + activity: { + id: EventId.make(crypto.randomUUID()), + tone: "info", + kind: IMPORT_ACTIVITY_KIND, + summary: `Imported from Codex session ${summary.title}`, + payload: { + sessionId, + sourceKind: summary.kind, + sourceCreatedAt: summary.createdAt, + sourceUpdatedAt: summary.updatedAt, + sourceModel: parsed.model, + importedAt, + }, + turnId: null, + createdAt: importedAt, + }, + createdAt: importedAt, + }); + }), + { + onFailure: (cause) => ({ + sessionId, + status: "failed" as const, + threadId: null, + projectId: input.targetProjectId, + error: cause instanceof Error ? cause.message : String(cause), + }), + onSuccess: () => ({ + sessionId, + status: "imported" as const, + threadId: nextThreadId, + projectId: input.targetProjectId, + error: null, + }), + }, + ); + + results.push(importResult); + if (importResult.status === "imported" && importResult.threadId) { + importedSessions.set(sessionId, { + threadId: importResult.threadId, + importedAt, + }); + } + } + + return { results }; + }); + + return { + listSessions, + peekSession, + importSessions, + } satisfies CodexImportShape; +}); + +export const CodexImportLive = Layer.effect(CodexImport, makeCodexImport); diff --git a/apps/server/src/codexImport/Services/CodexImport.ts b/apps/server/src/codexImport/Services/CodexImport.ts new file mode 100644 index 0000000000..d8cb5866db --- /dev/null +++ b/apps/server/src/codexImport/Services/CodexImport.ts @@ -0,0 +1,27 @@ +import type { + CodexImportError, + CodexImportImportSessionsInput, + CodexImportImportSessionsResult, + CodexImportListSessionsInput, + CodexImportPeekSessionInput, + CodexImportPeekSessionResult, + CodexImportSessionSummary, +} from "@t3tools/contracts"; +import { Context } from "effect"; +import type { Effect } from "effect"; + +export interface CodexImportShape { + readonly listSessions: ( + input: CodexImportListSessionsInput, + ) => Effect.Effect, CodexImportError>; + readonly peekSession: ( + input: CodexImportPeekSessionInput, + ) => Effect.Effect; + readonly importSessions: ( + input: CodexImportImportSessionsInput, + ) => Effect.Effect; +} + +export class CodexImport extends Context.Service()( + "t3/codexImport/Services/CodexImport", +) {} diff --git a/apps/server/src/codexImport/parseCodexTranscript.test.ts b/apps/server/src/codexImport/parseCodexTranscript.test.ts new file mode 100644 index 0000000000..f39a56a4d4 --- /dev/null +++ b/apps/server/src/codexImport/parseCodexTranscript.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import { classifyCodexSessionKind, parseCodexTranscript } from "./parseCodexTranscript.js"; + +describe("parseCodexTranscript", () => { + it("parses importable text messages and transcript context", () => { + const raw = [ + JSON.stringify({ + type: "turn_context", + payload: { + model: "gpt-5-codex", + sandbox_policy: { type: "danger-full-access" }, + collaboration_mode: { mode: "plan" }, + }, + }), + JSON.stringify({ + timestamp: "2026-01-01T00:00:00.000Z", + type: "response_item", + payload: { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "skip me" }], + }, + }), + JSON.stringify({ + timestamp: "2026-01-01T00:00:01.000Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "Hello" }, + { type: "input_image", data: "ignored" }, + ], + }, + }), + JSON.stringify({ + timestamp: "2026-01-01T00:00:02.000Z", + type: "response_item", + payload: { + type: "reasoning", + role: "assistant", + content: [{ type: "text", text: "ignored" }], + }, + }), + JSON.stringify({ + timestamp: "2026-01-01T00:00:03.000Z", + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [ + { type: "output_text", text: "Hi" }, + { type: "text", text: " there" }, + ], + }, + }), + ].join("\n"); + + const parsed = parseCodexTranscript(raw); + + expect(parsed.model).toBe("gpt-5-codex"); + expect(parsed.runtimeMode).toBe("full-access"); + expect(parsed.interactionMode).toBe("plan"); + expect(parsed.messages).toEqual([ + { + role: "user", + text: "Hello", + createdAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }, + { + role: "assistant", + text: "Hi there", + createdAt: "2026-01-01T00:00:03.000Z", + updatedAt: "2026-01-01T00:00:03.000Z", + }, + ]); + }); + + it("normalizes numeric timestamps to ISO strings", () => { + const raw = JSON.stringify({ + timestamp: 1_735_689_600, + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Hello" }], + }, + }); + + const parsed = parseCodexTranscript(raw); + expect(parsed.messages[0]?.createdAt).toBe("2025-01-01T00:00:00.000Z"); + }); + + it("classifies direct, subagent-child, and orchestrator sessions", () => { + expect( + classifyCodexSessionKind({ + source: "interactive", + messages: [{ role: "user", text: "plain prompt", createdAt: "1", updatedAt: "1" }], + }), + ).toBe("direct"); + + expect( + classifyCodexSessionKind({ + source: "thread_spawn/subagent", + messages: [{ role: "user", text: "child prompt", createdAt: "1", updatedAt: "1" }], + }), + ).toBe("subagent-child"); + + expect( + classifyCodexSessionKind({ + source: "interactive", + messages: [ + { + role: "user", + text: "subagent_notification: child completed", + createdAt: "1", + updatedAt: "1", + }, + ], + }), + ).toBe("orchestrator"); + }); +}); diff --git a/apps/server/src/codexImport/parseCodexTranscript.ts b/apps/server/src/codexImport/parseCodexTranscript.ts new file mode 100644 index 0000000000..06198346f3 --- /dev/null +++ b/apps/server/src/codexImport/parseCodexTranscript.ts @@ -0,0 +1,230 @@ +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type OrchestrationMessageRole, + type ProviderInteractionMode, + type RuntimeMode, +} from "@t3tools/contracts"; + +export interface ParsedCodexTranscriptMessage { + readonly role: OrchestrationMessageRole; + readonly text: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface ParsedCodexTranscript { + readonly model: string | null; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + readonly messages: ReadonlyArray; +} + +export class CodexTranscriptParseError extends Error { + constructor(message: string) { + super(message); + this.name = "CodexTranscriptParseError"; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function readString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function normalizeRuntimeMode(value: unknown): RuntimeMode { + return readString(value) === "danger-full-access" ? "full-access" : "approval-required"; +} + +function normalizeInteractionMode(value: unknown): ProviderInteractionMode { + return readString(value) === "plan" ? "plan" : "default"; +} + +function normalizeTimestamp(value: unknown, lineNumber: number): string { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new CodexTranscriptParseError( + `Invalid JSONL transcript at line ${String(lineNumber)}: response_item is missing a timestamp.`, + ); + } + if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) { + return normalizeTimestamp(Number(trimmed), lineNumber); + } + const timestamp = new Date(trimmed); + if (Number.isFinite(timestamp.getTime())) { + return timestamp.toISOString(); + } + } + + if (typeof value === "number" && Number.isFinite(value)) { + const timestampMs = Math.abs(value) < 1_000_000_000_000 ? value * 1_000 : value; + const timestamp = new Date(timestampMs); + if (Number.isFinite(timestamp.getTime())) { + return timestamp.toISOString(); + } + } + + throw new CodexTranscriptParseError( + `Invalid JSONL transcript at line ${String(lineNumber)}: response_item is missing a timestamp.`, + ); +} + +function extractTurnContext(record: Record): Record | null { + if (readString(record.type) === "turn_context") { + if (isRecord(record.payload)) { + return record.payload; + } + return record; + } + + if (isRecord(record.turn_context)) { + return record.turn_context; + } + + if (isRecord(record.payload) && isRecord(record.payload.turn_context)) { + return record.payload.turn_context; + } + + return null; +} + +function extractResponseMessage(record: Record): Record | null { + if (readString(record.type) !== "response_item") { + return null; + } + + if (!isRecord(record.payload)) { + return null; + } + + return readString(record.payload.type) === "message" ? record.payload : null; +} + +function readContentText(contentItem: unknown): string { + if (!isRecord(contentItem)) { + return ""; + } + + const type = readString(contentItem.type); + if (type !== "input_text" && type !== "output_text" && type !== "text") { + return ""; + } + + return ( + readString(contentItem.text) ?? + readString(contentItem.value) ?? + readString(contentItem.content) ?? + "" + ); +} + +function readMessageText(payload: Record): string { + const content = Array.isArray(payload.content) ? payload.content : []; + return content.map(readContentText).join(""); +} + +function pushWithinWindow(items: T[], item: T, maxItems?: number): void { + items.push(item); + if (maxItems !== undefined && maxItems > 0 && items.length > maxItems) { + items.splice(0, items.length - maxItems); + } +} + +export function parseCodexTranscript( + rawJsonl: string, + options?: { readonly messageWindow?: number }, +): ParsedCodexTranscript { + const messages: ParsedCodexTranscriptMessage[] = []; + let model: string | null = null; + let runtimeMode: RuntimeMode = DEFAULT_RUNTIME_MODE; + let interactionMode: ProviderInteractionMode = DEFAULT_PROVIDER_INTERACTION_MODE; + + const lines = rawJsonl.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]?.trim(); + if (!line) { + continue; + } + + let parsedLine: unknown; + try { + parsedLine = JSON.parse(line); + } catch (error) { + throw new CodexTranscriptParseError( + `Invalid JSONL transcript at line ${String(index + 1)}: ${error instanceof Error ? error.message : "Unable to parse JSON."}`, + ); + } + + if (!isRecord(parsedLine)) { + throw new CodexTranscriptParseError( + `Invalid JSONL transcript at line ${String(index + 1)}: expected an object record.`, + ); + } + + const turnContext = extractTurnContext(parsedLine); + if (turnContext) { + model = readString(turnContext.model) ?? model; + if (isRecord(turnContext.sandbox_policy)) { + runtimeMode = normalizeRuntimeMode(turnContext.sandbox_policy.type); + } + if (isRecord(turnContext.collaboration_mode)) { + interactionMode = normalizeInteractionMode(turnContext.collaboration_mode.mode); + } + } + + const payload = extractResponseMessage(parsedLine); + if (!payload) { + continue; + } + + const role = readString(payload.role); + if (role !== "user" && role !== "assistant" && role !== "system") { + continue; + } + + const timestamp = normalizeTimestamp(parsedLine.timestamp, index + 1); + const text = readMessageText(payload); + if (text.length === 0) { + continue; + } + + pushWithinWindow( + messages, + { + role, + text, + createdAt: timestamp, + updatedAt: timestamp, + }, + options?.messageWindow, + ); + } + + return { + model, + runtimeMode, + interactionMode, + messages, + }; +} + +export function classifyCodexSessionKind(input: { + readonly source: string | null; + readonly messages: ReadonlyArray; +}): "direct" | "subagent-child" | "orchestrator" { + const lastUserMessage = input.messages.toReversed().find((message) => message.role === "user"); + if (lastUserMessage?.text.toLowerCase().includes("subagent_notification")) { + return "orchestrator"; + } + + const normalizedSource = input.source?.toLowerCase() ?? ""; + if (normalizedSource.includes("thread_spawn") || normalizedSource.includes("subagent")) { + return "subagent-child"; + } + + return "direct"; +} diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index b8eeb54189..8d118e87f1 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -994,7 +994,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); yield* runGit(repoDir, [ "config", - "remote.fork-seed.url", + "remote.fork-seed.pushurl", "git@github.com:jasonLaster/codething-mvp.git", ]); @@ -1058,13 +1058,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); yield* runGit(repoDir, [ "config", - "remote.origin.url", + "remote.origin.pushurl", "git@github.com:pingdotgg/codething-mvp.git", ]); yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); yield* runGit(repoDir, [ "config", - "remote.my-org/upstream.url", + "remote.my-org/upstream.pushurl", "git@github.com:pingdotgg/codething-mvp.git", ]); yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); @@ -1765,7 +1765,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); yield* runGit(repoDir, [ "config", - "remote.fork-seed.url", + "remote.fork-seed.pushurl", "git@github.com:octocat/codething-mvp.git", ]); @@ -1808,7 +1808,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ).toBe(true); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); }), - 12_000, + 20_000, ); it.effect( @@ -1827,13 +1827,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); yield* runGit(repoDir, [ "config", - "remote.origin.url", + "remote.origin.pushurl", "git@github.com:pingdotgg/codething-mvp.git", ]); yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); yield* runGit(repoDir, [ "config", - "remote.my-org/upstream.url", + "remote.my-org/upstream.pushurl", "git@github.com:pingdotgg/codething-mvp.git", ]); yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); @@ -1917,7 +1917,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); yield* runGit(repoDir, [ "config", - "remote.fork-seed.url", + "remote.fork-seed.pushurl", "git@github.com:octocat/codething-mvp.git", ]); @@ -1987,7 +1987,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); yield* runGit(repoDir, [ "config", - "remote.fork-seed.url", + "remote.fork-seed.pushurl", "git@github.com:octocat/codething-mvp.git", ]); @@ -2167,7 +2167,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); yield* runGit(repoDir, [ "config", - "remote.fork-seed.url", + "remote.fork-seed.pushurl", "git@github.com:octocat/codething-mvp.git", ]); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 21f3411d1e..41c1d83f93 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -742,8 +742,16 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; } - const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); - const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); + const [remotePushUrl, remoteUrl] = yield* Effect.all( + [ + readConfigValueNullable(cwd, `remote.${remoteName}.pushurl`), + readConfigValueNullable(cwd, `remote.${remoteName}.url`), + ], + { concurrency: "unbounded" }, + ); + const repositoryNameWithOwner = + parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remotePushUrl) ?? + parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); return { repositoryNameWithOwner, ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index bbb7e1b430..78ac55c450 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -192,6 +192,11 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); + assert.equal(defaultsByCommand.get("sidebar.rename"), "mod+shift+r"); + assert.equal(defaultsByCommand.get("sidebar.thread.previous"), "alt+arrowup"); + assert.equal(defaultsByCommand.get("sidebar.thread.next"), "alt+arrowdown"); + assert.equal(defaultsByCommand.get("sidebar.project.previous"), "alt+shift+arrowup"); + assert.equal(defaultsByCommand.get("sidebar.project.next"), "alt+shift+arrowdown"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 165b2edeb0..44429baa4d 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -67,10 +67,22 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+shift+f", command: "threads.search", when: "!terminalFocus" }, + { key: "mod+alt+f", command: "threads.searchAll", when: "!terminalFocus" }, + { key: "mod+alt+p", command: "projects.search", when: "!terminalFocus" }, + { key: "mod+shift+s", command: "snippets.open", when: "!terminalFocus" }, + { key: "mod+shift+k", command: "skills.open", when: "!terminalFocus" }, + { key: "mod+shift+r", command: "sidebar.rename", when: "!terminalFocus" }, + { key: "alt+arrowup", command: "sidebar.thread.previous", when: "!terminalFocus" }, + { key: "alt+arrowdown", command: "sidebar.thread.next", when: "!terminalFocus" }, + { key: "alt+shift+arrowup", command: "sidebar.project.previous", when: "!terminalFocus" }, + { key: "alt+shift+arrowdown", command: "sidebar.project.next", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, + { key: "mod+[", command: "sidebar.history.previous", when: "!terminalFocus" }, + { key: "mod+]", command: "sidebar.history.next", when: "!terminalFocus" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9a9f3d71b0..508edc49a3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -423,6 +423,7 @@ const make = Effect.gen(function* () { const existingSessionThreadId = thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; if (existingSessionThreadId) { + const sessionErrored = thread.session?.status === "error"; const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; const cwdChanged = effectiveCwd !== activeSession?.cwd; const sessionModelSwitch = (yield* providerService.getCapabilities(desiredInstanceId)) @@ -441,6 +442,7 @@ const make = Effect.gen(function* () { !Equal.equals(previousModelSelection, requestedModelSelection); if ( + !sessionErrored && !runtimeModeChanged && !cwdChanged && !instanceChanged && @@ -462,6 +464,7 @@ const make = Effect.gen(function* () { desiredProvider: desiredModelSelection.instanceId, currentRuntimeMode: thread.session?.runtimeMode, desiredRuntimeMode: thread.runtimeMode, + sessionErrored, runtimeModeChanged, previousCwd: activeSession?.cwd, desiredCwd: effectiveCwd, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 9b6b1eb154..75884c26fc 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -642,6 +642,36 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.message.append": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.message.messageId, + role: command.message.role, + text: command.message.text, + ...(command.message.attachments !== undefined + ? { attachments: command.message.attachments } + : {}), + turnId: command.turnId ?? null, + streaming: false, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + } + case "thread.proposed-plan.upsert": { yield* requireThread({ readModel, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 4350596700..a666d7e192 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -540,7 +540,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( version: null, status: "warning", auth: { status: "unknown" }, - message: "Claude is disabled in T3 Code settings.", + message: "Claude is disabled in ClayCode settings.", }, }); } @@ -684,7 +684,7 @@ export const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): Serve version: null, status: "warning", auth: { status: "unknown" }, - message: "Claude is disabled in T3 Code settings.", + message: "Claude is disabled in ClayCode settings.", }, }); } diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 8986ba48f2..7ae9714efe 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -158,7 +158,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { version: null, status: "disabled", auth: { status: "unknown" }, - message: "Codex is disabled in T3 Code settings.", + message: "Codex is disabled in ClayCode settings.", }); assert.deepStrictEqual( diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e699ad8339..3d0d59c445 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -10,6 +10,7 @@ import { GitCommandError, KeybindingRule, MessageId, + CodexImportError, OpenError, type OrchestrationThreadShell, TerminalNotRunningError, @@ -53,6 +54,7 @@ import { vi } from "vitest"; import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; +import { CodexImport } from "./codexImport/Services/CodexImport.ts"; import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; import { CheckpointDiffQuery, @@ -541,6 +543,18 @@ const buildAppUnderTest = (options?: { ...options?.layers?.repositoryIdentityResolver, }), ), + Layer.provide( + Layer.mock(CodexImport)({ + listSessions: () => Effect.succeed([]), + peekSession: () => + Effect.fail( + new CodexImportError({ + message: "Codex import peek was not configured for this test", + }), + ), + importSessions: () => Effect.succeed({ results: [] }), + }), + ), Layer.provideMerge(authTestLayer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 85f2d84ad2..b7bdce0451 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,6 +50,7 @@ import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; +import { CodexImportLive } from "./codexImport/Layers/CodexImport.ts"; import { authBearerBootstrapRouteLayer, authBootstrapRouteLayer, @@ -196,7 +197,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const RuntimeDependenciesLive = ReactorLayerLive.pipe( +const CoreRuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(GitLayerLive), @@ -229,14 +230,25 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), + Layer.provideMerge( + CodexImportLive.pipe( + Layer.provide( + OrchestrationLayerLive.pipe( + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(PersistenceLayerLive), + ), + ), + ), + ), // Misc. Layer.provideMerge(AnalyticsServiceLayerLive), Layer.provideMerge(OpenLive), Layer.provideMerge(ServerLifecycleEventsLive), - Layer.provide(NetService.layer), ); +const RuntimeDependenciesLive = CoreRuntimeDependenciesLive.pipe(Layer.provide(NetService.layer)); + const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( Layer.provideMerge(RuntimeDependenciesLive), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 1f164860a6..b5d09e682b 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -443,7 +443,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { const startupBrowserTarget = yield* resolveStartupBrowserTarget; if (serverConfig.mode !== "desktop") { yield* Effect.logInfo( - "Authentication required. Open T3 Code using the pairing URL.", + "Authentication required. Open ClayCode using the pairing URL.", ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget })); } yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); diff --git a/apps/server/src/skills.test.ts b/apps/server/src/skills.test.ts new file mode 100644 index 0000000000..954d262a62 --- /dev/null +++ b/apps/server/src/skills.test.ts @@ -0,0 +1,99 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { searchSkills } from "./skills.js"; + +const tempDirs: string[] = []; + +async function makeTempDir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "t3-skills-test-")); + tempDirs.push(dir); + return dir; +} + +async function writeSkill( + rootPath: string, + skillName: string, + contents: string, + nestedFolder?: string, +) { + const skillDir = nestedFolder + ? path.join(rootPath, nestedFolder, skillName) + : path.join(rootPath, skillName); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), contents, "utf8"); +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("searchSkills", () => { + it("prefers workspace skills and extracts frontmatter fields", async () => { + const cwd = await makeTempDir(); + const codexHome = await makeTempDir(); + + await writeSkill( + path.join(cwd, ".codex", "skills"), + "agent-browser", + ["---", "name: agent-browser", "description: Browse and inspect apps", "---"].join("\n"), + ); + await writeSkill( + path.join(codexHome, "skills"), + "agent-browser", + ["---", "name: agent-browser", "description: Fallback personal copy", "---"].join("\n"), + ); + + const result = await searchSkills({ + cwd, + query: "agent-browser", + limit: 10, + codexHomePath: codexHome, + }); + + expect(result.truncated).toBe(false); + expect(result.skills).toEqual([ + { + name: "agent-browser", + description: "Browse and inspect apps", + skillPath: path.join(cwd, ".codex", "skills", "agent-browser", "SKILL.md"), + rootPath: path.join(cwd, ".codex", "skills"), + source: "workspace", + }, + ]); + }); + + it("treats dollar-only queries as match-all and discovers nested skill directories", async () => { + const cwd = await makeTempDir(); + const extraRoot = await makeTempDir(); + const emptyCodexHome = await makeTempDir(); + + await writeSkill(path.join(cwd, ".codex", "skills"), "local-review", "# Local review"); + await writeSkill( + extraRoot, + "gh-fix-ci", + ["---", "description: Fix CI failures", "---"].join("\n"), + "github", + ); + + const result = await searchSkills({ + cwd, + query: "$", + limit: 10, + codexHomePath: emptyCodexHome, + extraRoots: [extraRoot], + }); + + expect(result.skills.map((skill: { readonly name: string }) => skill.name)).toEqual([ + "local-review", + "gh-fix-ci", + ]); + expect(result.skills[1]).toMatchObject({ + source: "extra-root", + rootPath: extraRoot, + }); + }); +}); diff --git a/apps/server/src/skills.ts b/apps/server/src/skills.ts new file mode 100644 index 0000000000..1e7ab92943 --- /dev/null +++ b/apps/server/src/skills.ts @@ -0,0 +1,354 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { + SkillSearchInput, + SkillSearchResult, + SkillSource, + SkillSummary, +} from "@t3tools/contracts"; + +const SKILL_CACHE_TTL_MS = 15_000; +const SKILL_CACHE_MAX_KEYS = 8; + +interface SkillRoot { + readonly rootPath: string; + readonly source: SkillSource; +} + +interface DiscoveredSkill extends SkillSummary { + readonly priority: number; + readonly order: number; + readonly normalizedName: string; + readonly normalizedDirectoryName: string; + readonly normalizedDescription: string; +} + +interface CachedSkillIndex { + readonly discoveredAt: number; + readonly skills: readonly DiscoveredSkill[]; +} + +const skillIndexCache = new Map(); +const inFlightSkillIndexBuilds = new Map>(); + +function normalizeSkillToken(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function normalizeSearchQuery(input: string): string { + return input.trim().replace(/^\$+/, "").toLowerCase(); +} + +function cacheKeyForRoots(roots: readonly SkillRoot[]): string { + return JSON.stringify(roots); +} + +function touchCacheKey(key: string): void { + const cached = skillIndexCache.get(key); + if (!cached) { + return; + } + skillIndexCache.delete(key); + skillIndexCache.set(key, cached); + while (skillIndexCache.size > SKILL_CACHE_MAX_KEYS) { + const oldestKey = skillIndexCache.keys().next().value; + if (!oldestKey) { + break; + } + skillIndexCache.delete(oldestKey); + } +} + +function expandHomePath(input: string): string { + if (input === "~") { + return os.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(os.homedir(), input.slice(2)); + } + return input; +} + +async function statOrNull(targetPath: string): Promise> | null> { + try { + return await fs.stat(targetPath); + } catch { + return null; + } +} + +async function isDirectory(targetPath: string): Promise { + const stat = await statOrNull(targetPath); + return stat?.isDirectory() ?? false; +} + +async function listChildDirectories(rootPath: string): Promise { + try { + const entries = await fs.readdir(rootPath, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(rootPath, entry.name)) + .toSorted((left, right) => left.localeCompare(right)); + } catch { + return []; + } +} + +async function collectCandidateSkillDirs(rootPath: string): Promise { + if (!(await isDirectory(rootPath))) { + return []; + } + + const topLevelDirectories = await listChildDirectories(rootPath); + const nestedDirectories = await Promise.all( + topLevelDirectories.map((directoryPath) => listChildDirectories(directoryPath)), + ); + + return [...topLevelDirectories, ...nestedDirectories.flat()]; +} + +function extractFrontmatterBlock(contents: string): string | null { + if (!contents.startsWith("---")) { + return null; + } + + const normalizedContents = contents.replace(/\r\n/g, "\n"); + const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(normalizedContents); + return match?.[1] ?? null; +} + +function decodeYamlScalar(rawValue: string): string | null { + const trimmed = rawValue.trim(); + if ( + !trimmed || + trimmed === "|" || + trimmed === ">" || + trimmed === "[]" || + trimmed === "{}" || + trimmed.startsWith("[") || + trimmed.startsWith("{") + ) { + return null; + } + + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + const unquoted = trimmed.slice(1, -1); + if (trimmed.startsWith('"')) { + return ( + unquoted.replace(/\\\\/g, "\\").replace(/\\"/g, '"').replace(/\\n/g, "\n").trim() || null + ); + } + return unquoted.replace(/''/g, "'").trim() || null; + } + + return trimmed; +} + +function readFrontmatterField(frontmatter: string, field: "name" | "description"): string | null { + for (const rawLine of frontmatter.split("\n")) { + const match = new RegExp(`^${field}:[ \\t]*(.*)$`).exec(rawLine); + if (!match) { + continue; + } + return decodeYamlScalar(match[1] ?? ""); + } + return null; +} + +async function readSkillSummary(input: { + skillDirPath: string; + rootPath: string; + source: SkillSource; + priority: number; + order: number; +}): Promise { + const skillPath = path.join(input.skillDirPath, "SKILL.md"); + const stat = await statOrNull(skillPath); + if (!stat?.isFile()) { + return null; + } + + const fallbackName = path.basename(input.skillDirPath); + const contents = await fs.readFile(skillPath, "utf8").catch(() => null); + if (contents === null) { + return null; + } + + const frontmatter = extractFrontmatterBlock(contents); + const parsedName = frontmatter ? readFrontmatterField(frontmatter, "name") : null; + const parsedDescription = frontmatter ? readFrontmatterField(frontmatter, "description") : null; + const name = normalizeSkillToken(parsedName?.trim() || fallbackName); + + return { + name, + ...(parsedDescription?.trim() ? { description: parsedDescription.trim() } : {}), + skillPath, + rootPath: input.rootPath, + source: input.source, + priority: input.priority, + order: input.order, + normalizedName: normalizeSkillToken(name), + normalizedDirectoryName: normalizeSkillToken(fallbackName), + normalizedDescription: normalizeSkillToken(parsedDescription ?? ""), + }; +} + +async function discoverSkillsForRoot( + root: SkillRoot, + priority: number, +): Promise { + const candidateDirs = await collectCandidateSkillDirs(root.rootPath); + const discovered = await Promise.all( + candidateDirs.map((skillDirPath, index) => + readSkillSummary({ + skillDirPath, + rootPath: root.rootPath, + source: root.source, + priority, + order: priority * 10_000 + index, + }), + ), + ); + + return discovered.filter((skill): skill is DiscoveredSkill => skill !== null); +} + +function dedupeSkills(skills: readonly DiscoveredSkill[]): DiscoveredSkill[] { + const seen = new Set(); + const deduped: DiscoveredSkill[] = []; + + for (const skill of skills) { + if (!skill.normalizedName || seen.has(skill.normalizedName)) { + continue; + } + seen.add(skill.normalizedName); + deduped.push(skill); + } + + return deduped; +} + +async function buildSkillIndex(roots: readonly SkillRoot[]): Promise { + const discoveredByRoot = await Promise.all( + roots.map((root, index) => discoverSkillsForRoot(root, index)), + ); + + return { + discoveredAt: Date.now(), + skills: dedupeSkills(discoveredByRoot.flat()), + }; +} + +async function getCachedSkillIndex(roots: readonly SkillRoot[]): Promise { + const key = cacheKeyForRoots(roots); + const cached = skillIndexCache.get(key); + if (cached && Date.now() - cached.discoveredAt < SKILL_CACHE_TTL_MS) { + touchCacheKey(key); + return cached; + } + + const inFlight = inFlightSkillIndexBuilds.get(key); + if (inFlight) { + return inFlight; + } + + const build = buildSkillIndex(roots) + .then((built) => { + skillIndexCache.set(key, built); + touchCacheKey(key); + return built; + }) + .finally(() => { + inFlightSkillIndexBuilds.delete(key); + }); + + inFlightSkillIndexBuilds.set(key, build); + return build; +} + +function resolveSkillRoots(input: SkillSearchInput): SkillRoot[] { + const orderedRoots: SkillRoot[] = [ + { + rootPath: path.resolve(input.cwd, ".codex", "skills"), + source: "workspace", + }, + ...(input.extraRoots ?? []).map((rootPath) => ({ + rootPath: path.resolve(expandHomePath(rootPath)), + source: "extra-root" as const, + })), + { + rootPath: path.resolve( + expandHomePath(input.codexHomePath?.trim().length ? input.codexHomePath : "~/.codex"), + "skills", + ), + source: "codex-home", + }, + ]; + + const seenRoots = new Set(); + const dedupedRoots: SkillRoot[] = []; + + for (const root of orderedRoots) { + if (seenRoots.has(root.rootPath)) { + continue; + } + seenRoots.add(root.rootPath); + dedupedRoots.push(root); + } + + return dedupedRoots; +} + +function scoreSkill(skill: DiscoveredSkill, query: string): number { + if (!query) return skill.priority * 10; + if (skill.normalizedName === query) return skill.priority * 10; + if (skill.normalizedName.startsWith(query)) return skill.priority * 10 + 1; + if (skill.normalizedDirectoryName.startsWith(query)) return skill.priority * 10 + 2; + if (skill.normalizedName.includes(query)) return skill.priority * 10 + 3; + if (skill.normalizedDirectoryName.includes(query)) return skill.priority * 10 + 4; + if (skill.normalizedDescription.includes(query)) return skill.priority * 10 + 5; + return Number.POSITIVE_INFINITY; +} + +export async function searchSkills(input: SkillSearchInput): Promise { + const roots = resolveSkillRoots(input); + const skillIndex = await getCachedSkillIndex(roots); + const normalizedQuery = normalizeSearchQuery(input.query); + + const matchedSkills = skillIndex.skills + .map((skill) => ({ skill, score: scoreSkill(skill, normalizedQuery) })) + .filter((entry) => Number.isFinite(entry.score)) + .toSorted((left, right) => left.score - right.score || left.skill.order - right.skill.order) + .map(({ skill }) => { + if (skill.description) { + return { + name: skill.name, + description: skill.description, + skillPath: skill.skillPath, + rootPath: skill.rootPath, + source: skill.source, + } satisfies SkillSummary; + } + return { + name: skill.name, + skillPath: skill.skillPath, + rootPath: skill.rootPath, + source: skill.source, + } satisfies SkillSummary; + }); + + return { + skills: matchedSkills.slice(0, input.limit), + truncated: matchedSkills.length > input.limit, + }; +} diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 3279190141..ee42f8fc77 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -121,7 +121,7 @@ export const renderTerminalQrCode = (value: string, margin = 2): string => { export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): string => [ - "T3 Code server is ready.", + "ClayCode server is ready.", `Connection string: ${accessInfo.connectionString}`, `Token: ${accessInfo.token}`, `Pairing URL: ${accessInfo.pairingUrl}`, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fd256b32df..1b843a0a7f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -18,6 +18,7 @@ import { ProjectWriteFileError, OrchestrationReplayEventsError, FilesystemBrowseError, + SkillSearchError, ThreadId, type TerminalEvent, WS_METHODS, @@ -63,6 +64,8 @@ import { type SessionCredentialChange, } from "./auth/Services/SessionCredentialService.ts"; import { respondToAuthError } from "./auth/http.ts"; +import { CodexImport } from "./codexImport/Services/CodexImport.ts"; +import { searchSkills } from "./skills.ts"; function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< OrchestrationEvent, @@ -147,6 +150,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const startup = yield* ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; + const codexImport = yield* CodexImport; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const repositoryIdentityResolver = yield* RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment; @@ -783,6 +787,36 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => "rpc.aggregate": "server", }, ), + [WS_METHODS.codexImportListSessions]: (input) => + observeRpcEffect(WS_METHODS.codexImportListSessions, codexImport.listSessions(input), { + "rpc.aggregate": "codexImport", + }), + [WS_METHODS.codexImportPeekSession]: (input) => + observeRpcEffect(WS_METHODS.codexImportPeekSession, codexImport.peekSession(input), { + "rpc.aggregate": "codexImport", + }), + [WS_METHODS.codexImportImportSessions]: (input) => + observeRpcEffect( + WS_METHODS.codexImportImportSessions, + codexImport.importSessions(input), + { + "rpc.aggregate": "codexImport", + }, + ), + [WS_METHODS.skillsSearch]: (input) => + observeRpcEffect( + WS_METHODS.skillsSearch, + Effect.tryPromise({ + try: () => searchSkills(input), + catch: (cause) => + new SkillSearchError({ + message: + cause instanceof Error ? cause.message : "Failed to search local skills.", + cause, + }), + }), + { "rpc.aggregate": "skills" }, + ), [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index b6643d2a72..9e5e43863c 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -20,9 +20,9 @@ describe("branding", () => { value: { desktopBridge: { getAppBranding: () => ({ - baseName: "T3 Code", + baseName: "ClayCode", stageLabel: "Nightly", - displayName: "T3 Code (Nightly)", + displayName: "ClayCode (Nightly)", }), }, }, @@ -30,8 +30,8 @@ describe("branding", () => { const branding = await import("./branding"); - expect(branding.APP_BASE_NAME).toBe("T3 Code"); + expect(branding.APP_BASE_NAME).toBe("ClayCode"); expect(branding.APP_STAGE_LABEL).toBe("Nightly"); - expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); + expect(branding.APP_DISPLAY_NAME).toBe("ClayCode (Nightly)"); }); }); diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 99775a4c55..a03f64bd67 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -10,7 +10,7 @@ function readInjectedDesktopAppBranding(): DesktopAppBranding | null { const injectedDesktopAppBranding = readInjectedDesktopAppBranding(); -export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; +export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "ClayCode"; export const APP_STAGE_LABEL = injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha"); export const APP_DISPLAY_NAME = diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 445fe19305..1500ea1625 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -9,18 +9,20 @@ import { type MessageId, type OrchestrationReadModel, type ProjectId, - ProviderDriverKind, - ProviderInstanceId, type ServerConfig, type ServerLifecycleWelcomePayload, - type ThreadId, + ThreadId, type TurnId, WS_METHODS, OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; -import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; @@ -30,6 +32,11 @@ import { render } from "vitest-browser-react"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { useComposerDraftStore, DraftId } from "../composerDraftStore"; +import { useGlobalThreadSearchStore } from "../globalThreadSearchStore"; +import { useQuickThreadSearchStore } from "../quickThreadSearchStore"; +import { useProjectFolderSearchStore } from "../projectFolderSearchStore"; +import { useSkillPickerStore } from "../skillPickerStore"; +import { useSnippetPickerStore } from "../snippetPickerStore"; import { __resetEnvironmentApiOverridesForTests, __setEnvironmentApiOverrideForTests, @@ -50,12 +57,13 @@ import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; -import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; +import { useQueuedTurnStore } from "../queuedTurnStore"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; +import { COMPOSER_SNIPPETS, SAVED_COMPOSER_SNIPPETS_STORAGE_KEY } from "./chat/composerSnippets"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; @@ -77,18 +85,7 @@ const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); const THREAD_KEY = scopedThreadKey(THREAD_REF); const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; -const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings( - { - environmentId: LOCAL_ENVIRONMENT_ID, - id: PROJECT_ID, - cwd: "/repo/project", - repositoryIdentity: null, - }, - { - sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingOverrides, - }, -); +const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID)); const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; @@ -169,8 +166,9 @@ function createBaseServerConfig(): ServerConfig { issues: [], providers: [ { - driver: ProviderDriverKind.make("codex"), - instanceId: ProviderInstanceId.make("codex"), + instanceId: "codex" as any, + driver: "codex" as any, + provider: "codex" as any, enabled: true, installed: true, version: "0.116.0", @@ -326,7 +324,7 @@ function createSnapshotForTargetUser(options: { title: "Project", workspaceRoot: "/repo/project", defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), + instanceId: "codex" as any, model: "gpt-5", }, scripts: [], @@ -341,7 +339,7 @@ function createSnapshotForTargetUser(options: { projectId: PROJECT_ID, title: THREAD_TITLE, modelSelection: { - instanceId: ProviderInstanceId.make("codex"), + instanceId: "codex" as any, model: "gpt-5", }, interactionMode: "default", @@ -406,7 +404,7 @@ function addThreadToSnapshot( projectId: PROJECT_ID, title: "New thread", modelSelection: { - instanceId: ProviderInstanceId.make("codex"), + instanceId: "codex" as any, model: "gpt-5", }, interactionMode: "default", @@ -743,7 +741,7 @@ function createSnapshotWithSecondaryProject(options?: { id: "thread-secondary-project" as ThreadId, projectId: SECOND_PROJECT_ID, title: "Release checklist", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + modelSelection: { instanceId: "codex" as any, model: "gpt-5" }, interactionMode: "default", runtimeMode: "full-access", branch: "release/docs-portal", @@ -775,7 +773,7 @@ function createSnapshotWithSecondaryProject(options?: { id: ARCHIVED_SECONDARY_THREAD_ID, projectId: SECOND_PROJECT_ID, title: "Archived Docs Notes", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + modelSelection: { instanceId: "codex" as any, model: "gpt-5" }, interactionMode: "default", runtimeMode: "full-access", branch: "release/docs-archive", @@ -810,7 +808,7 @@ function createSnapshotWithSecondaryProject(options?: { id: SECOND_PROJECT_ID, title: "Docs Portal", workspaceRoot: "/repo/clients/docs-portal", - defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + defaultModelSelection: { instanceId: "codex" as any, model: "gpt-5" }, scripts: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, @@ -887,7 +885,7 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel { } function createSnapshotWithPlanFollowUpPrompt(options?: { - modelSelection?: { instanceId: ProviderInstanceId; model: string }; + modelSelection?: { instanceId: any; model: string }; planMarkdown?: string; }): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ @@ -895,7 +893,7 @@ function createSnapshotWithPlanFollowUpPrompt(options?: { targetText: "plan follow-up thread", }); const modelSelection = options?.modelSelection ?? { - instanceId: ProviderInstanceId.make("codex"), + instanceId: "codex" as any, model: "gpt-5", }; const planMarkdown = @@ -942,6 +940,23 @@ function createSnapshotWithPlanFollowUpPrompt(options?: { }; } +function createRunningSnapshot(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-running-target" as MessageId, + targetText: "running queue thread", + }); + + return updateThreadSessionInSnapshot(snapshot, THREAD_ID, { + threadId: THREAD_ID, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: "turn-running-queue" as TurnId, + lastError: null, + updatedAt: isoAt(60), + }); +} + function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { const customResult = customWsRpcResolver?.(body); if (customResult !== undefined) { @@ -973,6 +988,12 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { truncated: false, }; } + if (tag === WS_METHODS.skillsSearch) { + return { + skills: [], + truncated: false, + }; + } if (tag === WS_METHODS.shellOpenInEditor) { return null; } @@ -1388,12 +1409,98 @@ function dispatchChatNewShortcut(): void { ); } -function releaseModShortcut(key?: string): void { +function dispatchOpenSnippetsShortcut(): void { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "s", + shiftKey: true, + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); +} + +function dispatchOpenSkillPickerShortcut(): void { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + shiftKey: true, + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); +} + +function dispatchOpenQuickThreadSearchShortcut(): void { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "f", + shiftKey: true, + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); +} + +function dispatchOpenGlobalThreadSearchShortcut(): void { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "f", + altKey: true, + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); +} + +function dispatchOpenProjectFolderSearchShortcut(): void { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "p", + altKey: true, + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); +} + +function dispatchOpenCodexImportShortcut(): void { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "i", + shiftKey: true, + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); +} + +function dispatchRenameSidebarThreadShortcut(): void { + const useMetaForMod = isMacPlatform(navigator.platform); window.dispatchEvent( - new KeyboardEvent("keyup", { - key: key ?? (isMacPlatform(navigator.platform) ? "Meta" : "Control"), - metaKey: false, - ctrlKey: false, + new KeyboardEvent("keydown", { + key: "r", + shiftKey: true, + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, bubbles: true, cancelable: true, }), @@ -1483,6 +1590,16 @@ async function dispatchInputKey( await waitForLayout(); } +function modShortcutModifiers(init?: Pick) { + const useMetaForMod = isMacPlatform(navigator.platform); + return { + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + shiftKey: init?.shiftKey ?? false, + altKey: init?.altKey ?? false, + } satisfies Pick; +} + async function mountChatView(options: { viewport: ViewportSpec; snapshot: OrchestrationReadModel; @@ -1639,6 +1756,29 @@ describe("ChatView timeline estimator parity (full app)", () => { open: false, openIntent: null, }); + useQueuedTurnStore.setState({ + threadsByThreadKey: {}, + }); + useSnippetPickerStore.setState({ + open: false, + focusRequestId: 0, + }); + useSkillPickerStore.setState({ + open: false, + focusRequestId: 0, + }); + useQuickThreadSearchStore.setState({ + open: false, + focusRequestId: 0, + }); + useGlobalThreadSearchStore.setState({ + open: false, + focusRequestId: 0, + }); + useProjectFolderSearchStore.setState({ + open: false, + focusRequestId: 0, + }); useStore.setState({ activeEnvironmentId: null, environmentStateById: {}, @@ -1659,14 +1799,37 @@ describe("ChatView timeline estimator parity (full app)", () => { afterEach(() => { customWsRpcResolver = null; + useSnippetPickerStore.setState({ + open: false, + focusRequestId: 0, + }); + useSkillPickerStore.setState({ + open: false, + focusRequestId: 0, + }); + useQuickThreadSearchStore.setState({ + open: false, + focusRequestId: 0, + }); + useGlobalThreadSearchStore.setState({ + open: false, + focusRequestId: 0, + }); + useProjectFolderSearchStore.setState({ + open: false, + focusRequestId: 0, + }); + useQueuedTurnStore.setState({ + threadsByThreadKey: {}, + }); document.body.innerHTML = ""; }); - it("re-expands the bootstrap project using its logical key", async () => { + it("re-expands the bootstrap project using its scoped key", async () => { useUiStateStore.setState({ projectExpandedById: { - [PROJECT_LOGICAL_KEY]: false, + [PROJECT_KEY]: false, }, - projectOrder: [PROJECT_LOGICAL_KEY], + projectOrder: [PROJECT_KEY], threadLastVisitedAtById: {}, }); @@ -1681,7 +1844,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { await vi.waitFor( () => { - expect(useUiStateStore.getState().projectExpandedById[PROJECT_LOGICAL_KEY]).toBe(true); + expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true); }, { timeout: 8_000, interval: 16 }, ); @@ -2441,126 +2604,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("keeps custom provider instance ids when bootstrapping a local draft thread", async () => { - setDraftThreadWithoutWorktree(); - const openRouterInstanceId = ProviderInstanceId.make("claude_openrouter"); - const openRouterSelection = createModelSelection(openRouterInstanceId, "openai/gpt-5.5"); - useComposerDraftStore.getState().setModelSelection(THREAD_REF, openRouterSelection); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - providers: [ - ...nextFixture.serverConfig.providers, - { - driver: ProviderDriverKind.make("claudeAgent"), - instanceId: ProviderInstanceId.make("claudeAgent"), - enabled: true, - installed: true, - version: "2.1.117", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: NOW_ISO, - models: [ - { - slug: "claude-opus-4-7", - name: "Claude Opus 4.7", - isCustom: false, - capabilities: createModelCapabilities({ optionDescriptors: [] }), - }, - ], - slashCommands: [], - skills: [], - }, - { - driver: ProviderDriverKind.make("claudeAgent"), - instanceId: openRouterInstanceId, - displayName: "Claude OpenRouter", - enabled: true, - installed: true, - version: "2.1.117", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: NOW_ISO, - models: [ - { - slug: "claude-opus-4-7", - name: "Claude Opus 4.7", - isCustom: false, - capabilities: createModelCapabilities({ optionDescriptors: [] }), - }, - ], - slashCommands: [], - skills: [], - }, - ], - settings: { - ...nextFixture.serverConfig.settings, - providerInstances: { - ...nextFixture.serverConfig.settings.providerInstances, - [openRouterInstanceId]: { - driver: ProviderDriverKind.make("claudeAgent"), - displayName: "Claude OpenRouter", - config: { customModels: ["openai/gpt-5.5"] }, - }, - }, - }, - }; - }, - resolveRpc: (body) => { - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - return undefined; - }, - }); - - try { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "Hello there"); - await waitForLayout(); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(false); - sendButton.click(); - - await vi.waitFor( - () => { - const turnStartRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "thread.turn.start", - ) as - | { - modelSelection?: { instanceId?: string; model?: string }; - bootstrap?: { - createThread?: { - modelSelection?: { instanceId?: string; model?: string }; - }; - }; - } - | undefined; - - expect(turnStartRequest?.modelSelection).toMatchObject({ - instanceId: openRouterInstanceId, - model: "openai/gpt-5.5", - }); - expect(turnStartRequest?.bootstrap?.createThread?.modelSelection).toMatchObject({ - instanceId: openRouterInstanceId, - model: "openai/gpt-5.5", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - it("keeps new-worktree mode on empty server threads and bootstraps the first send", async () => { const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID); const mounted = await mountChatView({ @@ -3901,16 +3944,16 @@ describe("ChatView timeline estimator parity (full app)", () => { it("snapshots sticky codex settings into a new draft thread", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { - [ProviderInstanceId.make("codex")]: createModelSelection( - ProviderInstanceId.make("codex"), - "gpt-5.3-codex", - [ + codex: { + instanceId: "codex" as any, + model: "gpt-5.3-codex", + options: [ { id: "reasoningEffort", value: "medium" }, { id: "fastMode", value: true }, ], - ), + }, }, - stickyActiveProvider: ProviderInstanceId.make("codex"), + stickyActiveProvider: "codex", }); const mounted = await mountChatView({ @@ -3934,16 +3977,12 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const newDraftId = draftIdFromPath(newThreadPath); - // `toMatchObject` matches objects loosely (extras ignored) but compares - // arrays strictly, so wrap `options` in `arrayContaining` to keep the - // assertion focused on sticky `fastMode` carrying over without asserting - // on exactly which other options are preserved. expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { codex: { - instanceId: ProviderInstanceId.make("codex"), + instanceId: "codex" as any, model: "gpt-5.3-codex", - options: expect.arrayContaining([{ id: "fastMode", value: true }]), + options: [{ id: "fastMode", value: true }], }, }, activeProvider: "codex", @@ -3956,16 +3995,16 @@ describe("ChatView timeline estimator parity (full app)", () => { it("hydrates the provider alongside a sticky claude model", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { - [ProviderInstanceId.make("claudeAgent")]: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [ + claudeAgent: { + instanceId: "claudeAgent" as any, + model: "claude-opus-4-6", + options: [ { id: "effort", value: "max" }, { id: "fastMode", value: true }, ], - ), + }, }, - stickyActiveProvider: ProviderInstanceId.make("claudeAgent"), + stickyActiveProvider: "claudeAgent", }); const mounted = await mountChatView({ @@ -3991,14 +4030,14 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { - claudeAgent: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [ + claudeAgent: { + instanceId: "claudeAgent" as any, + model: "claude-opus-4-6", + options: [ { id: "effort", value: "max" }, { id: "fastMode", value: true }, ], - ), + }, }, activeProvider: "claudeAgent", }); @@ -4035,19 +4074,19 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("prefers draft state over sticky composer settings and defaults", async () => { + it("creates a fresh draft instead of reusing the existing project draft thread", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { - [ProviderInstanceId.make("codex")]: createModelSelection( - ProviderInstanceId.make("codex"), - "gpt-5.3-codex", - [ + codex: { + instanceId: "codex" as any, + model: "gpt-5.3-codex", + options: [ { id: "reasoningEffort", value: "medium" }, { id: "fastMode", value: true }, ], - ), + }, }, - stickyActiveProvider: ProviderInstanceId.make("codex"), + stickyActiveProvider: "codex", }); const mounted = await mountChatView({ @@ -4071,41 +4110,60 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const draftId = draftIdFromPath(threadPath); - // See the note on the sibling sticky-codex test: arrays match strictly - // under `toMatchObject`, so use `arrayContaining` to keep the assertion - // scoped to the sticky trait (`fastMode`) that must carry over. expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { codex: { - instanceId: ProviderInstanceId.make("codex"), + instanceId: "codex" as any, model: "gpt-5.3-codex", - options: expect.arrayContaining([{ id: "fastMode", value: true }]), + options: [{ id: "fastMode", value: true }], }, }, activeProvider: "codex", }); - useComposerDraftStore.getState().setModelSelection( - draftId, - createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + useComposerDraftStore.getState().setModelSelection(draftId, { + instanceId: "codex" as any, + model: "gpt-5.4", + options: [ { id: "reasoningEffort", value: "low" }, { id: "fastMode", value: true }, - ]), - ); + ], + }); await newThreadButton.click(); - await waitForURL( + const secondThreadPath = await waitForURL( mounted.router, - (path) => path === threadPath, - "New-thread should reuse the existing project draft thread.", + (path) => UUID_ROUTE_RE.test(path) && path !== threadPath, + "New-thread should create a fresh draft thread.", ); + const secondDraftId = draftIdFromPath(secondThreadPath); + expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { - codex: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ - { id: "reasoningEffort", value: "low" }, - { id: "fastMode", value: true }, - ]), + codex: { + instanceId: "codex" as any, + model: "gpt-5.4", + options: { + reasoningEffort: "low", + fastMode: true, + }, + }, + }, + activeProvider: "codex", + }); + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toMatchObject({ + projectId: PROJECT_ID, + }); + expect(composerDraftFor(secondDraftId)).toMatchObject({ + modelSelectionByProvider: { + codex: { + instanceId: "codex" as any, + model: "gpt-5.3-codex", + options: { + fastMode: true, + }, + }, }, activeProvider: "codex", }); @@ -4114,58 +4172,523 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("creates a new thread from the global chat.new shortcut", async () => { + it("disables send when the thread project is unavailable", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-chat-shortcut-test" as MessageId, - targetText: "chat shortcut test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.new", - shortcut: { - key: "o", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - { - command: "thread.jump.1", - shortcut: { - key: "1", - metaKey: true, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: false, - }, - }, - { - command: "modelPicker.jump.1", - shortcut: { - key: "1", - metaKey: true, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: false, - }, - whenAst: { type: "identifier", name: "modelPickerOpen" }, - }, - ], - }; - }, + snapshot: { + ...createSnapshotForTargetUser({ + targetMessageId: "msg-user-missing-project-send-target" as MessageId, + targetText: "missing project send target", + }), + projects: [], + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Blocked without active project"); + await waitForLayout(); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(true); + + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }), + ); + + await new Promise((resolve) => window.setTimeout(resolve, 150)); + expect( + wsRequests.some( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ), + ).toBe(false); + } finally { + await mounted.cleanup(); + } + }); + + it("queues with Tab during a running turn and auto-dispatches when the thread settles", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createRunningSnapshot(), + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Queue this when ready"); + await waitForLayout(); + + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor( + () => { + expect( + useQueuedTurnStore.getState().threadsByThreadKey[threadKeyFor(THREAD_ID)]?.items, + ).toHaveLength(1); + expect(composerDraftFor(THREAD_KEY)?.prompt ?? "").toBe(""); + }, + { timeout: 8_000, interval: 16 }, + ); + + fixture.snapshot = updateThreadSessionInSnapshot(createRunningSnapshot(), THREAD_ID, { + threadId: THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: isoAt(61), + }); + sendShellThreadUpsert(THREAD_ID); + + await vi.waitFor( + () => { + const turnStartRequest = [...wsRequests] + .toReversed() + .find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ) as + | { + command?: { message?: { text?: string } }; + message?: { text?: string }; + } + | undefined; + + const messageText = + turnStartRequest && "message" in turnStartRequest + ? turnStartRequest.message?.text + : undefined; + expect(messageText).toBe("Queue this when ready"); + expect(useQueuedTurnStore.getState().threadsByThreadKey[threadKeyFor(THREAD_ID)]).toBe( + undefined, + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("steers immediately with Enter during a running turn", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createRunningSnapshot(), + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Steer this right now"); + await waitForLayout(); + + const beforeCount = wsRequests.filter( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ).length; + + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor( + () => { + const turnStarts = wsRequests.filter( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ) as Array<{ message?: { text?: string } }>; + expect(turnStarts.length).toBeGreaterThan(beforeCount); + expect(turnStarts.at(-1)?.message?.text).toBe("Steer this right now"); + }, + { timeout: 8_000, interval: 16 }, + ); + + expect(useQueuedTurnStore.getState().threadsByThreadKey[threadKeyFor(THREAD_ID)]).toBe( + undefined, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("steers immediately with mod+Enter during a running turn", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createRunningSnapshot(), + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Steer with shortcut"); + await waitForLayout(); + + const beforeCount = wsRequests.filter( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ).length; + + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + ...modShortcutModifiers(), + }), + ); + + await vi.waitFor( + () => { + const turnStarts = wsRequests.filter( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ) as Array<{ message?: { text?: string } }>; + expect(turnStarts.length).toBeGreaterThan(beforeCount); + expect(turnStarts.at(-1)?.message?.text).toBe("Steer with shortcut"); + }, + { timeout: 8_000, interval: 16 }, + ); + + expect(useQueuedTurnStore.getState().threadsByThreadKey[threadKeyFor(THREAD_ID)]).toBe( + undefined, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("queues a follow-up to the front with mod+Shift+Enter during a running turn", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createRunningSnapshot(), + }); + + try { + useQueuedTurnStore.getState().enqueue(THREAD_REF, { + id: "queued-existing", + text: "Queued follow-up", + createdAt: isoAt(160), + images: [], + persistedAttachments: [], + terminalContexts: [], + modelSelection: { instanceId: "codex" as any, model: "gpt-5" }, + promptEffort: null, + runtimeMode: "full-access", + interactionMode: "default", + }); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Put this next"); + await waitForLayout(); + + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + ...modShortcutModifiers({ shiftKey: true }), + }), + ); + + await vi.waitFor( + () => { + expect( + useQueuedTurnStore + .getState() + .getQueue(THREAD_REF) + .map((entry) => entry.text), + ).toEqual(["Put this next", "Queued follow-up"]); + expect( + useComposerDraftStore.getState().draftsByThreadKey[threadKeyFor(THREAD_ID)]?.prompt ?? + "", + ).toBe(""); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("shows queue controls during a running turn and removes queued follow-ups from the panel", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createRunningSnapshot(), + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Queue from button"); + await waitForLayout(); + + await page.getByRole("button", { name: "Queue", exact: true }).click(); + + await vi.waitFor( + () => { + expect( + useQueuedTurnStore.getState().threadsByThreadKey[threadKeyFor(THREAD_ID)]?.items, + ).toHaveLength(1); + expect(document.body.textContent ?? "").toContain("1 queued follow-up"); + expect(document.body.textContent ?? "").toContain( + "Waiting for the current turn to settle.", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + await expect + .element(page.getByRole("button", { name: "Send queued follow-up now" })) + .toBeDisabled(); + + await page.getByRole("button", { name: "Remove queued follow-up" }).click(); + + await vi.waitFor( + () => { + expect(useQueuedTurnStore.getState().threadsByThreadKey[threadKeyFor(THREAD_ID)]).toBe( + undefined, + ); + expect(document.body.textContent ?? "").not.toContain("queued follow-up"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("clears queued follow-ups during a running turn without dispatching after settle", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createRunningSnapshot(), + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, + }); + + try { + useQueuedTurnStore.getState().enqueue(THREAD_REF, { + id: "queued-clear-first", + text: "This cleared queue item should not dispatch", + createdAt: isoAt(170), + images: [], + persistedAttachments: [], + terminalContexts: [], + modelSelection: { instanceId: "codex" as any, model: "gpt-5" }, + promptEffort: null, + runtimeMode: "full-access", + interactionMode: "default", + }); + useQueuedTurnStore.getState().enqueue(THREAD_REF, { + id: "queued-clear-second", + text: "This second cleared queue item should not dispatch", + createdAt: isoAt(171), + images: [], + persistedAttachments: [], + terminalContexts: [], + modelSelection: { instanceId: "codex" as any, model: "gpt-5" }, + promptEffort: null, + runtimeMode: "full-access", + interactionMode: "default", + }); + await waitForLayout(); + + await vi.waitFor( + () => { + expect(document.body.textContent ?? "").toContain("2 queued follow-ups"); + }, + { timeout: 8_000, interval: 16 }, + ); + + await page.getByRole("button", { name: "Clear all" }).click(); + + await vi.waitFor( + () => { + expect(useQueuedTurnStore.getState().threadsByThreadKey[threadKeyFor(THREAD_ID)]).toBe( + undefined, + ); + expect(document.body.textContent ?? "").not.toContain("queued follow-up"); + }, + { timeout: 8_000, interval: 16 }, + ); + + const beforeSettledTurnStarts = wsRequests.filter( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ).length; + + fixture.snapshot = updateThreadSessionInSnapshot(createRunningSnapshot(), THREAD_ID, { + threadId: THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: isoAt(172), + }); + sendShellThreadUpsert(THREAD_ID); + await waitForLayout(); + + expect( + wsRequests.filter( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ), + ).toHaveLength(beforeSettledTurnStarts); + } finally { + await mounted.cleanup(); + } + }); + + it("saves a queued follow-up into the shortcut-opened snippet picker", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createRunningSnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "snippets.open", + shortcut: { + key: "s", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Queued snippet from queue panel"); + await waitForLayout(); + + await page.getByRole("button", { name: "Queue", exact: true }).click(); + + await vi.waitFor( + () => { + expect( + useQueuedTurnStore.getState().threadsByThreadKey[threadKeyFor(THREAD_ID)]?.items, + ).toHaveLength(1); + }, + { timeout: 8_000, interval: 16 }, + ); + + await page.getByRole("button", { name: "Save queued follow-up as snippet" }).click(); + + await vi.waitFor( + () => { + const raw = localStorage.getItem(SAVED_COMPOSER_SNIPPETS_STORAGE_KEY); + expect(raw).toBeTruthy(); + expect(raw).toContain("Queued snippet from queue panel"); + }, + { timeout: 8_000, interval: 16 }, + ); + + dispatchOpenSnippetsShortcut(); + await waitForElement( + () => document.querySelector('[data-testid="snippet-picker-input"]'), + "Unable to find snippet picker input.", + ); + await expect + .element(page.getByRole("button", { name: /Queued snippet from queue panel/i })) + .toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + + it("creates a new thread from the global chat.new shortcut", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-chat-shortcut-test" as MessageId, + targetText: "chat shortcut test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "chat.new", + shortcut: { + key: "o", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, }); try { @@ -5574,10 +6097,7 @@ describe("ChatView timeline estimator parity (full app)", () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, snapshot: createSnapshotWithPlanFollowUpPrompt({ - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.3-codex-spark", - }, + modelSelection: { instanceId: "codex" as any, model: "gpt-5.3-codex-spark" }, planMarkdown: "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", }), @@ -5607,10 +6127,7 @@ describe("ChatView timeline estimator parity (full app)", () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, snapshot: createSnapshotWithPlanFollowUpPrompt({ - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.3-codex-spark", - }, + modelSelection: { instanceId: "codex" as any, model: "gpt-5.3-codex-spark" }, planMarkdown: "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", }), @@ -5661,20 +6178,287 @@ describe("ChatView timeline estimator parity (full app)", () => { () => document.querySelector('[data-chat-composer-form="true"]'), "Unable to find composer form.", ); - + + await vi.waitFor( + () => { + const menuRect = menuItem.getBoundingClientRect(); + const composerRect = composerForm.getBoundingClientRect(); + const hitTarget = document.elementFromPoint( + menuRect.left + menuRect.width / 2, + menuRect.top + menuRect.height / 2, + ); + + expect(menuRect.width).toBeGreaterThan(0); + expect(menuRect.height).toBeGreaterThan(0); + expect(menuRect.bottom).toBeLessThanOrEqual(composerRect.bottom); + expect(hitTarget instanceof Element && menuItem.contains(hitTarget)).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("shows /snippet in the built-in slash command menu", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-snippet-command-target" as MessageId, + targetText: "snippet command thread", + }), + }); + + try { + await waitForComposerEditor(); + await page.getByTestId("composer-editor").fill("/"); + + const menuItem = await waitForComposerMenuItem("slash:snippet"); + await expect.element(menuItem).toBeInTheDocument(); + expect(menuItem.textContent).toContain("/snippet"); + } finally { + await mounted.cleanup(); + } + }); + + it("opens the snippet picker from the composer button and inserts a snippet after existing text", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-snippet-insert-target" as MessageId, + targetText: "snippet insert thread", + }), + }); + + try { + const debugSnippet = COMPOSER_SNIPPETS.find((snippet) => snippet.id === "debug-issue"); + if (!debugSnippet) { + throw new Error("Expected debug-issue snippet to exist."); + } + + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Existing context"); + await waitForComposerText("Existing context"); + + await page.getByLabelText("Open snippet picker").click(); + await waitForElement( + () => document.querySelector('[data-testid="snippet-picker-input"]'), + "Unable to find snippet picker input.", + ); + await page.getByTestId("snippet-picker-input").fill("debug"); + await page.getByRole("button", { name: /Debug Issue/i }).click(); + + await waitForComposerText(`Existing context${debugSnippet.body}`); + } finally { + await mounted.cleanup(); + } + }); + + it("opens the snippet picker from the global shortcut and inserts a saved snippet", async () => { + localStorage.setItem( + SAVED_COMPOSER_SNIPPETS_STORAGE_KEY, + JSON.stringify([ + { + id: "saved-snippet-1", + body: "Saved reusable snippet", + createdAt: "2026-04-16T10:00:00.000Z", + updatedAt: "2026-04-16T10:00:00.000Z", + }, + ]), + ); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-snippet-shortcut-target" as MessageId, + targetText: "snippet shortcut thread", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "snippets.open", + shortcut: { + key: "s", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForComposerEditor(); + dispatchOpenSnippetsShortcut(); + await waitForElement( + () => document.querySelector('[data-testid="snippet-picker-input"]'), + "Unable to find snippet picker input.", + ); + await page.getByRole("button", { name: /Saved reusable snippet/i }).click(); + await waitForComposerText("Saved reusable snippet"); + } finally { + await mounted.cleanup(); + } + }); + + it("opens the skill picker from the global shortcut and inserts a skill reference block", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-skill-shortcut-target" as MessageId, + targetText: "skill picker thread", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "skills.open", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.skillsSearch) { + return { + skills: [ + { + name: "agent-browser", + description: "Open pages, click around, and inspect web apps.", + skillPath: "/Users/test/.codex/skills/agent-browser/SKILL.md", + rootPath: "/Users/test/.codex/skills", + source: "workspace", + }, + ], + truncated: false, + }; + } + return undefined; + }, + }); + + try { + await waitForComposerEditor(); + dispatchOpenSkillPickerShortcut(); + await waitForElement( + () => document.querySelector('[data-testid="skill-picker-input"]'), + "Unable to find skill picker input.", + ); + await page.getByRole("button", { name: /agent-browser/i }).click(); + await waitForComposerText( + [ + "## Use skill: agent-browser", + "Open pages, click around, and inspect web apps.", + "", + "Read the full instructions from: /Users/test/.codex/skills/agent-browser/SKILL.md", + ].join("\n"), + ); + expect(wsRequests.some((request) => request._tag === WS_METHODS.skillsSearch)).toBe(true); + } finally { + await mounted.cleanup(); + } + }); + + it("opens sidebar rename from the global shortcut and submits the rename", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sidebar-rename-target" as MessageId, + targetText: "sidebar rename thread", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "sidebar.rename", + shortcut: { + key: "r", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, + }); + + try { + await page.getByTestId(`thread-row-${THREAD_ID}`).click(); + dispatchRenameSidebarThreadShortcut(); + const renameInput = await waitForElement( + () => + document.querySelector( + `[data-testid="thread-rename-input-${THREAD_ID}"]`, + ), + "Unable to find sidebar rename input.", + ); + expect(renameInput.value).toBe(THREAD_TITLE); + await page.getByTestId(`thread-rename-input-${THREAD_ID}`).fill("Renamed browser thread"); + await dispatchInputKey(renameInput, { + key: "Enter", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + }); await vi.waitFor( () => { - const menuRect = menuItem.getBoundingClientRect(); - const composerRect = composerForm.getBoundingClientRect(); - const hitTarget = document.elementFromPoint( - menuRect.left + menuRect.width / 2, - menuRect.top + menuRect.height / 2, - ); + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.meta.update", + ) as + | { + _tag: string; + type?: string; + threadId?: string; + title?: string; + } + | undefined; - expect(menuRect.width).toBeGreaterThan(0); - expect(menuRect.height).toBeGreaterThan(0); - expect(menuRect.bottom).toBeLessThanOrEqual(composerRect.bottom); - expect(hitTarget instanceof Element && menuItem.contains(hitTarget)).toBe(true); + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "thread.meta.update", + threadId: THREAD_ID, + title: "Renamed browser thread", + }); }, { timeout: 8_000, interval: 16 }, ); @@ -5683,150 +6467,170 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("opens the model picker when selecting /model", async () => { + it("opens quick thread search from the global shortcut and navigates to a recent match", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-model-command-target" as MessageId, - targetText: "model command thread", - }), + snapshot: createSnapshotWithSecondaryProject({ includeArchivedSecondaryThread: false }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "threads.search", + shortcut: { + key: "f", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + nextFixture.snapshot = { + ...nextFixture.snapshot, + threads: nextFixture.snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? { + ...thread, + title: "Local coding notes", + messages: [ + createUserMessage({ + id: "msg-user-quick-thread-current" as MessageId, + text: "capture notes for later", + offsetSeconds: 3, + }), + ], + updatedAt: isoAt(3), + } + : thread.id === ("thread-secondary-project" as ThreadId) + ? { + ...thread, + messages: [ + createUserMessage({ + id: "msg-user-quick-thread-secondary" as MessageId, + text: "please debug the release checklist before launch", + offsetSeconds: 45, + }), + ], + updatedAt: isoAt(45), + } + : thread, + ), + }; + }, }); try { - await waitForComposerEditor(); - await page.getByTestId("composer-editor").fill("/mod"); - - const menuItem = await waitForComposerMenuItem("slash:model"); - await menuItem.click(); - - await vi.waitFor(() => { - expect(document.querySelector(".model-picker-list")).not.toBeNull(); - expect(findComposerProviderModelPicker()?.textContent).not.toContain("/model"); - }); - - await new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => resolve()); - }); - }); - + dispatchOpenQuickThreadSearchShortcut(); + await waitForElement( + () => document.querySelector('[data-testid="quick-thread-search-input"]'), + "Unable to find quick thread search input.", + ); + await page.getByTestId("quick-thread-search-input").fill("release"); + await page.getByRole("button", { name: /Release checklist/i }).click(); await vi.waitFor(() => { - const searchInput = document.querySelector( - 'input[placeholder="Search models..."]', + expect(mounted.router.state.location.pathname).toBe( + serverThreadPath("thread-secondary-project" as ThreadId), ); - expect(searchInput).not.toBeNull(); - expect(document.activeElement).toBe(searchInput); }); } finally { await mounted.cleanup(); } }); - it("toggles the model picker and shows jump keys immediately from the shortcut", async () => { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-model-picker-shortcut-target" as MessageId, - targetText: "model picker shortcut thread", - }); + it("opens global thread search from the global shortcut and navigates to a deep content match", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: { - ...snapshot, - projects: snapshot.projects.map((project) => - project.id === PROJECT_ID - ? Object.assign({}, project, { - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }, - }) - : project, - ), - threads: snapshot.threads.map((thread) => - thread.id === THREAD_ID - ? Object.assign({}, thread, { - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - }) - : thread, - ), - }, + snapshot: createSnapshotWithSecondaryProject({ includeArchivedSecondaryThread: false }), configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, keybindings: [ { - command: "modelPicker.toggle", + command: "threads.searchAll", shortcut: { - key: "m", + key: "f", metaKey: false, - ctrlKey: true, - shiftKey: true, - altKey: false, - modKey: false, + ctrlKey: false, + shiftKey: false, + altKey: true, + modKey: true, }, whenAst: { type: "not", node: { type: "identifier", name: "terminalFocus" }, }, }, + ], + }; + nextFixture.snapshot = { + ...nextFixture.snapshot, + threads: nextFixture.snapshot.threads.map((thread) => + thread.id === ("thread-secondary-project" as ThreadId) + ? { + ...thread, + messages: [ + createAssistantMessage({ + id: "msg-assistant-global-thread-secondary" as MessageId, + text: "The queue recovery checklist is ready for launch.", + offsetSeconds: 52, + }), + ], + updatedAt: isoAt(52), + } + : thread, + ), + }; + }, + }); + + try { + dispatchOpenGlobalThreadSearchShortcut(); + await waitForElement( + () => + document.querySelector('[data-testid="global-thread-search-input"]'), + "Unable to find global thread search input.", + ); + await page.getByTestId("global-thread-search-input").fill("queue recovery"); + await page.getByRole("button", { name: /Release checklist/i }).click(); + await vi.waitFor(() => { + expect(mounted.router.state.location.pathname).toBe( + serverThreadPath("thread-secondary-project" as ThreadId), + ); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("opens project folder search from the global shortcut and starts a new thread in the selected project", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSecondaryProject(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ { - command: "thread.jump.1", + command: "projects.search", shortcut: { - key: "1", + key: "p", metaKey: false, - ctrlKey: true, + ctrlKey: false, shiftKey: false, - altKey: false, - modKey: false, + altKey: true, + modKey: true, }, - }, - { - command: "modelPicker.jump.1", - shortcut: { - key: "1", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: false, - modKey: false, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, }, - whenAst: { type: "identifier", name: "modelPickerOpen" }, - }, - ], - providers: [ - { - ...nextFixture.serverConfig.providers[0]!, - models: [ - { - slug: "gpt-5.1-codex-max", - name: "GPT-5.1 Codex Max", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, - ], - }), - }, - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, - ], - }), - }, - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, - ], - }), - }, - ], }, ], }; @@ -5834,49 +6638,254 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - await waitForServerConfigToApply(); - await waitForComposerEditor(); - - const initialPath = mounted.router.state.location.pathname; - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "m", - ctrlKey: true, - shiftKey: true, - bubbles: true, - cancelable: true, - }), + dispatchOpenProjectFolderSearchShortcut(); + await waitForElement( + () => + document.querySelector('[data-testid="project-folder-search-input"]'), + "Unable to find project folder search input.", ); - + await page.getByTestId("project-folder-search-input").fill("docs"); + await page.getByRole("button", { name: /Docs Portal/i }).click(); await vi.waitFor(() => { - expect(document.querySelector(".model-picker-list")).not.toBeNull(); + expect(mounted.router.state.location.pathname).toMatch(UUID_ROUTE_RE); }); - - const jumpLabel = isMacPlatform(navigator.platform) ? "⌃1" : "Ctrl+1"; await vi.waitFor(() => { - expect( - Array.from( - document.querySelectorAll('.model-picker-list [data-slot="kbd"]'), - ).some((element) => element.textContent?.trim() === jumpLabel), - ).toBe(true); + const draftThread = useComposerDraftStore + .getState() + .getDraftSession(draftIdFromPath(mounted.router.state.location.pathname)); + expect(draftThread?.projectId).toBe(SECOND_PROJECT_ID); }); - expect(mounted.router.state.location.pathname).toBe(initialPath); + } finally { + await mounted.cleanup(); + } + }); - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "m", - ctrlKey: true, - shiftKey: true, - bubbles: true, - cancelable: true, - }), + it("imports a Codex transcript into a durable thread from the global shortcut", async () => { + const importedThreadId = ThreadId.make("thread-codex-imported"); + let sessionAlreadyImported = false; + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-codex-import-target" as MessageId, + targetText: "codex import thread", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...baseSnapshot, + threads: [ + ...baseSnapshot.threads, + { + id: importedThreadId, + projectId: PROJECT_ID, + title: "Fix the flaky release checklist", + modelSelection: { + instanceId: "codex" as any, + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: isoAt(80), + updatedAt: isoAt(120), + archivedAt: null, + deletedAt: null, + messages: [ + createUserMessage({ + id: "msg-user-codex-imported" as MessageId, + text: "please debug the release checklist before launch", + offsetSeconds: 81, + }), + createAssistantMessage({ + id: "msg-assistant-codex-imported" as MessageId, + text: "I found the flaky step in the release checklist.", + offsetSeconds: 82, + }), + ], + activities: [ + { + id: EventId.make("activity-codex-imported"), + tone: "info", + kind: "codex-import.imported", + summary: "Imported from Codex session Fix the flaky release checklist", + payload: { + sessionId: "codex-session-1", + }, + turnId: null, + createdAt: isoAt(120), + }, + ], + proposedPlans: [], + checkpoints: [], + session: null, + }, + ], + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.codexImportListSessions) { + return [ + { + sessionId: "codex-session-1", + title: "Fix the flaky release checklist", + cwd: null, + createdAt: isoAt(80), + updatedAt: isoAt(90), + model: "gpt-5-codex", + kind: "direct", + transcriptAvailable: true, + transcriptError: null, + alreadyImported: sessionAlreadyImported, + importedThreadId: sessionAlreadyImported ? importedThreadId : null, + lastUserMessage: "please debug the release checklist before launch", + lastAssistantMessage: "I found the flaky step in the release checklist.", + }, + ]; + } + if (body._tag === WS_METHODS.codexImportPeekSession) { + return { + sessionId: "codex-session-1", + title: "Fix the flaky release checklist", + cwd: null, + createdAt: isoAt(80), + updatedAt: isoAt(90), + model: "gpt-5-codex", + runtimeMode: "full-access", + interactionMode: "default", + kind: "direct", + transcriptAvailable: true, + transcriptError: null, + alreadyImported: sessionAlreadyImported, + importedThreadId: sessionAlreadyImported ? importedThreadId : null, + messages: [ + { + role: "user", + text: "please debug the release checklist before launch", + createdAt: isoAt(81), + }, + { + role: "assistant", + text: "I found the flaky step in the release checklist.", + createdAt: isoAt(82), + }, + ], + }; + } + if (body._tag === WS_METHODS.codexImportImportSessions) { + sessionAlreadyImported = true; + return { + results: [ + { + sessionId: "codex-session-1", + status: "imported", + threadId: importedThreadId, + projectId: PROJECT_ID, + error: null, + }, + ], + }; + } + return undefined; + }, + }); + + try { + dispatchOpenCodexImportShortcut(); + await waitForElement( + () => document.querySelector('[data-testid="codex-import-query"]'), + "Unable to find Codex import input.", + ); + await expect.element(page.getByTestId("codex-import-confirm")).toBeInTheDocument(); + await waitForElement( + () => + document.querySelector( + '[data-codex-import-session="codex-session-1"]', + ) as HTMLElement | null, + "Unable to find the Codex session row.", + ); + await page.getByTestId("codex-import-confirm").click(); + + await vi.waitFor( + () => { + const pathname = mounted.router.state.location.pathname; + expect(pathname).toBe(`/${LOCAL_ENVIRONMENT_ID}/${importedThreadId}`); + expect(document.body.textContent ?? "").toContain("Fix the flaky release checklist"); + expect(document.body.textContent ?? "").toContain( + "I found the flaky step in the release checklist.", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + expect( + wsRequests.some((request) => request._tag === WS_METHODS.codexImportListSessions), + ).toBe(true); + expect(wsRequests.some((request) => request._tag === WS_METHODS.codexImportPeekSession)).toBe( + true, ); + expect( + wsRequests.some((request) => request._tag === WS_METHODS.codexImportImportSessions), + ).toBe(true); + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.codexImportImportSessions && + request.targetProjectId === PROJECT_ID, + ), + ).toBe(true); + dispatchOpenCodexImportShortcut(); + await waitForElement( + () => document.querySelector('[data-testid="codex-import-query"]'), + "Unable to re-open Codex import input.", + ); await vi.waitFor(() => { - expect(document.querySelector(".model-picker-list")).toBeNull(); + expect(document.body.textContent ?? "").toContain("Imported"); + expect(document.body.textContent ?? "").toContain("Already imported"); + expect(document.body.textContent ?? "").toContain("Open imported thread"); }); + + await page.getByTestId("codex-import-confirm").click(); + await vi.waitFor( + () => { + const pathname = mounted.router.state.location.pathname; + expect(pathname).toBe(`/${LOCAL_ENVIRONMENT_ID}/${importedThreadId}`); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("saves and deletes a draft snippet from the picker", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-snippet-save-target" as MessageId, + targetText: "snippet save thread", + }), + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Save this useful draft"); + await waitForComposerText("Save this useful draft"); + + await page.getByLabelText("Open snippet picker").click(); + await expect.element(page.getByTestId("snippet-picker-input")).toBeVisible(); + await page.getByTestId("snippet-picker-save-draft").click(); + const deleteButton = await waitForElement( + () => + document.querySelector( + '[data-testid^="snippet-picker-delete-saved:"]', + ), + "Unable to find saved snippet delete button.", + ); + + deleteButton.click(); + await expect + .element(page.getByTestId(/^snippet-picker-delete-saved:/)) + .not.toBeInTheDocument(); } finally { - releaseModShortcut("Control"); await mounted.cleanup(); } }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 40cd1b4210..272f810326 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8,16 +8,16 @@ import { type ProjectScript, type ProjectId, type ProviderApprovalDecision, + ProviderDriverKind, ProviderInstanceId, - type ServerProvider, type ResolvedKeybindingsConfig, + type ServerProvider, type ScopedThreadRef, type ThreadId, type TurnId, type KeybindingCommand, OrchestrationThreadActivity, ProviderInteractionMode, - ProviderDriverKind, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; @@ -98,15 +98,13 @@ import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; -import { useMediaQuery } from "../hooks/useMediaQuery"; -import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon } from "lucide-react"; import { cn, randomUUID } from "~/lib/utils"; -import { stackedThreadToast, toastManager } from "./ui/toast"; +import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { @@ -119,7 +117,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; +import { deriveLogicalProjectKey } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, @@ -139,7 +137,9 @@ import { } from "../lib/terminalContext"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { QueuedFollowUpsPanel } from "./QueuedFollowUpsPanel"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; +import { ImportFromCodexDialog } from "./ImportFromCodexDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; @@ -179,7 +179,13 @@ import { } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; -import { RightPanelSheet } from "./RightPanelSheet"; +import { type QueuedTurnDraft, useQueuedTurnStore } from "../queuedTurnStore"; +import { + SAVED_COMPOSER_SNIPPETS_STORAGE_KEY, + SavedComposerSnippetList, + normalizeComposerSnippetBody, + upsertSavedComposerSnippet, +} from "./chat/composerSnippets"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; @@ -316,6 +322,7 @@ function formatOutgoingPrompt(params: { } const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +type ComposerSubmissionDisposition = "steer" | "queue" | "queue-front"; type ChatViewProps = | { @@ -414,12 +421,12 @@ interface PersistentThreadTerminalDrawerProps { threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; + keybindings: ResolvedKeybindingsConfig; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; - keybindings: ResolvedKeybindingsConfig; onAddTerminalContext: (selection: TerminalContextSelection) => void; } @@ -427,12 +434,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadRef, threadId, visible, + keybindings, launchContext, focusRequestId, splitShortcutLabel, newShortcutLabel, closeShortcutLabel, - keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); @@ -570,13 +577,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra activeTerminalId={terminalState.activeTerminalId} terminalGroups={terminalState.terminalGroups} activeTerminalGroupId={terminalState.activeTerminalGroupId} + keybindings={keybindings} focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} onSplitTerminal={splitTerminal} onNewTerminal={createNewTerminal} splitShortcutLabel={visible ? splitShortcutLabel : undefined} newShortcutLabel={visible ? newShortcutLabel : undefined} closeShortcutLabel={visible ? closeShortcutLabel : undefined} - keybindings={keybindings} onActiveTerminalChange={activateTerminal} onCloseTerminal={closeTerminal} onHeightChange={setTerminalHeight} @@ -618,7 +625,6 @@ export default function ChatView(props: ChatViewProps) { (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; - const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, @@ -686,7 +692,6 @@ export default function ChatView(props: ChatViewProps) { const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); - const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -695,6 +700,7 @@ export default function ChatView(props: ChatViewProps) { const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); + const [codexImportDialogOpen, setCodexImportDialogOpen] = useState(false); const [terminalLaunchContext, setTerminalLaunchContext] = useState( null, ); @@ -709,11 +715,17 @@ export default function ChatView(props: ChatViewProps) { {}, LastInvokedScriptByProjectSchema, ); + const [, setSavedSnippets] = useLocalStorage( + SAVED_COMPOSER_SNIPPETS_STORAGE_KEY, + [] as SavedComposerSnippetList, + SavedComposerSnippetList, + ); const legendListRef = useRef(null); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + const queueDispatchInFlightRef = useRef(null); const terminalOpenByThreadRef = useRef>({}); const terminalState = useTerminalStateStore((state) => @@ -779,7 +791,7 @@ export default function ChatView(props: ChatViewProps) { threadId, draftThread, fallbackDraftProject?.defaultModelSelection ?? { - instanceId: ProviderInstanceId.make("codex"), + instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("codex")), model: DEFAULT_MODEL, }, localDraftError, @@ -801,6 +813,19 @@ export default function ChatView(props: ChatViewProps) { [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const EMPTY_QUEUED_TURNS = useMemo(() => [] as readonly QueuedTurnDraft[], []); + const queuedTurns = useQueuedTurnStore((store) => + activeThreadKey + ? (store.threadsByThreadKey[activeThreadKey]?.items ?? EMPTY_QUEUED_TURNS) + : EMPTY_QUEUED_TURNS, + ); + const enqueueQueuedTurn = useQueuedTurnStore((store) => store.enqueue); + const prependQueuedTurn = useQueuedTurnStore((store) => store.prepend); + const consumeQueuedTurn = useQueuedTurnStore((store) => store.consume); + const removeQueuedTurn = useQueuedTurnStore((store) => store.remove); + const moveQueuedTurn = useQueuedTurnStore((store) => store.move); + const replaceQueuedTurnText = useQueuedTurnStore((store) => store.replaceText); + const clearQueuedTurnsForThread = useQueuedTurnStore((store) => store.clearThread); const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); @@ -855,16 +880,10 @@ export default function ChatView(props: ChatViewProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); - const projectGroupingSettings = useSettings((settings) => ({ - sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, - })); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; - const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); - const memberProjects = allProjects.filter( - (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, - ); + const logicalKey = deriveLogicalProjectKey(activeProject); + const memberProjects = allProjects.filter((p) => deriveLogicalProjectKey(p) === logicalKey); const seen = new Set(); const envs: Array<{ environmentId: EnvironmentId; @@ -900,7 +919,6 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, allProjects, - projectGroupingSettings, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, @@ -930,10 +948,7 @@ export default function ChatView(props: ChatViewProps) { throw new Error("No active project is available for this pull request."); } const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); - const logicalProjectKey = deriveLogicalProjectKeyFromSettings( - activeProject, - projectGroupingSettings, - ); + const logicalProjectKey = deriveLogicalProjectKey(activeProject); const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); if (storedDraftSession) { setDraftThreadContext(storedDraftSession.draftId, input); @@ -994,7 +1009,6 @@ export default function ChatView(props: ChatViewProps) { getDraftSessionByLogicalProjectKey, isServerThread, navigate, - projectGroupingSettings, routeKind, setDraftThreadContext, setLogicalProjectDraftThreadId, @@ -1021,10 +1035,7 @@ export default function ChatView(props: ChatViewProps) { const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - markThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - activeLatestTurn.completedAt, - ); + markThreadVisited(scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id))); }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, @@ -1056,9 +1067,12 @@ export default function ChatView(props: ChatViewProps) { ? primaryServerConfig : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; + const selectedProviderCandidate = selectedProviderByThreadId ?? threadProvider; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, - selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), + selectedProviderCandidate + ? ProviderDriverKind.make(selectedProviderCandidate) + : ProviderDriverKind.make("codex"), ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); @@ -1432,10 +1446,6 @@ export default function ChatView(props: ChatViewProps) { const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); - // Prefer an instance-id match so a custom Codex instance (e.g. - // `codex_personal`) surfaces its own status/message in the banner rather - // than the default Codex's. Falls back to first-match-by-kind when no - // saved instance id is available or the instance no longer exists. const activeProviderInstanceId = activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId ?? @@ -1878,13 +1888,11 @@ export default function ChatView(props: ChatViewProps) { title: `Deleted action "${deletedName ?? "Unknown"}"`, }); } catch (error) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not delete action", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); + toastManager.add({ + type: "error", + title: "Could not delete action", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); } }, [activeProject, persistProjectScripts], @@ -1941,11 +1949,6 @@ export default function ChatView(props: ChatViewProps) { return !open; }); }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); - const closePlanSidebar = useCallback(() => { - setPlanSidebarOpen(false); - planSidebarDismissedForTurnRef.current = - activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; - }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -2032,7 +2035,6 @@ export default function ChatView(props: ChatViewProps) { planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(true); } else { - planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(false); } planSidebarDismissedForTurnRef.current = null; @@ -2041,7 +2043,6 @@ export default function ChatView(props: ChatViewProps) { // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. // Don't auto-open for plans carried over from a previous turn (the user can open manually). useEffect(() => { - if (!autoOpenPlanSidebar) return; if (!activePlan) return; if (planSidebarOpen) return; const latestTurnId = activeLatestTurn?.turnId ?? null; @@ -2049,13 +2050,7 @@ export default function ChatView(props: ChatViewProps) { const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; if (planSidebarDismissedForTurnRef.current === turnKey) return; setPlanSidebarOpen(true); - }, [ - activePlan, - activeLatestTurn?.turnId, - autoOpenPlanSidebar, - planSidebarOpen, - sidebarProposedPlan?.turnId, - ]); + }, [activePlan, activeLatestTurn?.turnId, planSidebarOpen, sidebarProposedPlan?.turnId]); useEffect(() => { setIsRevertingCheckpoint(false); @@ -2138,6 +2133,17 @@ export default function ChatView(props: ChatViewProps) { requestedEnvMode: envMode, isGitRepo, }); + const isFirstThreadMessage = activeThread + ? !isServerThread || activeThread.messages.length === 0 + : false; + const requiresBaseBranchForComposerSend = + isFirstThreadMessage && sendEnvMode === "worktree" && !activeThread?.worktreePath; + const isComposerSendBlockedByMissingBaseBranch = + requiresBaseBranchForComposerSend && !activeThreadBranch; + const canSubmitComposerTurn = + activeThread !== undefined && + activeProject !== undefined && + !isComposerSendBlockedByMissingBaseBranch; useEffect(() => { setPendingServerThreadEnvMode(null); @@ -2266,7 +2272,6 @@ export default function ChatView(props: ChatViewProps) { const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), - modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; const command = resolveShortcutCommand(event, keybindings, { @@ -2316,13 +2321,6 @@ export default function ChatView(props: ChatViewProps) { return; } - if (command === "modelPicker.toggle") { - event.preventDefault(); - event.stopPropagation(); - composerRef.current?.toggleModelPicker(); - return; - } - const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -2331,8 +2329,8 @@ export default function ChatView(props: ChatViewProps) { event.stopPropagation(); void runProjectScript(script); }; - window.addEventListener("keydown", handler, true); - return () => window.removeEventListener("keydown", handler, true); + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); }, [ activeProject, terminalState.terminalOpen, @@ -2348,6 +2346,26 @@ export default function ChatView(props: ChatViewProps) { toggleTerminalVisibility, ]); + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) { + return; + } + const usesMod = event.metaKey || event.ctrlKey; + if (!usesMod || !event.shiftKey || event.altKey) { + return; + } + if (event.key.toLowerCase() !== "i") { + return; + } + event.preventDefault(); + event.stopPropagation(); + setCodexImportDialogOpen(true); + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, []); + const onRevertToTurnCount = useCallback( async (turnCount: number) => { const api = readEnvironmentApi(environmentId); @@ -2398,7 +2416,276 @@ export default function ChatView(props: ChatViewProps) { ], ); - const onSend = async (e?: { preventDefault: () => void }) => { + const dispatchQueuedTurn = useCallback( + async (queuedTurn: QueuedTurnDraft) => { + const api = readEnvironmentApi(environmentId); + if ( + !api || + !activeThread || + !isServerThread || + phase === "running" || + isSendBusy || + isConnecting || + sendInFlightRef.current || + activePendingApproval || + pendingUserInputs.length > 0 || + activePendingProgress + ) { + throw new Error("Queued follow-up cannot dispatch right now."); + } + + const threadIdForSend = activeThread.id; + const messageIdForSend = newMessageId(); + const messageCreatedAt = new Date().toISOString(); + const providerInstance = providerStatuses.find( + (status) => status.instanceId === queuedTurn.modelSelection.instanceId, + ); + const provider = providerInstance?.driver ?? ProviderDriverKind.make("codex"); + const providerModel = queuedTurn.modelSelection.model; + const providerModels = providerInstance?.models ?? []; + const messageTextForSend = appendTerminalContextsToPrompt( + queuedTurn.text, + queuedTurn.terminalContexts, + ); + const outgoingMessageText = formatOutgoingPrompt({ + provider, + model: providerModel, + models: providerModels, + effort: queuedTurn.promptEffort, + text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + }); + const optimisticAttachments = queuedTurn.images.map((image) => ({ + type: "image" as const, + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + previewUrl: image.previewUrl, + })); + + sendInFlightRef.current = true; + beginLocalDispatch({ preparingWorktree: false }); + setThreadError(threadIdForSend, null); + + isAtEndRef.current = true; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + await legendListRef.current?.scrollToEnd?.({ animated: false }); + + setOptimisticUserMessages((existing) => [ + ...existing, + { + id: messageIdForSend, + role: "user", + text: outgoingMessageText, + ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), + createdAt: messageCreatedAt, + streaming: false, + }, + ]); + + try { + await persistThreadSettingsForNextTurn({ + threadId: threadIdForSend, + createdAt: messageCreatedAt, + modelSelection: queuedTurn.modelSelection, + runtimeMode: queuedTurn.runtimeMode, + interactionMode: queuedTurn.interactionMode, + }); + + await api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: threadIdForSend, + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: queuedTurn.persistedAttachments.map((attachment) => ({ + type: "image" as const, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: attachment.dataUrl, + })), + }, + modelSelection: queuedTurn.modelSelection, + titleSeed: truncate( + queuedTurn.text.trim() || + queuedTurn.images[0]?.name || + (queuedTurn.terminalContexts[0] + ? formatTerminalContextLabel(queuedTurn.terminalContexts[0]) + : activeThread.title), + ), + runtimeMode: queuedTurn.runtimeMode, + interactionMode: queuedTurn.interactionMode, + createdAt: messageCreatedAt, + }); + sendInFlightRef.current = false; + } catch (err) { + setOptimisticUserMessages((existing) => { + const removed = existing.filter((message) => message.id === messageIdForSend); + for (const message of removed) { + revokeUserMessagePreviewUrls(message); + } + const next = existing.filter((message) => message.id !== messageIdForSend); + return next.length === existing.length ? existing : next; + }); + setThreadError( + threadIdForSend, + err instanceof Error ? err.message : "Failed to send queued follow-up.", + ); + sendInFlightRef.current = false; + resetLocalDispatch(); + throw err instanceof Error ? err : new Error("Failed to send queued follow-up."); + } + }, + [ + activeThread, + activePendingApproval, + activePendingProgress, + beginLocalDispatch, + environmentId, + isConnecting, + isSendBusy, + isServerThread, + pendingUserInputs.length, + persistThreadSettingsForNextTurn, + phase, + providerStatuses, + resetLocalDispatch, + setThreadError, + ], + ); + + const queueComposerTurn = async ( + input?: { + text: string; + interactionMode: ProviderInteractionMode; + } | null, + options?: { + prepend?: boolean; + }, + ) => { + if (!activeThread || !activeThreadRef || !isServerThread) { + return false; + } + + const sendCtx = composerRef.current?.getSendContext(); + if (!sendCtx) { + return false; + } + const { + images: sendCtxImages, + terminalContexts: sendCtxTerminalContexts, + selectedPromptEffort: ctxSelectedPromptEffort, + selectedModelSelection: ctxSelectedModelSelection, + } = sendCtx; + + const queuedImages = input ? [] : sendCtxImages; + const queuedTerminalContexts = input ? [] : sendCtxTerminalContexts; + const promptForQueue = input?.text ?? promptRef.current; + const { + trimmedPrompt, + sendableTerminalContexts, + expiredTerminalContextCount, + hasSendableContent, + } = deriveComposerSendState({ + prompt: promptForQueue, + imageCount: queuedImages.length, + terminalContexts: queuedTerminalContexts, + }); + + if (!hasSendableContent) { + if (expiredTerminalContextCount > 0) { + const toastCopy = buildExpiredTerminalContextToastCopy( + expiredTerminalContextCount, + "empty", + ); + toastManager.add({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }); + } + return false; + } + + const persistedAttachments = await Promise.all( + queuedImages.map(async (image) => ({ + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + })), + ); + + const queuedDraft = { + id: randomUUID(), + text: trimmedPrompt, + createdAt: new Date().toISOString(), + images: queuedImages.map(cloneComposerImageForRetry), + persistedAttachments, + terminalContexts: [...sendableTerminalContexts], + modelSelection: ctxSelectedModelSelection, + promptEffort: ctxSelectedPromptEffort, + runtimeMode, + interactionMode: input?.interactionMode ?? interactionMode, + } satisfies QueuedTurnDraft; + + if (options?.prepend) { + prependQueuedTurn(activeThreadRef, queuedDraft); + } else { + enqueueQueuedTurn(activeThreadRef, queuedDraft); + } + + if (expiredTerminalContextCount > 0) { + const toastCopy = buildExpiredTerminalContextToastCopy( + expiredTerminalContextCount, + "omitted", + ); + toastManager.add({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }); + } + + setThreadError(activeThread.id, null); + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + return true; + }; + + const saveQueuedTurnAsSnippet = useCallback( + (queuedTurn: QueuedTurnDraft) => { + const normalizedBody = normalizeComposerSnippetBody(queuedTurn.text); + if (normalizedBody.length === 0) { + toastManager.add({ + type: "warning", + title: "Add some text before saving a snippet.", + }); + return; + } + + setSavedSnippets((existing) => { + const result = upsertSavedComposerSnippet(existing, normalizedBody); + toastManager.add({ + type: "success", + title: result.deduped ? "Already saved to snippets" : "Saved to snippets", + }); + return result.snippets; + }); + }, + [setSavedSnippets], + ); + + const onSend = async ( + e?: { preventDefault: () => void }, + disposition: ComposerSubmissionDisposition = "steer", + ) => { e?.preventDefault(); const api = readEnvironmentApi(environmentId); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; @@ -2433,6 +2720,10 @@ export default function ChatView(props: ChatViewProps) { draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); + if (phase === "running" && (disposition === "queue" || disposition === "queue-front")) { + await queueComposerTurn(followUp, { prepend: disposition === "queue-front" }); + return; + } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -2459,32 +2750,29 @@ export default function ChatView(props: ChatViewProps) { expiredTerminalContextCount, "empty", ); - toastManager.add( - stackedThreadToast({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }), - ); + toastManager.add({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }); } return; } - if (!activeProject) return; + if (phase === "running" && (disposition === "queue" || disposition === "queue-front")) { + await queueComposerTurn(undefined, { prepend: disposition === "queue-front" }); + return; + } const threadIdForSend = activeThread.id; - const isFirstMessage = !isServerThread || activeThread.messages.length === 0; - const baseBranchForWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath - ? activeThreadBranch - : null; - - // In worktree mode, require an explicit base branch so we don't silently - // fall back to local execution when branch selection is missing. - const shouldCreateWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; - if (shouldCreateWorktree && !activeThreadBranch) { - setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); + if (!canSubmitComposerTurn) { + if (isComposerSendBlockedByMissingBaseBranch) { + setThreadError( + threadIdForSend, + "Select a base branch before sending in New worktree mode.", + ); + } return; } + const baseBranchForWorktree = requiresBaseBranchForComposerSend ? activeThreadBranch : null; sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); @@ -2547,13 +2835,11 @@ export default function ChatView(props: ChatViewProps) { expiredTerminalContextCount, "omitted", ); - toastManager.add( - stackedThreadToast({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }), - ); + toastManager.add({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }); } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); @@ -2579,14 +2865,16 @@ export default function ChatView(props: ChatViewProps) { } } const title = truncate(titleSeed); - const threadCreateModelSelection = createModelSelection( - ctxSelectedModelSelection.instanceId, + const selectedInstanceId = + ctxSelectedModelSelection.instanceId ?? ProviderInstanceId.make("codex"); + const threadCreateModelSelection: ModelSelection = createModelSelection( + ProviderInstanceId.make(selectedInstanceId), ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, ctxSelectedModelSelection.options, ); // Auto-title from first message - if (isFirstMessage && isServerThread) { + if (isFirstThreadMessage && isServerThread) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), @@ -2693,6 +2981,72 @@ export default function ChatView(props: ChatViewProps) { } }; + useEffect(() => { + if (!activeThread || !activeThreadRef || !isServerThread) { + return; + } + if (queuedTurns.length === 0) { + return; + } + if ( + phase === "running" || + isSendBusy || + isConnecting || + sendInFlightRef.current || + queueDispatchInFlightRef.current !== null || + activePendingApproval || + pendingUserInputs.length > 0 || + activePendingProgress + ) { + return; + } + if (!readEnvironmentApi(environmentId)) { + return; + } + if ( + promptRef.current.trim().length > 0 || + composerImagesRef.current.length > 0 || + composerTerminalContextsRef.current.length > 0 + ) { + return; + } + + const nextQueuedTurn = queuedTurns[0]; + if (!nextQueuedTurn) { + return; + } + + queueDispatchInFlightRef.current = nextQueuedTurn.id; + consumeQueuedTurn(activeThreadRef, nextQueuedTurn.id); + + void dispatchQueuedTurn(nextQueuedTurn) + .catch(() => { + prependQueuedTurn(activeThreadRef, nextQueuedTurn); + }) + .finally(() => { + queueDispatchInFlightRef.current = null; + }); + }, [ + activePendingApproval, + activePendingProgress, + activeThread, + activeThreadRef, + consumeQueuedTurn, + dispatchQueuedTurn, + environmentId, + isConnecting, + isSendBusy, + isServerThread, + pendingUserInputs.length, + phase, + prependQueuedTurn, + queuedTurns, + ]); + + const onQueue = () => { + void onSend(undefined, "queue"); + }; + const onInterrupt = async () => { const api = readEnvironmentApi(environmentId); if (!api || !activeThread) return; @@ -2978,7 +3332,7 @@ export default function ChatView(props: ChatViewProps) { // Optimistically open the plan sidebar when implementing (not refining). // "default" mode here means the agent is executing the plan, which produces // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default" && autoOpenPlanSidebar) { + if (nextInteractionMode === "default") { planSidebarDismissedForTurnRef.current = null; setPlanSidebarOpen(true); } @@ -3007,7 +3361,6 @@ export default function ChatView(props: ChatViewProps) { runtimeMode, setComposerDraftInteractionMode, setThreadError, - autoOpenPlanSidebar, environmentId, ], ); @@ -3100,8 +3453,8 @@ export default function ChatView(props: ChatViewProps) { return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)); }) .then(() => { - // Signal that the plan sidebar should open on the new thread when enabled. - planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar; + // Signal that the plan sidebar should open on the new thread. + planSidebarOpenOnNextThreadRef.current = true; return navigate({ to: "/$environmentId/$threadId", params: { @@ -3118,16 +3471,12 @@ export default function ChatView(props: ChatViewProps) { threadId: nextThreadId, }) .catch(() => undefined); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not start implementation thread", - description: - err instanceof Error - ? err.message - : "An error occurred while creating the new thread.", - }), - ); + toastManager.add({ + type: "error", + title: "Could not start implementation thread", + description: + err instanceof Error ? err.message : "An error occurred while creating the new thread.", + }); }) .then(finish, finish); }, [ @@ -3142,16 +3491,12 @@ export default function ChatView(props: ChatViewProps) { navigate, resetLocalDispatch, runtimeMode, - autoOpenPlanSidebar, environmentId, ]); const onProviderModelSelect = useCallback( (instanceId: ProviderInstanceId, model: string) => { if (!activeThread) return; - // Look up the configured instance so model normalization and custom - // model lookup stay scoped to that exact instance. Unknown instance ids - // are rejected by returning early; the server remains authoritative too. const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); const resolvedDriverKind = entry?.driver ?? null; if ( @@ -3271,6 +3616,17 @@ export default function ChatView(props: ChatViewProps) { void onRevertToTurnCountRef.current(targetTurnCount); }, []); + const canDispatchQueuedTurnNow = + isServerThread && + phase !== "running" && + !isSendBusy && + !isConnecting && + !sendInFlightRef.current && + queueDispatchInFlightRef.current === null && + !activePendingApproval && + pendingUserInputs.length === 0 && + !activePendingProgress; + // Empty state: no active thread if (!activeThread) { return ; @@ -3281,14 +3637,14 @@ export default function ChatView(props: ChatViewProps) { {/* Top bar */}
+ + {/* Error banner */} {/* Input bar */} -
+
+ {activeThreadRef && queuedTurns.length > 0 ? ( +
+ { + if (!canDispatchQueuedTurnNow) { + return; + } + consumeQueuedTurn(activeThreadRef, queuedTurn.id); + void dispatchQueuedTurn(queuedTurn).catch(() => { + prependQueuedTurn(activeThreadRef, queuedTurn); + }); + }} + onSaveAsSnippet={saveQueuedTurnAsSnippet} + onDelete={(queuedTurn) => removeQueuedTurn(activeThreadRef, queuedTurn.id)} + onClearAll={() => clearQueuedTurnsForThread(activeThreadRef)} + onMove={(queuedTurn, nextIndex) => + moveQueuedTurn(activeThreadRef, queuedTurn.id, nextIndex) + } + onReplaceText={(queuedTurn, nextText) => + replaceQueuedTurnText(activeThreadRef, queuedTurn.id, nextText) + } + /> +
+ ) : null} void onSend(undefined, "queue-front")} onImplementPlanInNewThread={onImplementPlanInNewThread} onRespondToApproval={onRespondToApproval} onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} @@ -3450,34 +3834,34 @@ export default function ChatView(props: ChatViewProps) { setThreadError={setThreadError} onExpandImage={onExpandTimelineImage} /> - {isGitRepo && ( - - )}
+ {isGitRepo && ( + + )} {pullRequestDialogState ? ( { + setPlanSidebarOpen(false); + // Track that the user explicitly dismissed for this turn so auto-open won't fight them. + planSidebarDismissedForTurnRef.current = + activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; + }} /> ) : null}
@@ -3520,6 +3908,7 @@ export default function ChatView(props: ChatViewProps) { threadRef={mountedThreadRef} threadId={mountedThreadRef.threadId} visible={mountedThreadKey === activeThreadKey && terminalState.terminalOpen} + keybindings={keybindings} launchContext={ mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null } @@ -3527,25 +3916,9 @@ export default function ChatView(props: ChatViewProps) { splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} newShortcutLabel={newTerminalShortcutLabel ?? undefined} closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} - keybindings={keybindings} onAddTerminalContext={addTerminalContextToDraft} /> ))} - {shouldUsePlanSidebarSheet ? ( - - - - ) : null} {expandedImage && ( diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d64c80ff96..0b7da9121a 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -92,6 +92,11 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { useServerKeybindings } from "../rpc/serverState"; +import { useQuickThreadSearchStore } from "../quickThreadSearchStore"; +import { useGlobalThreadSearchStore } from "../globalThreadSearchStore"; +import { useProjectFolderSearchStore } from "../projectFolderSearchStore"; +import { useSkillPickerStore } from "../skillPickerStore"; +import { useSnippetPickerStore } from "../snippetPickerStore"; import { resolveShortcutCommand } from "../keybindings"; import { Command, @@ -698,6 +703,71 @@ function OpenCommandPaletteDialog() { }); } + actionItems.push({ + kind: "action", + value: "action:quick-thread-search", + searchTerms: ["thread search", "recent threads", "quick search", "jump to thread"], + title: "Search recent threads", + description: "Jump across your most recent threads by title or opening prompt", + icon: , + shortcutCommand: "threads.search", + run: async () => { + useQuickThreadSearchStore.getState().openDialog(); + }, + }); + + actionItems.push({ + kind: "action", + value: "action:global-thread-search", + searchTerms: ["search all threads", "deep thread search", "messages", "plans", "assistant"], + title: "Search all threads", + description: "Search titles, messages, and plans across every loaded thread", + icon: , + shortcutCommand: "threads.searchAll", + run: async () => { + useGlobalThreadSearchStore.getState().openDialog(); + }, + }); + + actionItems.push({ + kind: "action", + value: "action:project-folder-search", + searchTerms: ["project folders", "projects", "folder search", "new thread in project"], + title: "Search project folders", + description: "Jump to a project and open a new thread there", + icon: , + shortcutCommand: "projects.search", + run: async () => { + useProjectFolderSearchStore.getState().openDialog(); + }, + }); + + actionItems.push({ + kind: "action", + value: "action:snippets", + searchTerms: ["snippets", "snippet", "prompt library", "templates", "reusable prompts"], + title: "Open snippets", + description: "Browse saved and built-in prompt snippets", + icon: , + shortcutCommand: "snippets.open", + run: async () => { + useSnippetPickerStore.getState().openPicker(); + }, + }); + + actionItems.push({ + kind: "action", + value: "action:skills", + searchTerms: ["skills", "skill picker", "workspace skills", "agent skill", "skill reference"], + title: "Open skills", + description: "Insert a workspace skill reference into the composer", + icon: , + shortcutCommand: "skills.open", + run: async () => { + useSkillPickerStore.getState().openPicker(); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index c9696b0c73..9270ff4a70 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -873,6 +873,7 @@ export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; + replaceValue: (value: string, cursor: number) => void; readSnapshot: () => { value: string; cursor: number; @@ -1560,9 +1561,26 @@ function ComposerPromptEditorInner({ ), ); }, + replaceValue: (value: string, cursor: number) => { + const normalizedCursor = clampCollapsedComposerCursor(value, cursor); + snapshotRef.current = { + value, + cursor: normalizedCursor, + expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), + terminalContextIds: terminalContexts.map((context) => context.id), + }; + isApplyingControlledUpdateRef.current = true; + editor.update(() => { + $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); + $setSelectionAtComposerOffset(normalizedCursor); + }); + queueMicrotask(() => { + isApplyingControlledUpdateRef.current = false; + }); + }, readSnapshot, }), - [focusAt, readSnapshot], + [editor, focusAt, readSnapshot, terminalContexts], ); const handleEditorChange = useCallback((editorState: EditorState) => { diff --git a/apps/web/src/components/GlobalThreadSearchDialog.browser.tsx b/apps/web/src/components/GlobalThreadSearchDialog.browser.tsx new file mode 100644 index 0000000000..b1a0c2df42 --- /dev/null +++ b/apps/web/src/components/GlobalThreadSearchDialog.browser.tsx @@ -0,0 +1,179 @@ +import "../index.css"; + +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { EnvironmentId, type MessageId, type ProjectId, type ThreadId } from "@t3tools/contracts"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { GlobalThreadSearchDialog } from "./GlobalThreadSearchDialog"; +import type { Project, Thread } from "../types"; + +const navigateSpy = vi.fn(); + +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => navigateSpy, + }; +}); + +const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const PROJECT_ID = "project-1" as ProjectId; + +function createThread(input: { + id: string; + title: string; + createdAt: string; + updatedAt?: string; + messages?: Thread["messages"]; + proposedPlans?: Thread["proposedPlans"]; +}): Thread { + return { + id: input.id as ThreadId, + environmentId: ENVIRONMENT_ID, + codexThreadId: null, + projectId: PROJECT_ID, + title: input.title, + modelSelection: { + instanceId: "codex" as any, + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: input.messages ?? [], + proposedPlans: input.proposedPlans ?? [], + error: null, + createdAt: input.createdAt, + archivedAt: null, + updatedAt: input.updatedAt ?? input.createdAt, + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }; +} + +async function mountDialog(input?: { + threads?: readonly Thread[]; + activeThreadRef?: ReturnType | null; +}) { + const projects: Project[] = [ + { + id: PROJECT_ID, + environmentId: ENVIRONMENT_ID, + name: "Project", + cwd: "/repo/project", + defaultModelSelection: null, + scripts: [], + }, + ]; + + const threads = input?.threads ?? [ + createThread({ + id: "thread-title", + title: "Queue recovery plan", + createdAt: "2026-04-16T12:00:00.000Z", + updatedAt: "2026-04-16T12:05:00.000Z", + messages: [ + { + id: "message-title" as MessageId, + role: "user", + text: "Ship the release today", + createdAt: "2026-04-16T12:01:00.000Z", + streaming: false, + }, + ], + }), + createThread({ + id: "thread-plan", + title: "Incident notes", + createdAt: "2026-04-16T12:00:00.000Z", + updatedAt: "2026-04-16T12:06:00.000Z", + proposedPlans: [ + { + id: "plan-1" as Thread["proposedPlans"][number]["id"], + turnId: null, + planMarkdown: "Investigate the queue recovery path overnight.", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-04-16T12:02:00.000Z", + updatedAt: "2026-04-16T12:02:00.000Z", + }, + ], + }), + ]; + + const onOpenChange = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + , + { container: host }, + ); + + const cleanup = async () => { + await screen.unmount(); + host.remove(); + }; + + return { + [Symbol.asyncDispose]: cleanup, + cleanup, + onOpenChange, + }; +} + +describe("GlobalThreadSearchDialog", () => { + afterEach(() => { + document.body.innerHTML = ""; + navigateSpy.mockReset(); + }); + + it("searches across titles and plan content and navigates to the selected thread", async () => { + await using _mounted = await mountDialog(); + + await page.getByTestId("global-thread-search-input").fill("queue"); + + await vi.waitFor(() => { + const results = Array.from( + document.querySelectorAll('[data-global-thread-search-result="true"]'), + ); + expect(results).toHaveLength(2); + expect(results[0]?.textContent).toContain("Queue recovery plan"); + expect(results[1]?.textContent).toContain("Incident notes"); + }); + + await page.getByRole("button", { name: /Incident notes/i }).click(); + + expect(navigateSpy).toHaveBeenCalledWith({ + to: "/$environmentId/$threadId", + params: { + environmentId: ENVIRONMENT_ID, + threadId: "thread-plan", + }, + }); + }); + + it("closes without navigating when selecting the active thread", async () => { + await using mounted = await mountDialog({ + activeThreadRef: scopeThreadRef(ENVIRONMENT_ID, "thread-title" as ThreadId), + }); + + await page.getByTestId("global-thread-search-input").fill("queue"); + await page.getByRole("button", { name: /Queue recovery plan/i }).click(); + + expect(navigateSpy).not.toHaveBeenCalled(); + expect(mounted.onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/apps/web/src/components/GlobalThreadSearchDialog.tsx b/apps/web/src/components/GlobalThreadSearchDialog.tsx new file mode 100644 index 0000000000..9f823635ca --- /dev/null +++ b/apps/web/src/components/GlobalThreadSearchDialog.tsx @@ -0,0 +1,314 @@ +import { scopedThreadKey } from "@t3tools/client-runtime"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import { useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; + +import { + GLOBAL_THREAD_SEARCH_RESULT_LIMIT, + buildGlobalThreadSearchIndex, + buildGlobalThreadSearchResults, + type GlobalThreadSearchResult, +} from "../lib/globalThreadSearch"; +import { buildHighlightSegments, findTextOccurrences } from "../lib/searchText"; +import { buildThreadRouteParams } from "../threadRoutes"; +import { formatRelativeTimeLabel } from "../timestampFormat"; +import type { Project, Thread } from "../types"; +import { Badge } from "./ui/badge"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { ScrollArea } from "./ui/scroll-area"; +import { cn } from "~/lib/utils"; + +interface GlobalThreadSearchDialogProps { + open: boolean; + focusRequestId: number; + threads: readonly Thread[]; + projects: readonly Project[]; + activeThreadRef: ScopedThreadRef | null; + onOpenChange: (open: boolean) => void; +} + +function matchedFieldLabel(field: GlobalThreadSearchResult["matchedField"]) { + switch (field) { + case "title": + return "Title"; + case "user": + return "User"; + case "assistant": + return "Assistant"; + case "plan": + return "Plan"; + } +} + +function formatExactResultTimestamp(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value; + } + + return parsed.toLocaleString([], { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export function GlobalThreadSearchDialog(props: GlobalThreadSearchDialogProps) { + const navigate = useNavigate(); + const [query, setQuery] = useState(""); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const inputRef = useRef(null); + const deferredQuery = useDeferredValue(query); + + useEffect(() => { + if (!props.open) { + setQuery(""); + setHighlightedIndex(0); + return; + } + + setQuery(""); + setHighlightedIndex(0); + }, [props.open]); + + useEffect(() => { + if (!props.open) { + return; + } + + window.requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + }, [props.focusRequestId, props.open]); + + const index = useMemo( + () => + buildGlobalThreadSearchIndex({ + threads: props.threads, + projects: props.projects, + }), + [props.projects, props.threads], + ); + + const searchResults = useMemo( + () => + buildGlobalThreadSearchResults({ + index, + query: deferredQuery, + }), + [deferredQuery, index], + ); + + useEffect(() => { + if (!props.open) { + return; + } + setHighlightedIndex(0); + }, [deferredQuery, props.open]); + + useEffect(() => { + if (searchResults.results.length === 0) { + setHighlightedIndex(0); + return; + } + + setHighlightedIndex((current) => Math.min(current, searchResults.results.length - 1)); + }, [searchResults.results.length]); + + const openResult = async (resultIndex: number) => { + const result = searchResults.results[resultIndex]; + if (!result) { + return; + } + + props.onOpenChange(false); + + if ( + props.activeThreadRef && + scopedThreadKey(props.activeThreadRef) === scopedThreadKey(result.threadRef) + ) { + return; + } + + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(result.threadRef), + }); + }; + + const onInputKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + props.onOpenChange(false); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + if (searchResults.results.length === 0) return; + setHighlightedIndex((current) => (current + 1) % searchResults.results.length); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + if (searchResults.results.length === 0) return; + setHighlightedIndex( + (current) => (current - 1 + searchResults.results.length) % searchResults.results.length, + ); + return; + } + + if (event.key !== "Enter") { + return; + } + + event.preventDefault(); + void openResult(highlightedIndex); + }; + + return ( + + + + Search All Threads + + Search titles, user prompts, assistant replies, and proposed plans across all loaded + threads in the current workspace. + + + +
+ setQuery(event.target.value)} + onKeyDown={onInputKeyDown} + /> +
+ + {searchResults.totalResults === 0 + ? "No results" + : searchResults.truncated + ? `Showing ${searchResults.results.length} of ${searchResults.totalResults} results` + : `${searchResults.totalResults} results`} + + Enter opens • Up/Down moves • Esc closes +
+
+ +
+ +
+ {deferredQuery.trim().length === 0 ? ( +
+ Start typing to search up to {GLOBAL_THREAD_SEARCH_RESULT_LIMIT} thread matches + across titles, messages, and plans. +
+ ) : searchResults.results.length === 0 ? ( +
+ No threads matched this search. +
+ ) : ( + searchResults.results.map((result, index) => { + const titleSegments = buildHighlightSegments( + result.threadTitle, + findTextOccurrences(result.threadTitle, deferredQuery), + ); + const snippetSegments = buildHighlightSegments( + result.displaySnippet, + findTextOccurrences(result.displaySnippet, deferredQuery), + ); + const isHighlighted = index === highlightedIndex; + + return ( + + ); + }) + )} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/ImportFromCodexDialog.tsx b/apps/web/src/components/ImportFromCodexDialog.tsx new file mode 100644 index 0000000000..9971daec92 --- /dev/null +++ b/apps/web/src/components/ImportFromCodexDialog.tsx @@ -0,0 +1,601 @@ +import type { + CodexImportPeekSessionResult, + CodexImportSessionSummary, + ScopedProjectRef, +} from "@t3tools/contracts"; +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { Loader2Icon, RefreshCwIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { useShallow } from "zustand/react/shallow"; + +import { getPrimaryEnvironmentConnection } from "../environments/runtime"; +import { ensureLocalApi } from "../localApi"; +import { selectProjectsAcrossEnvironments, useStore } from "../store"; +import { buildThreadRouteParams } from "../threadRoutes"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { ScrollArea } from "./ui/scroll-area"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; +import { Spinner } from "./ui/spinner"; +import { toastManager } from "./ui/toast"; +import { cn } from "~/lib/utils"; + +interface ImportFromCodexDialogProps { + readonly open: boolean; + readonly codexHomePath?: string; + readonly preferredProjectRef?: ScopedProjectRef | null; + readonly onOpenChange: (open: boolean) => void; +} + +interface ImportTargetProject { + readonly key: string; + readonly ref: ScopedProjectRef; + readonly name: string; + readonly environmentLabel: string; +} + +function formatTimestamp(value: string | null): string { + if (!value) { + return "Unknown"; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value; + } + return parsed.toLocaleString([], { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function summarize(text: string | null, maxChars = 160): string { + if (!text) { + return ""; + } + const normalized = text.trim().replace(/\s+/g, " "); + if (normalized.length <= maxChars) { + return normalized; + } + return `${normalized.slice(0, maxChars - 1)}…`; +} + +export function ImportFromCodexDialog(props: ImportFromCodexDialogProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [selectedSessionId, setSelectedSessionId] = useState(null); + const [targetProjectKey, setTargetProjectKey] = useState(null); + const normalizedHomePath = props.codexHomePath?.trim() || null; + const sessionsQueryKey = ["codex-import", "sessions", normalizedHomePath] as const; + + const localEnvironmentId = getPrimaryEnvironmentConnection().environmentId; + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const projectOptions = useMemo(() => { + return projects + .filter((project) => project.environmentId === localEnvironmentId) + .map((project) => { + const ref = scopeProjectRef(project.environmentId, project.id); + return { + key: scopedProjectKey(ref), + ref, + name: project.name, + environmentLabel: project.environmentId, + }; + }) + .toSorted((left, right) => { + const nameComparison = left.name.localeCompare(right.name); + if (nameComparison !== 0) { + return nameComparison; + } + return left.environmentLabel.localeCompare(right.environmentLabel); + }); + }, [localEnvironmentId, projects]); + + useEffect(() => { + if (!props.open) { + setQuery(""); + setSelectedSessionId(null); + return; + } + + const frame = window.requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => { + window.cancelAnimationFrame(frame); + }; + }, [props.open]); + + useEffect(() => { + if (!props.open) { + return; + } + + const preferredKey = props.preferredProjectRef + ? scopedProjectKey(props.preferredProjectRef) + : null; + if (preferredKey && projectOptions.some((project) => project.key === preferredKey)) { + setTargetProjectKey(preferredKey); + return; + } + + if (targetProjectKey && projectOptions.some((project) => project.key === targetProjectKey)) { + return; + } + + setTargetProjectKey(projectOptions[0]?.key ?? null); + }, [projectOptions, props.open, props.preferredProjectRef, targetProjectKey]); + + const sessionsQuery = useQuery({ + queryKey: sessionsQueryKey, + enabled: props.open, + staleTime: 30_000, + queryFn: async () => { + return ensureLocalApi().codexImport.listSessions({ + ...(normalizedHomePath ? { homePath: normalizedHomePath } : {}), + kind: "all", + }); + }, + }); + + const filteredSessions = useMemo(() => { + const allSessions = sessionsQuery.data ?? []; + const needle = query.trim().toLowerCase(); + if (!needle) { + return allSessions; + } + return allSessions.filter((session) => { + const haystack = [ + session.title, + session.lastUserMessage ?? "", + session.lastAssistantMessage ?? "", + ] + .join(" ") + .toLowerCase(); + return haystack.includes(needle); + }); + }, [query, sessionsQuery.data]); + + useEffect(() => { + if (!props.open) { + return; + } + + if ( + selectedSessionId && + filteredSessions.some((session) => session.sessionId === selectedSessionId) + ) { + return; + } + + setSelectedSessionId(filteredSessions[0]?.sessionId ?? null); + }, [filteredSessions, props.open, selectedSessionId]); + + const selectedSession = useMemo( + () => filteredSessions.find((session) => session.sessionId === selectedSessionId) ?? null, + [filteredSessions, selectedSessionId], + ); + + const peekQuery = useQuery({ + queryKey: ["codex-import", "peek", selectedSessionId, normalizedHomePath], + enabled: props.open && selectedSessionId !== null, + staleTime: 60_000, + queryFn: async () => { + if (!selectedSessionId) { + return null; + } + return ensureLocalApi().codexImport.peekSession({ + ...(normalizedHomePath ? { homePath: normalizedHomePath } : {}), + sessionId: selectedSessionId, + messageCount: 200, + }); + }, + }); + + const importMutation = useMutation({ + mutationFn: async (input: { + readonly project: ImportTargetProject; + readonly peek: CodexImportPeekSessionResult; + }) => { + const result = await ensureLocalApi().codexImport.importSessions({ + ...(normalizedHomePath ? { homePath: normalizedHomePath } : {}), + targetProjectId: input.project.ref.projectId, + sessionIds: [input.peek.sessionId], + }); + const imported = result.results[0]; + if (!imported) { + throw new Error("Import did not return a result."); + } + if (imported.status === "failed" || imported.threadId === null) { + throw new Error(imported.error ?? "Failed to import Codex transcript."); + } + + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams({ + environmentId: input.project.ref.environmentId, + threadId: imported.threadId, + }), + }); + + return imported; + }, + onSuccess: (result) => { + queryClient.setQueryData( + sessionsQueryKey, + (existing) => + existing?.map((session) => + session.sessionId === result.sessionId + ? { + ...session, + alreadyImported: true, + importedThreadId: result.threadId, + } + : session, + ) ?? existing, + ); + queryClient.setQueryData( + ["codex-import", "peek", result.sessionId, normalizedHomePath], + (existing) => + existing + ? { + ...existing, + alreadyImported: true, + importedThreadId: result.threadId, + } + : existing, + ); + void queryClient.invalidateQueries({ queryKey: sessionsQueryKey }); + void queryClient.invalidateQueries({ + queryKey: ["codex-import", "peek", result.sessionId, normalizedHomePath], + }); + toastManager.add({ + type: "success", + title: + result.status === "skipped-existing" + ? "Opened existing imported thread" + : "Imported into a thread", + description: + result.status === "skipped-existing" + ? "That Codex session was already imported, so we jumped back to the existing thread." + : "The full Codex transcript is now available as a real ClayCode thread.", + }); + props.onOpenChange(false); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to import Codex transcript", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }, + }); + + const previewMessages = useMemo(() => { + const seenKeys = new Map(); + return (peekQuery.data?.messages ?? []).map((message) => { + const normalizedText = message.text.trim().replace(/\s+/g, " ").slice(0, 120); + const baseKey = [message.createdAt, message.role, normalizedText].join(":"); + const duplicateCount = seenKeys.get(baseKey) ?? 0; + seenKeys.set(baseKey, duplicateCount + 1); + return { + key: duplicateCount === 0 ? baseKey : `${baseKey}:${duplicateCount}`, + message, + }; + }); + }, [peekQuery.data?.messages]); + + const handleImport = () => { + const peek = peekQuery.data; + const project = projectOptions.find((option) => option.key === targetProjectKey) ?? null; + if (!peek || !project) { + return; + } + importMutation.mutate({ peek, project }); + }; + + const onQueryKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + props.onOpenChange(false); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + if (filteredSessions.length === 0) { + return; + } + const currentIndex = Math.max( + 0, + filteredSessions.findIndex((session) => session.sessionId === selectedSessionId), + ); + const nextIndex = (currentIndex + 1) % filteredSessions.length; + setSelectedSessionId(filteredSessions[nextIndex]?.sessionId ?? null); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + if (filteredSessions.length === 0) { + return; + } + const currentIndex = Math.max( + 0, + filteredSessions.findIndex((session) => session.sessionId === selectedSessionId), + ); + const nextIndex = (currentIndex - 1 + filteredSessions.length) % filteredSessions.length; + setSelectedSessionId(filteredSessions[nextIndex]?.sessionId ?? null); + return; + } + + if (event.key === "Enter" && peekQuery.data && targetProjectKey) { + event.preventDefault(); + handleImport(); + } + }; + + const importDisabled = + importMutation.isPending || + peekQuery.data === null || + peekQuery.isPending || + targetProjectKey === null; + const importAlreadyExists = + peekQuery.data?.alreadyImported ?? selectedSession?.alreadyImported ?? false; + + return ( + + + + Import from Codex + + Browse local Codex transcripts and import one into a real ClayCode thread with durable + history. + + + +
+
+
+ setQuery(event.target.value)} + onKeyDown={onQueryKeyDown} + /> + +
+
+ + {filteredSessions.length === 0 + ? "No sessions found" + : `${filteredSessions.length} session${filteredSessions.length === 1 ? "" : "s"}`} + + Enter imports • Up/Down moves • Esc closes +
+
+ +
+ {sessionsQuery.isPending ? ( +
+ + Loading Codex sessions… +
+ ) : sessionsQuery.isError ? ( +
+ {sessionsQuery.error instanceof Error + ? sessionsQuery.error.message + : "Unable to load Codex sessions."} +
+ ) : filteredSessions.length === 0 ? ( +
+ No Codex transcripts matched this search. +
+ ) : ( + filteredSessions.map((session) => { + const isSelected = session.sessionId === selectedSessionId; + return ( + + ); + }) + )} +
+
+
+
+ +
+
+
+
+
+ {selectedSession?.title ?? "Select a session"} +
+
+ {selectedSession + ? `${selectedSession.kind.replace("-", " ")} session • Updated ${formatTimestamp(selectedSession.updatedAt)}` + : "Choose a session from the list to preview it."} +
+
+ +
+
+ +
+ + {!selectedSession ? ( +
+ Select a Codex session to inspect its transcript preview. +
+ ) : peekQuery.isPending ? ( +
+ + Loading transcript preview… +
+ ) : peekQuery.isError ? ( +
+ {peekQuery.error instanceof Error + ? peekQuery.error.message + : "Unable to load this Codex transcript."} +
+ ) : peekQuery.data ? ( +
+
+
+
Model
+
{peekQuery.data.model ?? "Unknown"}
+
+
+
Messages
+
{peekQuery.data.messages.length}
+
+
+
Runtime mode
+
{peekQuery.data.runtimeMode}
+
+
+
Interaction mode
+
{peekQuery.data.interactionMode}
+
+
+
Import status
+
+ {peekQuery.data.alreadyImported + ? "Already imported" + : "Not imported yet"} +
+
+
+ +
+ {previewMessages.length === 0 ? ( +
+ This transcript did not include any messages. +
+ ) : ( + previewMessages.map(({ key, message }) => ( +
+
+ + {message.role} + + + {formatTimestamp(message.createdAt)} + +
+

+ {message.text} +

+
+ )) + )} +
+
+ ) : null} +
+
+
+
+
+ +
+ Imports create durable local threads in the primary environment. +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/ProjectFolderSearchDialog.browser.tsx b/apps/web/src/components/ProjectFolderSearchDialog.browser.tsx new file mode 100644 index 0000000000..607a4ef32c --- /dev/null +++ b/apps/web/src/components/ProjectFolderSearchDialog.browser.tsx @@ -0,0 +1,72 @@ +import "../index.css"; + +import { EnvironmentId, type ProjectId } from "@t3tools/contracts"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { ProjectFolderSearchDialog } from "./ProjectFolderSearchDialog"; +import type { Project } from "../types"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); + +function makeProject(id: string, name: string, cwd: string): Project { + return { + id: id as ProjectId, + environmentId: ENVIRONMENT_ID, + name, + cwd, + defaultModelSelection: null, + scripts: [], + }; +} + +async function mountDialog() { + const onOpenChange = vi.fn(); + const onSelectProject = vi.fn(async () => undefined); + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + , + { container: host }, + ); + + const cleanup = async () => { + await screen.unmount(); + host.remove(); + }; + + return { + [Symbol.asyncDispose]: cleanup, + cleanup, + onOpenChange, + onSelectProject, + }; +} + +describe("ProjectFolderSearchDialog", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("filters projects and opens the highlighted result", async () => { + await using mounted = await mountDialog(); + + await page.getByTestId("project-folder-search-input").fill("desk"); + await page.getByRole("button", { name: /Clay Desktop/i }).click(); + + expect(mounted.onOpenChange).toHaveBeenCalledWith(false); + expect(mounted.onSelectProject).toHaveBeenCalledWith( + expect.objectContaining({ environmentId: ENVIRONMENT_ID, projectId: "project-2" }), + ); + }); +}); diff --git a/apps/web/src/components/ProjectFolderSearchDialog.tsx b/apps/web/src/components/ProjectFolderSearchDialog.tsx new file mode 100644 index 0000000000..2062ff92d1 --- /dev/null +++ b/apps/web/src/components/ProjectFolderSearchDialog.tsx @@ -0,0 +1,207 @@ +import { scopeProjectRef } from "@t3tools/client-runtime"; +import type { ScopedProjectRef } from "@t3tools/contracts"; +import { FolderIcon } from "lucide-react"; +import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; + +import { buildProjectFolderSearchResults } from "../lib/projectFolderSearch"; +import type { Project } from "../types"; +import { cn } from "~/lib/utils"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { ScrollArea } from "./ui/scroll-area"; + +interface ProjectFolderSearchDialogProps { + open: boolean; + focusRequestId: number; + projects: readonly Project[]; + onSelectProject: (projectRef: ScopedProjectRef) => Promise | void; + onOpenChange: (open: boolean) => void; +} + +export function ProjectFolderSearchDialog(props: ProjectFolderSearchDialogProps) { + const [query, setQuery] = useState(""); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const inputRef = useRef(null); + const deferredQuery = useDeferredValue(query); + + useEffect(() => { + if (!props.open) { + setQuery(""); + setHighlightedIndex(0); + return; + } + + setQuery(""); + setHighlightedIndex(0); + }, [props.open]); + + useEffect(() => { + if (!props.open) { + return; + } + + window.requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + }, [props.focusRequestId, props.open]); + + const searchResults = useMemo( + () => + buildProjectFolderSearchResults({ + projects: props.projects, + query: deferredQuery, + }), + [deferredQuery, props.projects], + ); + + useEffect(() => { + if (!props.open) { + return; + } + setHighlightedIndex(0); + }, [deferredQuery, props.open]); + + useEffect(() => { + if (searchResults.results.length === 0) { + setHighlightedIndex(0); + return; + } + + setHighlightedIndex((current) => Math.min(current, searchResults.results.length - 1)); + }, [searchResults.results.length]); + + const openResult = async (resultIndex: number) => { + const result = searchResults.results[resultIndex]; + if (!result) { + return; + } + + props.onOpenChange(false); + await props.onSelectProject(scopeProjectRef(result.project.environmentId, result.project.id)); + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + props.onOpenChange(false); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + if (searchResults.results.length === 0) return; + setHighlightedIndex((current) => (current + 1) % searchResults.results.length); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + if (searchResults.results.length === 0) return; + setHighlightedIndex( + (current) => (current - 1 + searchResults.results.length) % searchResults.results.length, + ); + return; + } + + if (event.key !== "Enter") { + return; + } + + event.preventDefault(); + void openResult(highlightedIndex); + }; + + return ( + + + + Search Project Folders + + Fuzzy-search the project folders in the sidebar, then open a new thread in the selected + project. + + + +
+ setQuery(event.target.value)} + onKeyDown={onInputKeyDown} + /> +
+ + {searchResults.totalResults === 0 + ? "No results" + : searchResults.truncated + ? `Showing ${searchResults.results.length} of ${searchResults.totalResults} projects` + : `${searchResults.totalResults} projects`} + + Enter opens new thread • Up/Down moves • Esc closes +
+
+ +
+ +
+ {props.projects.length === 0 ? ( +
+ No project folders are available in the sidebar yet. +
+ ) : searchResults.results.length === 0 ? ( +
+ No project folders matched this search. +
+ ) : ( + searchResults.results.map((result, index) => { + const isHighlighted = index === highlightedIndex; + + return ( + + ); + }) + )} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/QueuedFollowUpsPanel.browser.tsx b/apps/web/src/components/QueuedFollowUpsPanel.browser.tsx new file mode 100644 index 0000000000..309cccb38f --- /dev/null +++ b/apps/web/src/components/QueuedFollowUpsPanel.browser.tsx @@ -0,0 +1,195 @@ +import { ThreadId } from "@t3tools/contracts"; +import { page } from "vitest/browser"; +import type { ComponentProps } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { QueuedFollowUpsPanel } from "./QueuedFollowUpsPanel"; +import type { QueuedTurnDraft } from "../queuedTurnStore"; + +function makeQueuedTurn(overrides: Partial = {}): QueuedTurnDraft { + const file = new File(["hello"], "queued.png", { type: "image/png" }); + return { + id: overrides.id ?? crypto.randomUUID(), + text: overrides.text ?? "Queued follow-up", + createdAt: overrides.createdAt ?? "2026-04-16T12:00:00.000Z", + images: overrides.images ?? [ + { + type: "image", + id: "image-1", + name: "queued.png", + mimeType: "image/png", + sizeBytes: file.size, + previewUrl: "data:image/png;base64,aGVsbG8=", + file, + }, + ], + persistedAttachments: overrides.persistedAttachments ?? [ + { + id: "image-1", + name: "queued.png", + mimeType: "image/png", + sizeBytes: file.size, + dataUrl: "data:image/png;base64,aGVsbG8=", + }, + ], + terminalContexts: overrides.terminalContexts ?? [], + modelSelection: overrides.modelSelection ?? { instanceId: "codex" as any, model: "gpt-5" }, + promptEffort: overrides.promptEffort ?? null, + runtimeMode: overrides.runtimeMode ?? "full-access", + interactionMode: overrides.interactionMode ?? "default", + }; +} + +async function mountPanel(props: Partial> = {}) { + const host = document.createElement("div"); + document.body.append(host); + + const onSendNow = vi.fn(); + const onSaveAsSnippet = vi.fn(); + const onDelete = vi.fn(); + const onClearAll = vi.fn(); + const onMove = vi.fn(); + const onReplaceText = vi.fn(); + + const screen = await render( + , + { container: host }, + ); + + return { + onSendNow, + onSaveAsSnippet, + onDelete, + onClearAll, + onMove, + onReplaceText, + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("QueuedFollowUpsPanel", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renders queued turns and wires row actions", async () => { + const mounted = await mountPanel(); + + try { + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("2 queued follow-ups"); + expect(document.body.textContent ?? "").toContain("First queued follow-up"); + }); + + await page.getByRole("button", { name: "Edit queued follow-up" }).first().click(); + await page.getByRole("textbox").fill("Updated queued follow-up"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + expect(mounted.onReplaceText).toHaveBeenCalledWith( + expect.objectContaining({ id: "turn-1" }), + "Updated queued follow-up", + ); + + await page.getByRole("button", { name: "Send queued follow-up now" }).nth(1).click(); + expect(mounted.onSendNow).toHaveBeenCalledWith(expect.objectContaining({ id: "turn-2" })); + + await page.getByRole("button", { name: "Save queued follow-up as snippet" }).first().click(); + expect(mounted.onSaveAsSnippet).toHaveBeenCalledWith( + expect.objectContaining({ id: "turn-1" }), + ); + + await page.getByRole("button", { name: "Remove queued follow-up" }).nth(1).click(); + expect(mounted.onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: "turn-2" })); + + await page.getByRole("button", { name: "Clear all" }).click(); + expect(mounted.onClearAll).toHaveBeenCalledTimes(1); + + await page.getByRole("button", { name: "Move queued follow-up down" }).first().click(); + expect(mounted.onMove).toHaveBeenCalledWith(expect.objectContaining({ id: "turn-1" }), 1); + } finally { + await mounted.cleanup(); + } + }); + + it("disables send-now when queued dispatch is blocked", async () => { + const mounted = await mountPanel({ canSendNow: false }); + + try { + await expect + .element(page.getByRole("button", { name: "Send queued follow-up now" }).first()) + .toBeDisabled(); + } finally { + await mounted.cleanup(); + } + }); + + it("supports keyboard row traversal and reorder shortcuts", async () => { + const mounted = await mountPanel(); + + try { + const firstRow = page.getByTestId("queued-follow-up-turn-1"); + await firstRow.click(); + (await firstRow.element())?.focus(); + + document.activeElement?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowDown", + altKey: true, + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor(() => { + expect(document.activeElement?.getAttribute("data-testid")).toBe("queued-follow-up-turn-2"); + }); + + document.activeElement?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowUp", + altKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor(() => { + expect(mounted.onMove).toHaveBeenCalledWith(expect.objectContaining({ id: "turn-2" }), 0); + }); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/QueuedFollowUpsPanel.tsx b/apps/web/src/components/QueuedFollowUpsPanel.tsx new file mode 100644 index 0000000000..dd3a515d63 --- /dev/null +++ b/apps/web/src/components/QueuedFollowUpsPanel.tsx @@ -0,0 +1,376 @@ +import { + BookmarkPlusIcon, + ChevronDownIcon, + ChevronUpIcon, + CheckIcon, + PencilIcon, + SendHorizontalIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { cn } from "../lib/utils"; +import type { QueuedTurnDraft } from "../queuedTurnStore"; +import { Button } from "./ui/button"; + +interface QueuedFollowUpsPanelProps { + queuedItems: readonly QueuedTurnDraft[]; + canSendNow: boolean; + onSendNow: (draft: QueuedTurnDraft) => void; + onSaveAsSnippet: (draft: QueuedTurnDraft) => void; + onDelete: (draft: QueuedTurnDraft) => void; + onClearAll: () => void; + onMove: (draft: QueuedTurnDraft, nextIndex: number) => void; + onReplaceText: (draft: QueuedTurnDraft, nextText: string) => void; +} + +const PREVIEW_MAX_CHARS = 120; + +function summarizeQueuedTurn(turn: QueuedTurnDraft): string { + const normalized = turn.text.trim().replace(/\s+/g, " "); + if (normalized.length > 0) { + return normalized.length <= PREVIEW_MAX_CHARS + ? normalized + : `${normalized.slice(0, PREVIEW_MAX_CHARS - 1)}…`; + } + if (turn.images.length > 0) { + return turn.images.length === 1 + ? "1 image attachment" + : `${turn.images.length} image attachments`; + } + if (turn.terminalContexts.length > 0) { + return turn.terminalContexts.length === 1 + ? "1 terminal context" + : `${turn.terminalContexts.length} terminal contexts`; + } + return "Queued follow-up"; +} + +export function QueuedFollowUpsPanel({ + queuedItems, + canSendNow, + onSendNow, + onSaveAsSnippet, + onDelete, + onClearAll, + onMove, + onReplaceText, +}: QueuedFollowUpsPanelProps) { + const rowRefs = useRef(new Map()); + + const focusRowAtIndex = useCallback( + (index: number) => { + const target = queuedItems[index]; + if (!target) { + return; + } + rowRefs.current.get(target.id)?.focus(); + }, + [queuedItems], + ); + + if (queuedItems.length === 0) { + return null; + } + + return ( +
+
+
+

+ {queuedItems.length === 1 + ? "1 queued follow-up" + : `${queuedItems.length} queued follow-ups`} +

+

+ {canSendNow ? "Ready to dispatch in order." : "Waiting for the current turn to settle."} +

+

+ `Alt+Up/Down` switches rows. `Alt+Shift+Up/Down` reorders them. +

+
+ +
+ +
    + {queuedItems.map((item, index) => ( + onSendNow(item)} + onSaveAsSnippet={() => onSaveAsSnippet(item)} + onDelete={() => onDelete(item)} + onMoveToIndex={(nextIndex) => { + onMove(item, nextIndex); + queueMicrotask(() => focusRowAtIndex(nextIndex)); + }} + onFocusRowIndex={focusRowAtIndex} + rowRef={(node) => { + if (node) { + rowRefs.current.set(item.id, node); + } else { + rowRefs.current.delete(item.id); + } + }} + onReplaceText={(nextText) => onReplaceText(item, nextText)} + index={index} + totalRows={queuedItems.length} + /> + ))} +
+
+ ); +} + +function QueuedFollowUpRow(props: { + item: QueuedTurnDraft; + isNext: boolean; + canSendNow: boolean; + onSendNow: () => void; + onSaveAsSnippet: () => void; + onDelete: () => void; + onMoveToIndex: (nextIndex: number) => void; + onFocusRowIndex: (index: number) => void; + rowRef: (node: HTMLLIElement | null) => void; + onReplaceText: (nextText: string) => void; + index: number; + totalRows: number; +}) { + const { + item, + isNext, + canSendNow, + onSendNow, + onSaveAsSnippet, + onDelete, + onMoveToIndex, + onFocusRowIndex, + rowRef, + onReplaceText, + index, + totalRows, + } = props; + const [editing, setEditing] = useState(false); + const [draftText, setDraftText] = useState(item.text); + const textareaRef = useRef(null); + const canSaveAsSnippet = item.text.trim().length > 0; + const canMoveUp = index > 0; + const canMoveDown = index < totalRows - 1; + + useEffect(() => { + if (!editing) { + setDraftText(item.text); + } + }, [editing, item.text]); + + useEffect(() => { + if (!editing || !textareaRef.current) { + return; + } + textareaRef.current.focus(); + textareaRef.current.setSelectionRange( + textareaRef.current.value.length, + textareaRef.current.value.length, + ); + }, [editing]); + + const handleSave = useCallback(() => { + onReplaceText(draftText); + setEditing(false); + }, [draftText, onReplaceText]); + + const handleCancel = useCallback(() => { + setDraftText(item.text); + setEditing(false); + }, [item.text]); + + return ( +
  • { + if (editing || !event.altKey || event.metaKey || event.ctrlKey) { + return; + } + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") { + return; + } + event.preventDefault(); + const nextIndex = event.key === "ArrowUp" ? index - 1 : index + 1; + if (nextIndex < 0 || nextIndex >= totalRows) { + return; + } + if (event.shiftKey) { + onMoveToIndex(nextIndex); + return; + } + onFocusRowIndex(nextIndex); + }} + > +
    + + {isNext ? "Next up" : "Queued"} + + +
    + {editing ? ( +
    +