History

Merge origin/main into statemachine

Julius Marminge committed 8 months ago

Commit bbb88d12 parents 3faa32a 24122b150 files · +2181 −563Browse files

Unified · Split

50 files
  1. .docs/encyclopedia.md +180 −0
  2. .github/VOUCHED.td +28 −0
  3. .github/pull_request_template.md +33 −0
  4. .github/workflows/ci.yml +20 −0
  5. .github/workflows/pr-vouch.yml +175 −0
  6. .github/workflows/release.yml +12 −28
  7. CONTRIBUTING.md +65 −0
  8. README.md +4 −0
  9. apps/desktop/package.json +1 −1
  10. apps/desktop/src/main.ts +110 −16
  11. apps/desktop/src/runtimeArch.test.ts +49 −0
  12. apps/desktop/src/runtimeArch.ts +40 −0
  13. apps/desktop/src/updateMachine.test.ts +12 −6
  14. apps/desktop/src/updateMachine.ts +8 −3
  15. apps/desktop/src/updateState.test.ts +3 −0
  16. apps/marketing/src/pages/index.astro +1 −3
  17. apps/server/package.json +1 −1
  18. apps/server/scripts/cli.ts +2 −0
  19. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +0 −9
  20. apps/server/src/orchestration/decider.ts +0 −1
  21. apps/server/src/provider/Layers/CodexAdapter.ts +0 −1
  22. apps/web/package.json +1 −1
  23. apps/web/src/appSettings.test.ts +0 −21
  24. apps/web/src/appSettings.ts +1 −38
  25. apps/web/src/components/ChatView.tsx +270 −112
  26. apps/web/src/components/PlanSidebar.tsx +267 −0
  27. apps/web/src/components/ProjectScriptsControl.tsx +45 −1
  28. apps/web/src/components/Sidebar.logic.test.ts +124 −0
  29. apps/web/src/components/Sidebar.logic.ts +100 −0
  30. apps/web/src/components/Sidebar.tsx +204 −129
  31. apps/web/src/components/desktopUpdate.logic.test.ts +31 −0
  32. apps/web/src/components/desktopUpdate.logic.ts +19 −0
  33. apps/web/src/components/ui/sidebar.tsx +2 −1
  34. apps/web/src/components/ui/toast.logic.test.ts +40 −1
  35. apps/web/src/components/ui/toast.logic.ts +37 −0
  36. apps/web/src/components/ui/toast.tsx +16 −4
  37. apps/web/src/proposedPlan.ts +16 −0
  38. apps/web/src/routes/_chat.settings.tsx +5 −55
  39. apps/web/src/store.ts +1 −0
  40. apps/web/vite.config.ts +9 −0
  41. bun.lock +4 −4
  42. package.json +1 −0
  43. packages/contracts/package.json +1 −1
  44. packages/contracts/src/ipc.ts +11 −0
  45. packages/contracts/src/orchestration.ts +0 −123
  46. packages/contracts/src/provider.ts +0 −3
  47. scripts/build-desktop-artifact.ts +6 −0
  48. scripts/dev-runner.ts +2 −0
  49. scripts/release-smoke.ts +113 −0
  50. scripts/update-release-package-versions.ts +111 −0
.docs/encyclopedia.md+180−0
@@ -0,0 +1,180 @@
1+# Encyclopedia
2+
3+This is a living glossary for T3 Code. It explains what common terms mean in this codebase.
4+
5+## Table of contents
6+
7+- [Project and workspace](#project-and-workspace)
8+- [Thread timeline](#thread-timeline)
9+- [Orchestration](#orchestration)
10+- [Provider runtime](#provider-runtime)
11+- [Checkpointing](#checkpointing)
12+
13+## Concepts
14+
15+### Project and workspace
16+
17+#### Project
18+
19+The top-level workspace record in the app. In [the orchestration contracts][1], a project has a `workspaceRoot`, a title, and one or more threads. See [workspace-layout.md][2].
20+
21+#### Workspace root
22+
23+The root filesystem path for a project. In [the orchestration model][1], it is the base directory for branches and optional worktrees. See [workspace-layout.md][2].
24+
25+#### Worktree
26+
27+A Git worktree used as an isolated workspace for a thread. If a thread has a `worktreePath` in [the contracts][1], it runs there instead of in the main working tree. Git operations live in [GitCore.ts][3].
28+
29+### Thread timeline
30+
31+#### Thread
32+
33+The main durable unit of conversation and workspace history. In [the orchestration contracts][1], a thread holds messages, activities, checkpoints, and session-related state. See [projector.ts][4].
34+
35+#### Turn
36+
37+A single user-to-assistant work cycle inside a thread. It starts with user input and ends when follow-up work like checkpointing settles. See [the contracts][1], [ProviderRuntimeIngestion.ts][5], and [CheckpointReactor.ts][6].
38+
39+#### Activity
40+
41+A user-visible log item attached to a thread. In [the contracts][1], activities cover important non-message events like approvals, tool actions, and failures. They are projected into thread state in [projector.ts][4].
42+
43+### Orchestration
44+
45+Orchestration is the server-side domain layer that turns runtime activity into stable app state. The main entry point is [OrchestrationEngine.ts][7], with core logic in [decider.ts][8] and [projector.ts][4].
46+
47+#### Aggregate
48+
49+The domain object a command or event belongs to. In [the contracts][1], that is usually `project` or `thread`. See [decider.ts][8].
50+
51+#### Command
52+
53+A typed request to change domain state. In [the contracts][1], commands are validated in [commandInvariants.ts][9] and turned into events by [decider.ts][8].
54+Examples include `thread.create`, `thread.turn.start`, and `thread.checkpoint.revert`.
55+
56+#### Domain Event
57+
58+A persisted fact that something already happened. In [the contracts][1], events are the source of truth, and [projector.ts][4] shows how they are applied.
59+Examples include `thread.created`, `thread.message-sent`, and `thread.turn-diff-completed`.
60+
61+#### Decider
62+
63+The pure orchestration logic that turns commands plus current state into events. The core implementation is in [decider.ts][8], with preconditions in [commandInvariants.ts][9].
64+
65+#### Projection
66+
67+A read-optimized view derived from events. See [projector.ts][4], [ProjectionPipeline.ts][11], and [ProjectionSnapshotQuery.ts][10].
68+
69+#### Projector
70+
71+The logic that applies domain events to the read model or projection tables. See [projector.ts][4] and [ProjectionPipeline.ts][11].
72+
73+#### Read model
74+
75+The current materialized view of orchestration state. In [the contracts][1], it holds projects, threads, messages, activities, checkpoints, and session state. See [ProjectionSnapshotQuery.ts][10] and [OrchestrationEngine.ts][7].
76+
77+#### Reactor
78+
79+A side-effecting service that handles follow-up work after events or runtime signals. Examples include [CheckpointReactor.ts][6], [ProviderCommandReactor.ts][12], and [ProviderRuntimeIngestion.ts][5].
80+
81+#### Receipt
82+
83+A lightweight typed runtime signal emitted when an async milestone completes. See [RuntimeReceiptBus.ts][13].
84+Examples include `checkpoint.baseline.captured`, `checkpoint.diff.finalized`, and `turn.processing.quiesced`, which are emitted by flows such as [CheckpointReactor.ts][6].
85+
86+#### Quiesced
87+
88+"Quiesced" means a turn has gone quiet and stable. In [the receipt schema][13], it means the follow-up work has settled, including work in [CheckpointReactor.ts][6].
89+
90+### Provider runtime
91+
92+The live backend agent implementation and its event stream. The main service is [ProviderService.ts][14], the adapter contract is [ProviderAdapter.ts][15], and the overview is in [provider-architecture.md][16].
93+
94+#### Provider
95+
96+The backend agent runtime that actually performs work. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17].
97+
98+#### Session
99+
100+The live provider-backed runtime attached to a thread. Session shape is in [the orchestration contracts][1], and lifecycle is managed in [ProviderService.ts][14].
101+
102+#### Runtime mode
103+
104+The safety/access mode for a thread or session. In [the contracts][1], the main values are `approval-required` and `full-access`. See [runtime-modes.md][18].
105+
106+#### Interaction mode
107+
108+The agent interaction style for a thread. In [the contracts][1], the main values are `default` and `plan`. See [runtime-modes.md][18].
109+
110+#### Assistant delivery mode
111+
112+Controls how assistant text reaches the thread timeline. In [the contracts][1], `streaming` updates incrementally and `buffered` delivers a completed result. See [ProviderService.ts][14].
113+
114+#### Snapshot
115+
116+A point-in-time view of state. The word is used in multiple layers, including orchestration, provider, and checkpointing. See [ProjectionSnapshotQuery.ts][10], [ProviderAdapter.ts][15], and [CheckpointStore.ts][19].
117+
118+### Checkpointing
119+
120+Checkpointing captures workspace state over time so the app can diff turns and restore earlier points. The main pieces are [CheckpointStore.ts][19], [CheckpointDiffQuery.ts][20], and [CheckpointReactor.ts][6].
121+
122+#### Checkpoint
123+
124+A saved snapshot of a thread workspace at a particular turn. In practice it is a hidden Git ref in [CheckpointStore.ts][19] plus a projected summary from [ProjectionCheckpoints.ts][21]. Capture and lifecycle work happen in [CheckpointReactor.ts][6].
125+
126+#### Checkpoint ref
127+
128+The durable identifier for a filesystem checkpoint, stored as a Git ref. It is typed in [the contracts][1], constructed in [Utils.ts][22], and used by [CheckpointStore.ts][19].
129+
130+#### Checkpoint baseline
131+
132+The starting checkpoint for diffing a thread timeline. This flow is surfaced through [RuntimeReceiptBus.ts][13], coordinated in [CheckpointReactor.ts][6], and supported by [Utils.ts][22].
133+
134+#### Checkpoint diff
135+
136+The patch difference between two checkpoints. Query logic lives in [CheckpointDiffQuery.ts][20], diff parsing lives in [Diffs.ts][23], and finalization is coordinated by [CheckpointReactor.ts][6].
137+
138+#### Turn diff
139+
140+The file patch and changed-file summary for one turn. It is usually computed in [CheckpointDiffQuery.ts][20], represented in [the contracts][1], and recorded into thread state by [projector.ts][4].
141+
142+## Practical Shortcuts
143+
144+- If you see `requested`, think "intent recorded".
145+- If you see `completed`, think "result applied".
146+- If you see `receipt`, think "async milestone signal".
147+- If you see `checkpoint`, think "workspace snapshot for diff/restore".
148+- If you see `quiesced`, think "all relevant follow-up work has gone idle".
149+
150+## Related Docs
151+
152+- [architecture.md][24]
153+- [provider-architecture.md][16]
154+- [runtime-modes.md][18]
155+- [workspace-layout.md][2]
156+
157+[1]: ../packages/contracts/src/orchestration.ts
158+[2]: ./workspace-layout.md
159+[3]: ../apps/server/src/git/Layers/GitCore.ts
160+[4]: ../apps/server/src/orchestration/projector.ts
161+[5]: ../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
162+[6]: ../apps/server/src/orchestration/Layers/CheckpointReactor.ts
163+[7]: ../apps/server/src/orchestration/Layers/OrchestrationEngine.ts
164+[8]: ../apps/server/src/orchestration/decider.ts
165+[9]: ../apps/server/src/orchestration/commandInvariants.ts
166+[10]: ../apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
167+[11]: ../apps/server/src/orchestration/Layers/ProjectionPipeline.ts
168+[12]: ../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
169+[13]: ../apps/server/src/orchestration/Services/RuntimeReceiptBus.ts
170+[14]: ../apps/server/src/provider/Layers/ProviderService.ts
171+[15]: ../apps/server/src/provider/Services/ProviderAdapter.ts
172+[16]: ./provider-architecture.md
173+[17]: ../apps/server/src/provider/Layers/CodexAdapter.ts
174+[18]: ./runtime-modes.md
175+[19]: ../apps/server/src/checkpointing/Services/CheckpointStore.ts
176+[20]: ../apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts
177+[21]: ../apps/server/src/persistence/Services/ProjectionCheckpoints.ts
178+[22]: ../apps/server/src/checkpointing/Utils.ts
179+[23]: ../apps/server/src/checkpointing/Diffs.ts
180+[24]: ./architecture.md
.github/VOUCHED.td+28−0
@@ -0,0 +1,28 @@
1+# Trust list for this repository.
2+#
3+# External contributors listed here are treated as trusted by the vouch
4+# workflow. Collaborators with write access are automatically trusted and
5+# do not need to be duplicated in this file.
6+#
7+# Syntax:
8+# github:username
9+# -github:username reason for denouncement
10+#
11+# Keep entries sorted alphabetically.
12+github:adityavardhansharma
13+github:binbandit
14+github:chuks-qua
15+github:cursoragent
16+github:gbarros-dev
17+github:github-actions[bot]
18+github:hwanseoc
19+github:jamesx0416
20+github:jasonLaster
21+github:JoeEverest
22+github:nmggithub
23+github:Noojuno
24+github:notkainoa
25+github:PatrickBauer
26+github:realAhmedRoach
27+github:shiroyasha9
28+github:Yash-Singh1
.github/pull_request_template.md+33−0
@@ -0,0 +1,33 @@
1+<!--
2+⚠️ READ BEFORE OPENING ⚠️
3+
4+We are not actively accepting contributions right now.
5+
6+You can still open a PR, but please do so knowing there is a high chance
7+we may close it without merging it, or never review it.
8+
9+- Small, focused PRs are strongly preferred. Bug fixes are most likely to be merged.
10+- New features will most likely just annoy us.
11+- 1,000+ line PRs with a bunch of new features will probably get you banned from the repo.
12+-->
13+
14+## What Changed
15+
16+<!-- Describe the change clearly and keep scope tight. -->
17+
18+## Why
19+
20+<!-- Explain the problem being solved and why this approach is the right one. -->
21+
22+## UI Changes
23+
24+<!-- If this PR changes UI, include clear before/after screenshots.
25+ If the change involves motion or interaction, include a short video.
26+ Delete this section if not applicable. -->
27+
28+## Checklist
29+
30+- [ ] This PR is small and focused
31+- [ ] I explained what changed and why
32+- [ ] I included before/after screenshots for any UI changes
33+- [ ] I included a video for animation/interaction changes
.github/workflows/ci.yml+20−0
@@ -69,3 +69,23 @@ jobs: · 68 unmodified lines
6969 run: |
7070 test -f apps/desktop/dist-electron/preload.js
7171 grep -nE "desktopBridge|getWsUrl|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js
72+
73+ release_smoke:
74+ name: Release Smoke
75+ runs-on: ubuntu-24.04
76+ steps:
77+ - name: Checkout
78+ uses: actions/checkout@v4
79+
80+ - name: Setup Bun
81+ uses: oven-sh/setup-bun@v2
82+ with:
83+ bun-version-file: package.json
84+
85+ - name: Setup Node
86+ uses: actions/setup-node@v4
87+ with:
88+ node-version-file: package.json
89+
90+ - name: Exercise release-only workflow steps
91+ run: node scripts/release-smoke.ts
.github/workflows/pr-vouch.yml+175−0
@@ -0,0 +1,175 @@
1+name: PR Vouch
2+
3+on:
4+ pull_request_target:
5+ types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
6+ issue_comment:
7+ types: [created]
8+ push:
9+ branches:
10+ - main
11+ paths:
12+ - .github/VOUCHED.td
13+ - .github/workflows/pr-vouch.yml
14+
15+permissions:
16+ contents: read
17+ issues: write
18+ pull-requests: write
19+
20+jobs:
21+ collect-targets:
22+ name: Collect PR targets
23+ runs-on: ubuntu-24.04
24+ outputs:
25+ targets: ${{ steps.collect.outputs.targets }}
26+ steps:
27+ - id: collect
28+ uses: actions/github-script@v7
29+ with:
30+ script: |
31+ if (context.eventName === "pull_request_target") {
32+ const pr = context.payload.pull_request;
33+ core.setOutput("targets", JSON.stringify([{ number: pr.number, user: pr.user.login }]));
34+ return;
35+ }
36+
37+ if (context.eventName === "issue_comment") {
38+ const issue = context.payload.issue;
39+ const body = context.payload.comment?.body ?? "";
40+ if (!issue?.pull_request || !body.includes("/recheck-vouch")) {
41+ core.setOutput("targets", "[]");
42+ return;
43+ }
44+
45+ core.setOutput(
46+ "targets",
47+ JSON.stringify([{ number: issue.number, user: issue.user.login }]),
48+ );
49+ return;
50+ }
51+
52+ const pulls = await github.paginate(github.rest.pulls.list, {
53+ owner: context.repo.owner,
54+ repo: context.repo.repo,
55+ state: "open",
56+ per_page: 100,
57+ });
58+
59+ const targets = pulls.map((pull) => ({
60+ number: pull.number,
61+ user: pull.user.login,
62+ }));
63+ core.setOutput("targets", JSON.stringify(targets));
64+
65+ label:
66+ name: Label PR ${{ matrix.target.number }}
67+ needs: collect-targets
68+ if: ${{ needs.collect-targets.outputs.targets != '[]' }}
69+ runs-on: ubuntu-24.04
70+ concurrency:
71+ group: pr-vouch-${{ matrix.target.number }}
72+ cancel-in-progress: true
73+ strategy:
74+ fail-fast: false
75+ matrix:
76+ target: ${{ fromJson(needs.collect-targets.outputs.targets) }}
77+ steps:
78+ - id: vouch
79+ name: Check PR author trust
80+ uses: mitchellh/vouch/action/check-user@v1
81+ with:
82+ user: ${{ matrix.target.user }}
83+ allow-fail: true
84+ env:
85+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
86+
87+ - name: Sync PR labels
88+ uses: actions/github-script@v7
89+ env:
90+ PR_NUMBER: ${{ matrix.target.number }}
91+ VOUCH_STATUS: ${{ steps.vouch.outputs.status }}
92+ with:
93+ script: |
94+ const issueNumber = Number(process.env.PR_NUMBER);
95+ const status = process.env.VOUCH_STATUS;
96+ const managedLabels = [
97+ {
98+ name: "vouch:trusted",
99+ color: "1f883d",
100+ description: "PR author is trusted by repo permissions or the VOUCHED list.",
101+ },
102+ {
103+ name: "vouch:unvouched",
104+ color: "fbca04",
105+ description: "PR author is not yet trusted in the VOUCHED list.",
106+ },
107+ {
108+ name: "vouch:denounced",
109+ color: "d1242f",
110+ description: "PR author is explicitly blocked by the VOUCHED list.",
111+ },
112+ ];
113+
114+ const managedLabelNames = managedLabels.map((label) => label.name);
115+
116+ for (const label of managedLabels) {
117+ try {
118+ const { data: existing } = await github.rest.issues.getLabel({
119+ owner: context.repo.owner,
120+ repo: context.repo.repo,
121+ name: label.name,
122+ });
123+
124+ if (
125+ existing.color !== label.color ||
126+ (existing.description ?? "") !== label.description
127+ ) {
128+ await github.rest.issues.updateLabel({
129+ owner: context.repo.owner,
130+ repo: context.repo.repo,
131+ name: label.name,
132+ color: label.color,
133+ description: label.description,
134+ });
135+ }
136+ } catch (error) {
137+ if (error.status !== 404) {
138+ throw error;
139+ }
140+
141+ await github.rest.issues.createLabel({
142+ owner: context.repo.owner,
143+ repo: context.repo.repo,
144+ name: label.name,
145+ color: label.color,
146+ description: label.description,
147+ });
148+ }
149+ }
150+
151+ const nextLabelName =
152+ status === "denounced"
153+ ? "vouch:denounced"
154+ : ["bot", "collaborator", "vouched"].includes(status)
155+ ? "vouch:trusted"
156+ : "vouch:unvouched";
157+
158+ const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
159+ owner: context.repo.owner,
160+ repo: context.repo.repo,
161+ issue_number: issueNumber,
162+ });
163+
164+ const preservedLabels = currentLabels
165+ .map((label) => label.name)
166+ .filter((name) => !managedLabelNames.includes(name));
167+
168+ await github.rest.issues.setLabels({
169+ owner: context.repo.owner,
170+ repo: context.repo.repo,
171+ issue_number: issueNumber,
172+ labels: [...preservedLabels, nextLabelName],
173+ });
174+
175+ core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`);
.github/workflows/release.yml+12−28
@@ -255,6 +255,16 @@ jobs: · 254 unmodified lines
255255 needs: [preflight, build, publish_cli]
256256 runs-on: ubuntu-24.04
257257 steps:
258+ - name: Checkout
259+ uses: actions/checkout@v4
260+ with:
261+ ref: ${{ needs.preflight.outputs.ref }}
262+
263+ - name: Setup Node
264+ uses: actions/setup-node@v4
265+ with:
266+ node-version-file: package.json
267+
258268 - name: Download all desktop artifacts
259269 uses: actions/download-artifact@v4
260270 with:
@@ -312,33 +322,7 @@ jobs: · 51 unmodified lines
312322 name: Update version strings
313323 env:
314324 RELEASE_VERSION: ${{ needs.preflight.outputs.version }}
315− run: |
316− node --input-type=module -e '
317− import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
318−
319− const files = [
320− "apps/server/package.json",
321− "apps/desktop/package.json",
322− "apps/web/package.json",
323− "packages/contracts/package.json",
324− ];
325−
326− let changed = false;
327− for (const file of files) {
328− const packageJson = JSON.parse(readFileSync(file, "utf8"));
329− if (packageJson.version !== process.env.RELEASE_VERSION) {
330− packageJson.version = process.env.RELEASE_VERSION;
331− writeFileSync(file, `${JSON.stringify(packageJson, null, 2)}\n`);
332− changed = true;
333− }
334− }
335−
336− if (!changed) {
337− console.log("All package.json versions already match release version.");
338− }
339−
340− appendFileSync(process.env.GITHUB_OUTPUT, `changed=${changed}\n`);
341− '
325+ run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output
342326
343327 - name: Format package.json files
344328 if: steps.update_versions.outputs.changed == 'true'
@@ -346,7 +330,7 @@ jobs: · 1 unmodified line
346330
347331 - name: Refresh lockfile
348332 if: steps.update_versions.outputs.changed == 'true'
349− run: bun install
333+ run: bun install --lockfile-only --ignore-scripts
350334
351335 - name: Commit and push version bump
352336 if: steps.update_versions.outputs.changed == 'true'
CONTRIBUTING.md+65−0
@@ -0,0 +1,65 @@
1+# Contributing
2+
3+## Read This First
4+
5+We are not actively accepting contributions right now.
6+
7+You can still open an issue or PR, but please do so knowing there is a high chance we close it, defer it forever, or never look at it.
8+
9+If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control.
10+
11+PRs are automatically labeled with a `vouch:*` trust status.
12+
13+If you are an external contributor, expect `vouch:unvouched` until we explicitly add you to [.github/VOUCHED.td](.github/VOUCHED.td).
14+
15+## What We Are Most Likely To Accept
16+
17+Small, focused bug fixes.
18+
19+Small reliability fixes.
20+
21+Small performance improvements.
22+
23+Tightly scoped maintenance work that clearly improves the project without changing its direction.
24+
25+## What We Are Least Likely To Accept
26+
27+Large PRs.
28+
29+Drive-by feature work.
30+
31+Opinionated rewrites.
32+
33+Anything that expands product scope without us asking for it first.
34+
35+If you open a 1,000+ line PR full of new features, we will probably close it quickly and remember that you ignored the clearly written instructions.
36+
37+## If You Still Want To Open A PR
38+
39+Keep it small.
40+
41+Explain exactly what changed.
42+
43+Explain exactly why the change should exist.
44+
45+Do not mix unrelated fixes together.
46+
47+If the PR makes anything resembling a UI change, include clear before/after images.
48+
49+If the change depends on motion, timing, transitions, or interaction details, include a short video.
50+
51+If we have to guess what changed, we are much less likely to review it.
52+
53+## Issues First
54+
55+If you are thinking about a non-trivial change, open an issue first.
56+
57+That still does not mean we will want the PR, but it gives you a chance to avoid wasting your time.
58+
59+## Be Realistic
60+
61+Opening a PR does not create an obligation on our side.
62+
63+We may close it. We may ignore it. We may ask you to shrink it. We may reimplement the idea ourselves later.
64+
65+If you are fine with that, proceed.
README.md+4−0
@@ -21,4 +21,8 @@ We are very very early in this project. Expect bugs. · 20 unmodified lines
2121
2222 We are not accepting contributions yet.
2323
24+## If you REALLY want to contribute still.... read this first
25+
26+Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.
27+
2428 Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).
apps/desktop/package.json+1−1
@@ -1,6 +1,6 @@
11 {
22 "name": "@t3tools/desktop",
3− "version": "0.0.5",
3+ "version": "0.0.9",
44 "private": true,
55 "main": "dist-electron/main.js",
66 "scripts": {
apps/desktop/src/main.ts+110−16
@@ -31,6 +31,7 @@ import { · 30 unmodified lines
3131 reduceDesktopUpdateStateOnNoUpdate,
3232 reduceDesktopUpdateStateOnUpdateAvailable,
3333 } from "./updateMachine";
34+import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
3435
3536 fixPath();
3637
@@ -50,6 +51,8 @@ const ROOT_DIR = Path.resolve(__dirname, "../../.."); · 13 unmodified lines
5051 const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
5152 const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
5253 const APP_USER_MODEL_ID = "com.t3tools.t3code";
54+const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code";
55+const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
5356 const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;
5457 const COMMIT_HASH_DISPLAY_LENGTH = 12;
5558 const LOG_DIR = Path.join(STATE_DIR, "logs");
@@ -78,7 +81,13 @@ let backendLogSink: RotatingFileSink | null = null; · 22 unmodified lines
7881 let restoreStdIoCapture: (() => void) | null = null;
7982
8083 let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
81−const initialUpdateState = (): DesktopUpdateState => createInitialDesktopUpdateState(app.getVersion());
84+const desktopRuntimeInfo = resolveDesktopRuntimeInfo({
85+ platform: process.platform,
86+ processArch: process.arch,
87+ runningUnderArm64Translation: app.runningUnderARM64Translation === true,
88+});
89+const initialUpdateState = (): DesktopUpdateState =>
90+ createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo);
8291
8392 function logTimestamp(): string {
8493 return new Date().toISOString();
@@ -112,6 +121,25 @@ function formatErrorMessage(error: unknown): string { · 27 unmodified lines
112121 return String(error);
113122 }
114123
124+function getSafeExternalUrl(rawUrl: unknown): string | null {
125+ if (typeof rawUrl !== "string" || rawUrl.length === 0) {
126+ return null;
127+ }
128+
129+ let parsedUrl: URL;
130+ try {
131+ parsedUrl = new URL(rawUrl);
132+ } catch {
133+ return null;
134+ }
135+
136+ if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
137+ return null;
138+ }
139+
140+ return parsedUrl.toString();
141+}
142+
115143 function writeDesktopStreamChunk(
116144 streamName: "stdout" | "stderr",
117145 chunk: unknown,
@@ -574,6 +602,34 @@ function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { · 456 unmodified lines
574602 return resolveResourcePath(`icon.${ext}`);
575603 }
576604
605+/**
606+ * Resolve the Electron userData directory path.
607+ *
608+ * Electron derives the default userData path from `productName` in
609+ * package.json, which currently produces directories with spaces and
610+ * parentheses (e.g. `~/.config/T3 Code (Alpha)` on Linux). This is
611+ * unfriendly for shell usage and violates Linux naming conventions.
612+ *
613+ * We override it to a clean lowercase name (`t3code`). If the legacy
614+ * directory already exists we keep using it so existing users don't
615+ * lose their Chromium profile data (localStorage, cookies, sessions).
616+ */
617+function resolveUserDataPath(): string {
618+ const appDataBase =
619+ process.platform === "win32"
620+ ? process.env.APPDATA || Path.join(OS.homedir(), "AppData", "Roaming")
621+ : process.platform === "darwin"
622+ ? Path.join(OS.homedir(), "Library", "Application Support")
623+ : process.env.XDG_CONFIG_HOME || Path.join(OS.homedir(), ".config");
624+
625+ const legacyPath = Path.join(appDataBase, LEGACY_USER_DATA_DIR_NAME);
626+ if (FS.existsSync(legacyPath)) {
627+ return legacyPath;
628+ }
629+
630+ return Path.join(appDataBase, USER_DATA_DIR_NAME);
631+}
632+
577633 function configureAppIdentity(): void {
578634 app.setName(APP_DISPLAY_NAME);
579635 const commitHash = resolveAboutCommitHash();
@@ -659,6 +715,7 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed · 79 unmodified lines
659715 }
660716 updateDownloadInFlight = true;
661717 setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState));
718+ autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
662719 console.info("[desktop-updater] Downloading update...");
663720
664721 try {
@@ -697,7 +754,7 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed · 32 unmodified lines
697754 function configureAutoUpdater(): void {
698755 const enabled = shouldEnableAutoUpdates();
699756 setUpdateState({
700− ...createInitialDesktopUpdateState(app.getVersion()),
757+ ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo),
701758 enabled,
702759 status: enabled ? "idle" : "disabled",
703760 });
@@ -731,8 +788,15 @@ function configureAutoUpdater(): void { · 27 unmodified lines
731788 autoUpdater.channel = DESKTOP_UPDATE_CHANNEL;
732789 autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE;
733790 autoUpdater.allowDowngrade = false;
791+ autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
734792 let lastLoggedDownloadMilestone = -1;
735793
794+ if (isArm64HostRunningIntelBuild(desktopRuntimeInfo)) {
795+ console.info(
796+ "[desktop-updater] Apple Silicon host detected while running Intel build; updates will switch to arm64 packages.",
797+ );
798+ }
799+
736800 autoUpdater.on("checking-for-update", () => {
737801 console.info("[desktop-updater] Looking for updates...");
738802 });
@@ -1033,23 +1097,13 @@ function registerIpcHandlers(): void { · 294 unmodified lines
10331097
10341098 ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL);
10351099 ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => {
1036− if (typeof rawUrl !== "string" || rawUrl.length === 0) {
1037− return false;
1038− }
1039−
1040− let parsedUrl: URL;
1041− try {
1042− parsedUrl = new URL(rawUrl);
1043− } catch {
1044− return false;
1045− }
1046−
1047− if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
1100+ const externalUrl = getSafeExternalUrl(rawUrl);
1101+ if (!externalUrl) {
10481102 return false;
10491103 }
10501104
10511105 try {
1052− await shell.openExternal(parsedUrl.toString());
1106+ await shell.openExternal(externalUrl);
10531107 return true;
10541108 } catch {
10551109 return false;
@@ -1114,7 +1168,42 @@ function createWindow(): BrowserWindow { · 58 unmodified lines
11141168 },
11151169 });
11161170
1117− window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
1171+ window.webContents.on("context-menu", (event, params) => {
1172+ event.preventDefault();
1173+
1174+ const menuTemplate: MenuItemConstructorOptions[] = [];
1175+
1176+ if (params.misspelledWord) {
1177+ for (const suggestion of params.dictionarySuggestions.slice(0, 5)) {
1178+ menuTemplate.push({
1179+ label: suggestion,
1180+ click: () => window.webContents.replaceMisspelling(suggestion),
1181+ });
1182+ }
1183+ if (params.dictionarySuggestions.length === 0) {
1184+ menuTemplate.push({ label: "No suggestions", enabled: false });
1185+ }
1186+ menuTemplate.push({ type: "separator" });
1187+ }
1188+
1189+ menuTemplate.push(
1190+ { role: "cut", enabled: params.editFlags.canCut },
1191+ { role: "copy", enabled: params.editFlags.canCopy },
1192+ { role: "paste", enabled: params.editFlags.canPaste },
1193+ { role: "selectAll", enabled: params.editFlags.canSelectAll },
1194+ );
1195+
1196+ Menu.buildFromTemplate(menuTemplate).popup({ window });
1197+ });
1198+
1199+ window.webContents.setWindowOpenHandler(({ url }) => {
1200+ const externalUrl = getSafeExternalUrl(url);
1201+ if (externalUrl) {
1202+ void shell.openExternal(externalUrl);
1203+ }
1204+ return { action: "deny" };
1205+ });
1206+
11181207 window.on("page-title-updated", (event) => {
11191208 event.preventDefault();
11201209 window.setTitle(APP_DISPLAY_NAME);
@@ -1143,6 +1232,11 @@ function createWindow(): BrowserWindow { · 22 unmodified lines
11431232 return window;
11441233 }
11451234
1235+// Override Electron's userData path before the `ready` event so that
1236+// Chromium session data uses a filesystem-friendly directory name.
1237+// Must be called synchronously at the top level — before `app.whenReady()`.
1238+app.setPath("userData", resolveUserDataPath());
1239+
11461240 configureAppIdentity();
11471241
11481242 async function bootstrap(): Promise<void> {
apps/desktop/src/runtimeArch.test.ts+49−0
@@ -0,0 +1,49 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
4+
5+describe("resolveDesktopRuntimeInfo", () => {
6+ it("detects Rosetta-translated Intel builds on Apple Silicon", () => {
7+ const runtimeInfo = resolveDesktopRuntimeInfo({
8+ platform: "darwin",
9+ processArch: "x64",
10+ runningUnderArm64Translation: true,
11+ });
12+
13+ expect(runtimeInfo).toEqual({
14+ hostArch: "arm64",
15+ appArch: "x64",
16+ runningUnderArm64Translation: true,
17+ });
18+ expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(true);
19+ });
20+
21+ it("detects native Apple Silicon builds", () => {
22+ const runtimeInfo = resolveDesktopRuntimeInfo({
23+ platform: "darwin",
24+ processArch: "arm64",
25+ runningUnderArm64Translation: false,
26+ });
27+
28+ expect(runtimeInfo).toEqual({
29+ hostArch: "arm64",
30+ appArch: "arm64",
31+ runningUnderArm64Translation: false,
32+ });
33+ expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(false);
34+ });
35+
36+ it("passes through non-mac builds without translation", () => {
37+ const runtimeInfo = resolveDesktopRuntimeInfo({
38+ platform: "linux",
39+ processArch: "x64",
40+ runningUnderArm64Translation: true,
41+ });
42+
43+ expect(runtimeInfo).toEqual({
44+ hostArch: "x64",
45+ appArch: "x64",
46+ runningUnderArm64Translation: false,
47+ });
48+ });
49+});
apps/desktop/src/runtimeArch.ts+40−0
@@ -0,0 +1,40 @@
1+import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts";
2+
3+interface ResolveDesktopRuntimeInfoInput {
4+ readonly platform: NodeJS.Platform;
5+ readonly processArch: string;
6+ readonly runningUnderArm64Translation: boolean;
7+}
8+
9+function normalizeDesktopArch(arch: string): DesktopRuntimeArch {
10+ if (arch === "arm64") return "arm64";
11+ if (arch === "x64") return "x64";
12+ return "other";
13+}
14+
15+export function resolveDesktopRuntimeInfo(
16+ input: ResolveDesktopRuntimeInfoInput,
17+): DesktopRuntimeInfo {
18+ const appArch = normalizeDesktopArch(input.processArch);
19+
20+ if (input.platform !== "darwin") {
21+ return {
22+ hostArch: appArch,
23+ appArch,
24+ runningUnderArm64Translation: false,
25+ };
26+ }
27+
28+ const hostArch =
29+ appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch;
30+
31+ return {
32+ hostArch,
33+ appArch,
34+ runningUnderArm64Translation: input.runningUnderArm64Translation,
35+ };
36+}
37+
38+export function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean {
39+ return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64";
40+}
apps/desktop/src/updateMachine.test.ts+12−6
@@ -13,11 +13,17 @@ import { · 12 unmodified lines
1313 reduceDesktopUpdateStateOnUpdateAvailable,
1414 } from "./updateMachine";
1515
16+const runtimeInfo = {
17+ hostArch: "x64",
18+ appArch: "x64",
19+ runningUnderArm64Translation: false,
20+} as const;
21+
1622 describe("updateMachine", () => {
1723 it("clears transient errors when a check starts", () => {
1824 const state = reduceDesktopUpdateStateOnCheckStart(
1925 {
20− ...createInitialDesktopUpdateState("1.0.0"),
26+ ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
2127 enabled: true,
2228 status: "error",
2329 message: "network",
@@ -36,7 +42,7 @@ describe("updateMachine", () => { · 12 unmodified lines
3642 it("records a check failure without exposing an action", () => {
3743 const state = reduceDesktopUpdateStateOnCheckFailure(
3844 {
39− ...createInitialDesktopUpdateState("1.0.0"),
45+ ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
4046 enabled: true,
4147 status: "checking",
4248 },
@@ -52,7 +58,7 @@ describe("updateMachine", () => { · 9 unmodified lines
5258 it("preserves available version on download failure for retry", () => {
5359 const state = reduceDesktopUpdateStateOnDownloadFailure(
5460 {
55− ...createInitialDesktopUpdateState("1.0.0"),
61+ ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
5662 enabled: true,
5763 status: "downloading",
5864 availableVersion: "1.1.0",
@@ -70,7 +76,7 @@ describe("updateMachine", () => { · 11 unmodified lines
7076 it("transitions to downloaded and then preserves install retry state", () => {
7177 const downloaded = reduceDesktopUpdateStateOnDownloadComplete(
7278 {
73− ...createInitialDesktopUpdateState("1.0.0"),
79+ ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
7480 enabled: true,
7581 status: "downloading",
7682 availableVersion: "1.1.0",
@@ -89,7 +95,7 @@ describe("updateMachine", () => { · 12 unmodified lines
8995 it("clears stale download state when no update is available", () => {
9096 const state = reduceDesktopUpdateStateOnNoUpdate(
9197 {
92− ...createInitialDesktopUpdateState("1.0.0"),
98+ ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
9399 enabled: true,
94100 status: "error",
95101 availableVersion: "1.1.0",
@@ -111,7 +117,7 @@ describe("updateMachine", () => { · 15 unmodified lines
111117 it("tracks available, download start, and progress cleanly", () => {
112118 const available = reduceDesktopUpdateStateOnUpdateAvailable(
113119 {
114− ...createInitialDesktopUpdateState("1.0.0"),
120+ ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
115121 enabled: true,
116122 status: "checking",
117123 },
apps/desktop/src/updateMachine.ts+8−3
@@ -1,12 +1,18 @@
1−import type { DesktopUpdateState } from "@t3tools/contracts";
1+import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts";
22
33 import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState";
44
5−export function createInitialDesktopUpdateState(currentVersion: string): DesktopUpdateState {
5+export function createInitialDesktopUpdateState(
6+ currentVersion: string,
7+ runtimeInfo: DesktopRuntimeInfo,
8+): DesktopUpdateState {
69 return {
710 enabled: false,
811 status: "disabled",
912 currentVersion,
13+ hostArch: runtimeInfo.hostArch,
14+ appArch: runtimeInfo.appArch,
15+ runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation,
1016 availableVersion: null,
1117 downloadedVersion: null,
1218 downloadPercent: null,
@@ -152,4 +158,3 @@ export function reduceDesktopUpdateStateOnInstallFailure( · 139 unmodified lines
152158 canRetry: true,
153159 };
154160 }
155−
apps/desktop/src/updateState.test.ts+3−0
@@ -12,6 +12,9 @@ const baseState: DesktopUpdateState = { · 11 unmodified lines
1212 enabled: true,
1313 status: "idle",
1414 currentVersion: "1.0.0",
15+ hostArch: "x64",
16+ appArch: "x64",
17+ runningUnderArm64Translation: false,
1518 availableVersion: null,
1619 downloadedVersion: null,
1720 downloadPercent: null,
apps/marketing/src/pages/index.astro+1−3
@@ -77,12 +77,10 @@ · 76 unmodified lines
7777 const ua = navigator.userAgent;
7878 if (/Win/i.test(ua)) return { os: "win", label: "Download for Windows" };
7979 if (/Mac/i.test(ua)) {
80− const isArm =
81− /arm|aarch/i.test(navigator.platform ?? "") || ua.includes("ARM");
8280 return {
8381 os: "mac",
8482 label: `Download for macOS`,
85− arch: isArm ? "arm64" : "x64",
83+ arch: "arm64",
8684 };
8785 }
8886 if (/Linux/i.test(ua)) return { os: "linux", label: "Download for Linux" };
apps/server/package.json+1−1
@@ -1,6 +1,6 @@
11 {
22 "name": "t3",
3− "version": "0.0.5",
3+ "version": "0.0.9",
44 "repository": {
55 "type": "git",
66 "url": "https://github.com/pingdotgg/t3code",
apps/server/scripts/cli.ts+2−0
@@ -132,6 +132,7 @@ const buildCmd = Command.make( · 131 unmodified lines
132132 cwd: serverDir,
133133 stdout: config.verbose ? "inherit" : "ignore",
134134 stderr: "inherit",
135+ // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).
135136 shell: process.platform === "win32",
136137 })`bun tsdown`,
137138 );
@@ -226,6 +227,7 @@ const publishCmd = Command.make( · 88 unmodified lines
226227 cwd: serverDir,
227228 stdout: config.verbose ? "inherit" : "ignore",
228229 stderr: "inherit",
230+ // Windows needs shell mode to resolve .cmd shims.
229231 shell: process.platform === "win32",
230232 }),
231233 );
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+0−9
@@ -5,7 +5,6 @@ import { · 4 unmodified lines
55 type OrchestrationEvent,
66 type ProviderModelOptions,
77 type ProviderKind,
8− type ProviderServiceTier,
98 type ProviderStartOptions,
109 type OrchestrationSession,
1110 ThreadId,
@@ -206,7 +205,6 @@ const make = Effect.gen(function* () { · 194 unmodified lines
206205 readonly provider?: ProviderKind;
207206 readonly model?: string;
208207 readonly modelOptions?: ProviderModelOptions;
209− readonly serviceTier?: ProviderServiceTier | null;
210208 readonly providerOptions?: ProviderStartOptions;
211209 },
212210 ) {
@@ -242,7 +240,6 @@ const make = Effect.gen(function* () { · 29 unmodified lines
242240 : {}),
243241 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
244242 ...(desiredModel ? { model: desiredModel } : {}),
245− ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}),
246243 ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}),
247244 ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}),
248245 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
@@ -328,7 +325,6 @@ const make = Effect.gen(function* () { · 79 unmodified lines
328325 readonly attachments?: ReadonlyArray<ChatAttachment>;
329326 readonly provider?: ProviderKind;
330327 readonly model?: string;
331− readonly serviceTier?: ProviderServiceTier | null;
332328 readonly modelOptions?: ProviderModelOptions;
333329 readonly providerOptions?: ProviderStartOptions;
334330 readonly interactionMode?: "default" | "plan";
@@ -344,7 +340,6 @@ const make = Effect.gen(function* () { · 9 unmodified lines
344340 yield* ensureSessionForThread(input.threadId, input.createdAt, {
345341 ...(input.provider !== undefined ? { provider: input.provider } : {}),
346342 ...(input.model !== undefined ? { model: input.model } : {}),
347− ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
348343 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
349344 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
350345 });
@@ -366,7 +361,6 @@ const make = Effect.gen(function* () { · 15 unmodified lines
366361 ...(normalizedInput ? { input: normalizedInput } : {}),
367362 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
368363 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
369− ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
370364 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
371365 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
372366 });
@@ -481,9 +475,6 @@ const make = Effect.gen(function* () { · 108 unmodified lines
481475 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
482476 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),
483477 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
484− ...(event.payload.serviceTier !== undefined
485− ? { serviceTier: event.payload.serviceTier }
486− : {}),
487478 ...(event.payload.modelOptions !== undefined
488479 ? { modelOptions: event.payload.modelOptions }
489480 : {}),
apps/server/src/orchestration/decider.ts+0−1
@@ -301,7 +301,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 300 unmodified lines
301301 messageId: command.message.messageId,
302302 ...(command.provider !== undefined ? { provider: command.provider } : {}),
303303 ...(command.model !== undefined ? { model: command.model } : {}),
304− ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}),
305304 ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}),
306305 ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}),
307306 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,
apps/server/src/provider/Layers/CodexAdapter.ts+0−1
@@ -1354,7 +1354,6 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => · 1353 unmodified lines
13541354 threadId: input.threadId,
13551355 ...(input.input !== undefined ? { input: input.input } : {}),
13561356 ...(input.model !== undefined ? { model: input.model } : {}),
1357− ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
13581357 ...(input.modelOptions?.codex?.reasoningEffort !== undefined
13591358 ? { effort: input.modelOptions.codex.reasoningEffort }
13601359 : {}),
apps/web/package.json+1−1
@@ -1,6 +1,6 @@
11 {
22 "name": "@t3tools/web",
3− "version": "0.0.5",
3+ "version": "0.0.9",
44 "private": true,
55 "type": "module",
66 "scripts": {
apps/web/src/appSettings.test.ts+0−21
@@ -4,8 +4,6 @@ import { · 3 unmodified lines
44 getAppModelOptions,
55 getSlashModelOptions,
66 normalizeCustomModelSlugs,
7− resolveAppServiceTier,
8− shouldShowFastTierIcon,
97 resolveAppModelSelection,
108 } from "./appSettings";
119
@@ -84,22 +82,3 @@ describe("getSlashModelOptions", () => { · 72 unmodified lines
8482 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
8583 });
8684 });
87−
88−describe("resolveAppServiceTier", () => {
89− it("maps automatic to no override", () => {
90− expect(resolveAppServiceTier("auto")).toBeNull();
91− });
92−
93− it("preserves explicit service tier overrides", () => {
94− expect(resolveAppServiceTier("fast")).toBe("fast");
95− expect(resolveAppServiceTier("flex")).toBe("flex");
96− });
97−});
98−
99−describe("shouldShowFastTierIcon", () => {
100− it("shows the fast-tier icon only for gpt-5.4 on fast tier", () => {
101− expect(shouldShowFastTierIcon("gpt-5.4", "fast")).toBe(true);
102− expect(shouldShowFastTierIcon("gpt-5.4", "auto")).toBe(false);
103− expect(shouldShowFastTierIcon("gpt-5.3-codex", "fast")).toBe(false);
104− });
105−});
apps/web/src/appSettings.ts+1−38
@@ -1,31 +1,11 @@
11 import { useCallback, useSyncExternalStore } from "react";
22 import { Option, Schema } from "effect";
3−import { type ProviderKind, type ProviderServiceTier } from "@t3tools/contracts";
3+import { type ProviderKind } from "@t3tools/contracts";
44 import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
55
66 const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
77 const MAX_CUSTOM_MODEL_COUNT = 32;
88 export const MAX_CUSTOM_MODEL_LENGTH = 256;
9−export const APP_SERVICE_TIER_OPTIONS = [
10− {
11− value: "auto",
12− label: "Automatic",
13− description: "Use Codex defaults without forcing a service tier.",
14− },
15− {
16− value: "fast",
17− label: "Fast",
18− description: "Request the fast service tier when the model supports it.",
19− },
20− {
21− value: "flex",
22− label: "Flex",
23− description: "Request the flex service tier when the model supports it.",
24− },
25−] as const;
26−export type AppServiceTier = (typeof APP_SERVICE_TIER_OPTIONS)[number]["value"];
27−const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]);
28−const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]);
299 const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
3010 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
3111 };
@@ -41,7 +21,6 @@ const AppSettingsSchema = Schema.Struct({ · 9 unmodified lines
4121 enableAssistantStreaming: Schema.Boolean.pipe(
4222 Schema.withConstructorDefault(() => Option.some(false)),
4323 ),
44− codexServiceTier: AppServiceTierSchema.pipe(Schema.withConstructorDefault(() => Option.some("auto"))),
4524 customCodexModels: Schema.Array(Schema.String).pipe(
4625 Schema.withConstructorDefault(() => Option.some([])),
4726 ),
@@ -53,22 +32,6 @@ export interface AppModelOption { · 5 unmodified lines
5332 isCustom: boolean;
5433 }
5534
56−export function resolveAppServiceTier(serviceTier: AppServiceTier): ProviderServiceTier | null {
57− return serviceTier === "auto" ? null : serviceTier;
58−}
59−
60−export function shouldShowFastTierIcon(
61− model: string | null | undefined,
62− serviceTier: AppServiceTier,
63−): boolean {
64− const normalizedModel = normalizeModelSlug(model);
65− return (
66− resolveAppServiceTier(serviceTier) === "fast" &&
67− normalizedModel !== null &&
68− MODELS_WITH_FAST_SUPPORT.has(normalizedModel)
69− );
70−}
71−
7235 const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
7336
7437 let listeners: Array<() => void> = [];
apps/web/src/components/ChatView.tsx+270−112
@@ -92,6 +92,8 @@ import { · 91 unmodified lines
9292 buildPlanImplementationThreadTitle,
9393 buildPlanImplementationPrompt,
9494 buildProposedPlanMarkdownFilename,
95+ downloadPlanAsTextFile,
96+ normalizePlanMarkdownForExport,
9597 proposedPlanTitle,
9698 resolvePlanFollowUpSubmission,
9799 } from "../proposedPlan";
@@ -122,8 +124,9 @@ import { · 24 unmodified lines
122124 shortcutLabelForCommand,
123125 } from "../keybindings";
124126 import ChatMarkdown from "./ChatMarkdown";
127+import PlanSidebar from "./PlanSidebar";
125128 import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
126−import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
129+import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
127130 import {
128131 BotIcon,
129132 ChevronDownIcon,
@@ -135,13 +138,13 @@ import { · 5 unmodified lines
135138 DiffIcon,
136139 EllipsisIcon,
137140 FolderClosedIcon,
141+ ListTodoIcon,
138142 LockIcon,
139143 LockOpenIcon,
140144 Undo2Icon,
141145 XIcon,
142146 CopyIcon,
143147 CheckIcon,
144− ZapIcon,
145148 } from "lucide-react";
146149 import { Button } from "./ui/button";
147150 import { Input } from "./ui/input";
@@ -201,9 +204,6 @@ import { readNativeApi } from "~/nativeApi"; · 53 unmodified lines
201204 import {
202205 getAppModelOptions,
203206 resolveAppModelSelection,
204− resolveAppServiceTier,
205− shouldShowFastTierIcon,
206− type AppServiceTier,
207207 useAppSettings,
208208 } from "../appSettings";
209209 import {
@@ -291,21 +291,7 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { · 81 unmodified lines
291291 return "text-muted-foreground/40";
292292 }
293293
294−function normalizePlanMarkdownForExport(planMarkdown: string): string {
295− return `${planMarkdown.trimEnd()}\n`;
296−}
297294
298−function downloadTextFile(filename: string, contents: string): void {
299− const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" });
300− const url = URL.createObjectURL(blob);
301− const anchor = document.createElement("a");
302− anchor.href = url;
303− anchor.download = filename;
304− anchor.click();
305− window.setTimeout(() => {
306− URL.revokeObjectURL(url);
307− }, 0);
308−}
309295
310296 interface ExpandedImageItem {
311297 src: string;
@@ -420,7 +406,6 @@ type ComposerCommandItem = · 108 unmodified lines
420406 model: ModelSlug;
421407 label: string;
422408 description: string;
423− showFastBadge: boolean;
424409 };
425410
426411 type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
@@ -531,9 +516,6 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { · 104 unmodified lines
531516 </Badge>
532517 ) : null}
533518 <span className="flex min-w-0 items-center gap-1.5 truncate">
534− {props.item.type === "model" && props.item.showFastBadge ? (
535− <ZapIcon className="size-3.5 shrink-0 text-amber-500" />
536− ) : null}
537519 <span className="truncate">{props.item.label}</span>
538520 </span>
539521 <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span>
@@ -656,6 +638,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 116 unmodified lines
656638 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
657639 useState<Record<string, number>>({});
658640 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
641+ const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
642+ // Tracks whether the user explicitly dismissed the sidebar for the active turn.
643+ const planSidebarDismissedForTurnRef = useRef<string | null>(null);
644+ // When set, the thread-change reset effect will open the sidebar instead of closing it.
645+ // Used by "Implement in new thread" to carry the sidebar-open intent across navigation.
646+ const planSidebarOpenOnNextThreadRef = useRef(false);
659647 const [nowTick, setNowTick] = useState(() => Date.now());
660648 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
661649 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
@@ -757,11 +745,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 95 unmodified lines
757745 composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
758746 const isServerThread = serverThread !== undefined;
759747 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
760− const diffSearch = useMemo(
761− () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
762− [rawSearch],
763− );
764− const diffOpen = diffSearch.diff === "1";
748+ const diffOpen = rawSearch.diff === "1";
765749 const activeThreadId = activeThread?.id ?? null;
766750 const activeLatestTurn = activeThread?.latestTurn ?? null;
767751 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
@@ -793,8 +777,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 25 unmodified lines
793777 activeThread.messages.length > 0 ||
794778 activeThread.session !== null),
795779 );
796− const selectedServiceTierSetting = settings.codexServiceTier;
797− const selectedServiceTier = resolveAppServiceTier(selectedServiceTierSetting);
798780 const lockedProvider: ProviderKind | null = hasThreadStarted
799781 ? (sessionProvider ?? selectedProviderByThreadId ?? null)
800782 : null;
@@ -1262,10 +1244,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 461 unmodified lines
12621244 model: slug,
12631245 label: name,
12641246 description: `${providerLabel} · ${slug}`,
1265− showFastBadge:
1266− provider === "codex" && shouldShowFastTierIcon(slug, selectedServiceTierSetting),
12671247 }));
1268− }, [composerTrigger, searchableModelOptions, selectedServiceTierSetting, workspaceEntries]);
1248+ }, [composerTrigger, searchableModelOptions, workspaceEntries]);
12691249 const composerMenuOpen = Boolean(composerTrigger);
12701250 const activeComposerMenuItem = useMemo(
12711251 () =>
@@ -1621,6 +1601,36 @@ export default function ChatView({ threadId }: ChatViewProps) { · 349 unmodified lines
16211601 },
16221602 [activeProject, persistProjectScripts],
16231603 );
1604+ const deleteProjectScript = useCallback(
1605+ async (scriptId: string) => {
1606+ if (!activeProject) return;
1607+ const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId);
1608+
1609+ const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name;
1610+
1611+ try {
1612+ await persistProjectScripts({
1613+ projectId: activeProject.id,
1614+ projectCwd: activeProject.cwd,
1615+ previousScripts: activeProject.scripts,
1616+ nextScripts,
1617+ keybinding: null,
1618+ keybindingCommand: commandForProjectScript(scriptId),
1619+ });
1620+ toastManager.add({
1621+ type: "success",
1622+ title: `Deleted action "${deletedName ?? "Unknown"}"`,
1623+ });
1624+ } catch (error) {
1625+ toastManager.add({
1626+ type: "error",
1627+ title: "Could not delete action",
1628+ description: error instanceof Error ? error.message : "An unexpected error occurred.",
1629+ });
1630+ }
1631+ },
1632+ [activeProject, persistProjectScripts],
1633+ );
16241634
16251635 const handleRuntimeModeChange = useCallback(
16261636 (mode: RuntimeMode) => {
@@ -1763,6 +1773,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 136 unmodified lines
17631773 "button, summary, [role='button'], [data-scroll-anchor-target]",
17641774 );
17651775 if (!trigger || !scrollContainer.contains(trigger)) return;
1776+ if (trigger.closest("[data-scroll-anchor-ignore]")) return;
17661777
17671778 pendingInteractionAnchorRef.current = {
17681779 element: trigger,
@@ -1911,8 +1922,17 @@ export default function ChatView({ threadId }: ChatViewProps) { · 142 unmodified lines
19111922
19121923 useEffect(() => {
19131924 setExpandedWorkGroups({});
1925+ if (planSidebarOpenOnNextThreadRef.current) {
1926+ planSidebarOpenOnNextThreadRef.current = false;
1927+ setPlanSidebarOpen(true);
1928+ } else {
1929+ setPlanSidebarOpen(false);
1930+ }
1931+ planSidebarDismissedForTurnRef.current = null;
19141932 }, [activeThread?.id]);
19151933
1934+
1935+
19161936 useEffect(() => {
19171937 if (!composerMenuOpen) {
19181938 setComposerHighlightedItemId(null);
@@ -2633,7 +2653,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 714 unmodified lines
26332653 attachments: turnAttachments,
26342654 },
26352655 model: selectedModel || undefined,
2636− serviceTier: selectedServiceTier,
26372656 ...(selectedModelOptionsForDispatch
26382657 ? { modelOptions: selectedModelOptionsForDispatch }
26392658 : {}),
@@ -2925,6 +2944,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 285 unmodified lines
29252944 interactionMode: nextInteractionMode,
29262945 createdAt: messageCreatedAt,
29272946 });
2947+ // Optimistically open the plan sidebar when implementing (not refining).
2948+ // "default" mode here means the agent is executing the plan, which produces
2949+ // step-tracking activities that the sidebar will display.
2950+ if (nextInteractionMode === "default") {
2951+ planSidebarDismissedForTurnRef.current = null;
2952+ setPlanSidebarOpen(true);
2953+ }
29282954 sendInFlightRef.current = false;
29292955 } catch (err) {
29302956 setOptimisticUserMessages((existing) =>
@@ -3033,6 +3059,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 102 unmodified lines
30333059 .then(() => api.orchestration.getSnapshot())
30343060 .then((snapshot) => {
30353061 syncServerReadModel(snapshot);
3062+ // Signal that the plan sidebar should open on the new thread.
3063+ planSidebarOpenOnNextThreadRef.current = true;
30363064 return navigate({
30373065 to: "/$threadId",
30383066 params: { threadId: nextThreadId },
@@ -3428,14 +3456,21 @@ export default function ChatView({ threadId }: ChatViewProps) { · 389 unmodified lines
34283456 }}
34293457 onAddProjectScript={saveProjectScript}
34303458 onUpdateProjectScript={updateProjectScript}
3459+ onDeleteProjectScript={deleteProjectScript}
34313460 onToggleDiff={onToggleDiff}
34323461 />
34333462 </header>
34343463
34353464 {/* Error banner */}
34363465 <ProviderHealthBanner status={activeProviderStatus} />
3437− <ThreadErrorBanner error={activeThread.error} />
3438− <PlanModePanel activePlan={activePlan} />
3466+ <ThreadErrorBanner
3467+ error={activeThread.error}
3468+ onDismiss={() => setThreadError(activeThread.id, null)}
3469+ />
3470+ {/* Main content area with optional plan sidebar */}
3471+ <div className="flex min-h-0 flex-1">
3472+ {/* Chat column */}
3473+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">
34393474
34403475 {/* Messages */}
34413476 <div
@@ -3509,6 +3544,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 67 unmodified lines
35093544 answers={activePendingDraftAnswers}
35103545 questionIndex={activePendingQuestionIndex}
35113546 onSelectOption={onSelectActivePendingUserInputOption}
3547+ onAdvance={onAdvanceActivePendingUserInput}
35123548 />
35133549 </div>
35143550 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
@@ -3651,7 +3687,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 136 unmodified lines
36513687 model={selectedModelForPickerWithCustomFallback}
36523688 lockedProvider={lockedProvider}
36533689 modelOptionsByProvider={modelOptionsByProvider}
3654− serviceTierSetting={selectedServiceTierSetting}
36553690 onProviderModelChange={onProviderModelSelect}
36563691 />
36573692
@@ -3715,6 +3750,43 @@ export default function ChatView({ threadId }: ChatViewProps) { · 57 unmodified lines
37153750 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
37163751 </span>
37173752 </Button>
3753+
3754+ {/* Plan sidebar toggle */}
3755+ {(activePlan || activeProposedPlan || planSidebarOpen) ? (
3756+ <>
3757+ <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3758+ <Button
3759+ variant="ghost"
3760+ className={cn(
3761+ "shrink-0 whitespace-nowrap px-2 sm:px-3",
3762+ planSidebarOpen
3763+ ? "text-blue-400 hover:text-blue-300"
3764+ : "text-muted-foreground/70 hover:text-foreground/80",
3765+ )}
3766+ size="sm"
3767+ type="button"
3768+ onClick={() => {
3769+ setPlanSidebarOpen((open) => {
3770+ if (open) {
3771+ // Closing: track dismissal for current turn
3772+ const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null;
3773+ if (turnKey) {
3774+ planSidebarDismissedForTurnRef.current = turnKey;
3775+ }
3776+ } else {
3777+ // Re-opening: clear dismissal tracking
3778+ planSidebarDismissedForTurnRef.current = null;
3779+ }
3780+ return !open;
3781+ });
3782+ }}
3783+ title={planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
3784+ >
3785+ <ListTodoIcon />
3786+ <span className="sr-only sm:not-sr-only">Plan</span>
3787+ </Button>
3788+ </>
3789+ ) : null}
37183790 </div>
37193791
37203792 {/* Right side: send / stop button */}
@@ -3881,6 +3953,27 @@ export default function ChatView({ threadId }: ChatViewProps) { · 160 unmodified lines
38813953 </form>
38823954 </div>
38833955
3956+ </div>{/* end chat column */}
3957+
3958+ {/* Plan sidebar */}
3959+ {planSidebarOpen ? (
3960+ <PlanSidebar
3961+ activePlan={activePlan}
3962+ activeProposedPlan={activeProposedPlan}
3963+ markdownCwd={gitCwd ?? undefined}
3964+ workspaceRoot={activeProject?.cwd ?? undefined}
3965+ onClose={() => {
3966+ setPlanSidebarOpen(false);
3967+ // Track that the user explicitly dismissed for this turn so auto-open won't fight them.
3968+ const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null;
3969+ if (turnKey) {
3970+ planSidebarDismissedForTurnRef.current = turnKey;
3971+ }
3972+ }}
3973+ />
3974+ ) : null}
3975+ </div>{/* end horizontal flex container */}
3976+
38843977 {isGitRepo && (
38853978 <BranchToolbar
38863979 threadId={activeThread.id}
@@ -4005,6 +4098,7 @@ interface ChatHeaderProps { · 118 unmodified lines
40054098 onRunProjectScript: (script: ProjectScript) => void;
40064099 onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;
40074100 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;
4101+ onDeleteProjectScript: (scriptId: string) => Promise<void>;
40084102 onToggleDiff: () => void;
40094103 }
40104104
@@ -4024,6 +4118,7 @@ const ChatHeader = memo(function ChatHeader({ · 13 unmodified lines
40244118 onRunProjectScript,
40254119 onAddProjectScript,
40264120 onUpdateProjectScript,
4121+ onDeleteProjectScript,
40274122 onToggleDiff,
40284123 }: ChatHeaderProps) {
40294124 return (
@@ -4056,6 +4151,7 @@ const ChatHeader = memo(function ChatHeader({ · 26 unmodified lines
40564151 onRunScript={onRunProjectScript}
40574152 onAddScript={onAddProjectScript}
40584153 onUpdateScript={onUpdateProjectScript}
4154+ onDeleteScript={onDeleteProjectScript}
40594155 />
40604156 )}
40614157 {activeProjectName && (
@@ -4095,7 +4191,13 @@ const ChatHeader = memo(function ChatHeader({ · 33 unmodified lines
40954191 );
40964192 });
40974193
4098−const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: string | null }) {
4194+const ThreadErrorBanner = memo(function ThreadErrorBanner({
4195+ error,
4196+ onDismiss,
4197+}: {
4198+ error: string | null;
4199+ onDismiss?: () => void;
4200+}) {
40994201 if (!error) return null;
41004202 return (
41014203 <div className="pt-3 mx-auto max-w-3xl">
@@ -4104,6 +4206,18 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st · 2 unmodified lines
41044206 <AlertDescription className="line-clamp-3" title={error}>
41054207 {error}
41064208 </AlertDescription>
4209+ {onDismiss && (
4210+ <AlertAction>
4211+ <button
4212+ type="button"
4213+ aria-label="Dismiss error"
4214+ className="inline-flex size-6 items-center justify-center rounded-md text-destructive/60 transition-colors hover:text-destructive"
4215+ onClick={onDismiss}
4216+ >
4217+ <XIcon className="size-3.5" />
4218+ </button>
4219+ </AlertAction>
4220+ )}
41074221 </Alert>
41084222 </div>
41094223 );
@@ -4219,61 +4333,13 @@ const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActi · 109 unmodified lines
42194333 );
42204334 });
42214335
4222−interface PlanModePanelProps {
4223− activePlan: ReturnType<typeof deriveActivePlanState>;
4224−}
4225−
4226−const PlanModePanel = memo(function PlanModePanel({ activePlan }: PlanModePanelProps) {
4227− if (!activePlan) return null;
4228−
4229− return (
4230− <div className="pt-3 mx-auto max-w-3xl">
4231− <div className="rounded-xl border border-border/70 bg-muted/30 p-4">
4232− <div className="flex items-center gap-2">
4233− <Badge variant="secondary">Plan</Badge>
4234− <span className="text-xs text-muted-foreground">
4235− Updated {formatTimestamp(activePlan.createdAt)}
4236− </span>
4237− </div>
4238− {activePlan.explanation ? (
4239− <p className="mt-2 text-sm text-muted-foreground">{activePlan.explanation}</p>
4240− ) : null}
4241− <div className="mt-3 space-y-2">
4242− {activePlan.steps.map((step) => (
4243− <div
4244− key={`${step.status}:${step.step}`}
4245− className="flex items-start gap-3 rounded-lg border border-border/60 bg-background/80 px-3 py-2"
4246− >
4247− <Badge
4248− variant={
4249− step.status === "completed"
4250− ? "default"
4251− : step.status === "inProgress"
4252− ? "secondary"
4253− : "outline"
4254− }
4255− >
4256− {step.status === "inProgress"
4257− ? "In progress"
4258− : step.status === "completed"
4259− ? "Done"
4260− : "Pending"}
4261− </Badge>
4262− <div className="min-w-0 flex-1 text-sm">{step.step}</div>
4263− </div>
4264− ))}
4265− </div>
4266− </div>
4267− </div>
4268− );
4269−});
4270−
42714336 interface PendingUserInputPanelProps {
42724337 pendingUserInputs: PendingUserInput[];
42734338 respondingRequestIds: ApprovalRequestId[];
42744339 answers: Record<string, PendingUserInputDraftAnswer>;
42754340 questionIndex: number;
42764341 onSelectOption: (questionId: string, optionLabel: string) => void;
4342+ onAdvance: () => void;
42774343 }
42784344
42794345 const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
@@ -4282,6 +4348,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane · 2 unmodified lines
42824348 answers,
42834349 questionIndex,
42844350 onSelectOption,
4351+ onAdvance,
42854352 }: PendingUserInputPanelProps) {
42864353 if (pendingUserInputs.length === 0) return null;
42874354 const activePrompt = pendingUserInputs[0];
@@ -4295,6 +4362,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane · 7 unmodified lines
42954362 answers={answers}
42964363 questionIndex={questionIndex}
42974364 onSelectOption={onSelectOption}
4365+ onAdvance={onAdvance}
42984366 />
42994367 );
43004368 });
@@ -4305,42 +4373,135 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 4 unmodified lines
43054373 answers,
43064374 questionIndex,
43074375 onSelectOption,
4376+ onAdvance,
43084377 }: {
43094378 prompt: PendingUserInput;
43104379 isResponding: boolean;
43114380 answers: Record<string, PendingUserInputDraftAnswer>;
43124381 questionIndex: number;
43134382 onSelectOption: (questionId: string, optionLabel: string) => void;
4383+ onAdvance: () => void;
43144384 }) {
43154385 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
43164386 const activeQuestion = progress.activeQuestion;
4387+ const autoAdvanceTimerRef = useRef<number | null>(null);
4388+
4389+ // Clear auto-advance timer on unmount
4390+ useEffect(() => {
4391+ return () => {
4392+ if (autoAdvanceTimerRef.current !== null) {
4393+ window.clearTimeout(autoAdvanceTimerRef.current);
4394+ }
4395+ };
4396+ }, []);
4397+
4398+ const selectOptionAndAutoAdvance = useCallback(
4399+ (questionId: string, optionLabel: string) => {
4400+ onSelectOption(questionId, optionLabel);
4401+ if (autoAdvanceTimerRef.current !== null) {
4402+ window.clearTimeout(autoAdvanceTimerRef.current);
4403+ }
4404+ autoAdvanceTimerRef.current = window.setTimeout(() => {
4405+ autoAdvanceTimerRef.current = null;
4406+ onAdvance();
4407+ }, 200);
4408+ },
4409+ [onSelectOption, onAdvance],
4410+ );
4411+
4412+ // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance.
4413+ // Works even when the Lexical composer (contenteditable) has focus — the composer
4414+ // doubles as a custom-answer field during user input, and when it's empty the digit
4415+ // keys should pick options instead of typing into the editor.
4416+ useEffect(() => {
4417+ if (!activeQuestion || isResponding) return;
4418+ const handler = (event: globalThis.KeyboardEvent) => {
4419+ if (event.metaKey || event.ctrlKey || event.altKey) return;
4420+ const target = event.target;
4421+ if (
4422+ target instanceof HTMLInputElement ||
4423+ target instanceof HTMLTextAreaElement
4424+ ) {
4425+ return;
4426+ }
4427+ // If the user has started typing a custom answer in the contenteditable
4428+ // composer, let digit keys pass through so they can type numbers.
4429+ if (target instanceof HTMLElement && target.isContentEditable) {
4430+ const hasCustomText = progress.customAnswer.length > 0;
4431+ if (hasCustomText) return;
4432+ }
4433+ const digit = Number.parseInt(event.key, 10);
4434+ if (Number.isNaN(digit) || digit < 1 || digit > 9) return;
4435+ const optionIndex = digit - 1;
4436+ if (optionIndex >= activeQuestion.options.length) return;
4437+ const option = activeQuestion.options[optionIndex];
4438+ if (!option) return;
4439+ event.preventDefault();
4440+ selectOptionAndAutoAdvance(activeQuestion.id, option.label);
4441+ };
4442+ document.addEventListener("keydown", handler);
4443+ return () => document.removeEventListener("keydown", handler);
4444+ }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]);
43174445
43184446 if (!activeQuestion) {
43194447 return null;
43204448 }
43214449
43224450 return (
4323− <div className="px-4 py-4 sm:px-5">
4324− <div className="flex gap-2">
4325− <span className="uppercase text-sm tracking-[0.2em]">
4326− {questionIndex + 1}/{prompt.questions.length} {activeQuestion.header}
4327− </span>
4328− <div className="text-sm font-medium">{activeQuestion.question}</div>
4451+ <div className="px-4 py-3 sm:px-5">
4452+ <div className="flex items-center gap-3">
4453+ <div className="flex items-center gap-2">
4454+ {prompt.questions.length > 1 ? (
4455+ <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-[10px] font-medium tabular-nums text-muted-foreground/60">
4456+ {questionIndex + 1}/{prompt.questions.length}
4457+ </span>
4458+ ) : null}
4459+ <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/50 uppercase">
4460+ {activeQuestion.header}
4461+ </span>
4462+ </div>
43294463 </div>
4330− <div className="mt-3 flex flex-wrap gap-2">
4331− {activeQuestion.options.map((option) => {
4464+ <p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p>
4465+ <div className="mt-3 space-y-1">
4466+ {activeQuestion.options.map((option, index) => {
43324467 const isSelected = progress.selectedOptionLabel === option.label;
4468+ const shortcutKey = index < 9 ? index + 1 : null;
43334469 return (
4334− <Button
4470+ <button
43354471 key={`${activeQuestion.id}:${option.label}`}
4336− size="sm"
4337− variant={isSelected ? "default" : "outline"}
4472+ type="button"
43384473 disabled={isResponding}
4339− onClick={() => onSelectOption(activeQuestion.id, option.label)}
4340− title={option.description}
4474+ onClick={() => selectOptionAndAutoAdvance(activeQuestion.id, option.label)}
4475+ className={cn(
4476+ "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150",
4477+ isSelected
4478+ ? "border-blue-500/40 bg-blue-500/8 text-foreground"
4479+ : "border-transparent bg-muted/20 text-foreground/80 hover:bg-muted/40 hover:border-border/40",
4480+ isResponding && "opacity-50 cursor-not-allowed",
4481+ )}
43414482 >
4342− {option.label}
4343− </Button>
4483+ {shortcutKey !== null ? (
4484+ <kbd
4485+ className={cn(
4486+ "flex size-5 shrink-0 items-center justify-center rounded text-[11px] font-medium tabular-nums transition-colors duration-150",
4487+ isSelected
4488+ ? "bg-blue-500/20 text-blue-400"
4489+ : "bg-muted/40 text-muted-foreground/50 group-hover:bg-muted/60 group-hover:text-muted-foreground/70",
4490+ )}
4491+ >
4492+ {shortcutKey}
4493+ </kbd>
4494+ ) : null}
4495+ <div className="min-w-0 flex-1">
4496+ <span className="text-sm font-medium">{option.label}</span>
4497+ {option.description && option.description !== option.label ? (
4498+ <span className="ml-2 text-xs text-muted-foreground/50">{option.description}</span>
4499+ ) : null}
4500+ </div>
4501+ {isSelected ? (
4502+ <CheckIcon className="size-3.5 shrink-0 text-blue-400" />
4503+ ) : null}
4504+ </button>
43444505 );
43454506 })}
43464507 </div>
@@ -4554,7 +4715,7 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ · 207 unmodified lines
45544715 const saveContents = normalizePlanMarkdownForExport(planMarkdown);
45554716
45564717 const handleDownload = () => {
4557− downloadTextFile(downloadFilename, saveContents);
4718+ downloadPlanAsTextFile(downloadFilename, saveContents);
45584719 };
45594720
45604721 const openSaveDialog = () => {
@@ -4646,7 +4807,12 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ · 85 unmodified lines
46464807 </div>
46474808 {canCollapse ? (
46484809 <div className="mt-4 flex justify-center">
4649− <Button size="sm" variant="outline" onClick={() => setExpanded((value) => !value)}>
4810+ <Button
4811+ size="sm"
4812+ variant="outline"
4813+ data-scroll-anchor-ignore
4814+ onClick={() => setExpanded((value) => !value)}
4815+ >
46504816 {expanded ? "Collapse plan" : "Expand plan"}
46514817 </Button>
46524818 </div>
@@ -5368,7 +5534,6 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 715 unmodified lines
53685534 model: ModelSlug;
53695535 lockedProvider: ProviderKind | null;
53705536 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
5371− serviceTierSetting: AppServiceTier;
53725537 disabled?: boolean;
53735538 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
53745539 }) {
@@ -5401,9 +5566,6 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 26 unmodified lines
54015566 >
54025567 <span className="flex min-w-0 items-center gap-2">
54035568 <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" />
5404− {props.provider === "codex" && shouldShowFastTierIcon(props.model, props.serviceTierSetting) ? (
5405− <ZapIcon className="size-3.5 shrink-0 text-amber-500" />
5406− ) : null}
54075569 <span className="truncate">{selectedModelLabel}</span>
54085570 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
54095571 </span>
@@ -5446,10 +5608,6 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 36 unmodified lines
54465608 value={modelOption.slug}
54475609 onClick={() => setIsMenuOpen(false)}
54485610 >
5449− {option.value === "codex" &&
5450− shouldShowFastTierIcon(modelOption.slug, props.serviceTierSetting) ? (
5451− <ZapIcon className="size-3.5 shrink-0 text-amber-500" />
5452− ) : null}
54535611 {modelOption.name}
54545612 </MenuRadioItem>
54555613 ))}
apps/web/src/components/PlanSidebar.tsx+267−0
@@ -0,0 +1,267 @@
1+import { memo, useState, useCallback } from "react";
2+import { Badge } from "./ui/badge";
3+import { Button } from "./ui/button";
4+import { ScrollArea } from "./ui/scroll-area";
5+import ChatMarkdown from "./ChatMarkdown";
6+import {
7+ CheckIcon,
8+ ChevronDownIcon,
9+ ChevronRightIcon,
10+ EllipsisIcon,
11+ LoaderIcon,
12+ PanelRightCloseIcon,
13+} from "lucide-react";
14+import { cn } from "~/lib/utils";
15+import { formatTimestamp } from "../session-logic";
16+import type { ActivePlanState } from "../session-logic";
17+import type { LatestProposedPlanState } from "../session-logic";
18+import {
19+ proposedPlanTitle,
20+ buildProposedPlanMarkdownFilename,
21+ normalizePlanMarkdownForExport,
22+ downloadPlanAsTextFile,
23+} from "../proposedPlan";
24+import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
25+import { readNativeApi } from "~/nativeApi";
26+import { toastManager } from "./ui/toast";
27+
28+function stepStatusIcon(status: string): React.ReactNode {
29+ if (status === "completed") {
30+ return (
31+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-500">
32+ <CheckIcon className="size-3" />
33+ </span>
34+ );
35+ }
36+ if (status === "inProgress") {
37+ return (
38+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-blue-500/15 text-blue-400">
39+ <LoaderIcon className="size-3 animate-spin" />
40+ </span>
41+ );
42+ }
43+ return (
44+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30">
45+ <span className="size-1.5 rounded-full bg-muted-foreground/30" />
46+ </span>
47+ );
48+}
49+
50+interface PlanSidebarProps {
51+ activePlan: ActivePlanState | null;
52+ activeProposedPlan: LatestProposedPlanState | null;
53+ markdownCwd: string | undefined;
54+ workspaceRoot: string | undefined;
55+ onClose: () => void;
56+}
57+
58+const PlanSidebar = memo(function PlanSidebar({
59+ activePlan,
60+ activeProposedPlan,
61+ markdownCwd,
62+ workspaceRoot,
63+ onClose,
64+}: PlanSidebarProps) {
65+ const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
66+ const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
67+ const [copied, setCopied] = useState(false);
68+
69+ const planMarkdown = activeProposedPlan?.planMarkdown ?? null;
70+ const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null;
71+
72+ const handleCopyPlan = useCallback(() => {
73+ if (!planMarkdown) return;
74+ void navigator.clipboard.writeText(planMarkdown);
75+ setCopied(true);
76+ setTimeout(() => setCopied(false), 2000);
77+ }, [planMarkdown]);
78+
79+ const handleDownload = useCallback(() => {
80+ if (!planMarkdown) return;
81+ const filename = buildProposedPlanMarkdownFilename(planMarkdown);
82+ downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown));
83+ }, [planMarkdown]);
84+
85+ const handleSaveToWorkspace = useCallback(() => {
86+ const api = readNativeApi();
87+ if (!api || !workspaceRoot || !planMarkdown) return;
88+ const filename = buildProposedPlanMarkdownFilename(planMarkdown);
89+ setIsSavingToWorkspace(true);
90+ void api.projects
91+ .writeFile({
92+ cwd: workspaceRoot,
93+ relativePath: filename,
94+ contents: normalizePlanMarkdownForExport(planMarkdown),
95+ })
96+ .then((result) => {
97+ toastManager.add({
98+ type: "success",
99+ title: "Plan saved",
100+ description: result.relativePath,
101+ });
102+ })
103+ .catch((error) => {
104+ toastManager.add({
105+ type: "error",
106+ title: "Could not save plan",
107+ description:
108+ error instanceof Error ? error.message : "An error occurred.",
109+ });
110+ })
111+ .then(
112+ () => setIsSavingToWorkspace(false),
113+ () => setIsSavingToWorkspace(false),
114+ );
115+ }, [planMarkdown, workspaceRoot]);
116+
117+ return (
118+ <div className="flex h-full w-[340px] shrink-0 flex-col border-l border-border/70 bg-card/50">
119+ {/* Header */}
120+ <div className="flex h-12 shrink-0 items-center justify-between border-b border-border/60 px-3">
121+ <div className="flex items-center gap-2">
122+ <Badge
123+ variant="secondary"
124+ className="rounded-md bg-blue-500/10 px-1.5 py-0 text-[10px] font-semibold tracking-wide text-blue-400 uppercase"
125+ >
126+ Plan
127+ </Badge>
128+ {activePlan ? (
129+ <span className="text-[11px] text-muted-foreground/60">
130+ {formatTimestamp(activePlan.createdAt)}
131+ </span>
132+ ) : null}
133+ </div>
134+ <div className="flex items-center gap-1">
135+ {planMarkdown ? (
136+ <Menu>
137+ <MenuTrigger
138+ render={
139+ <Button
140+ size="icon-xs"
141+ variant="ghost"
142+ className="text-muted-foreground/50 hover:text-foreground/70"
143+ aria-label="Plan actions"
144+ />
145+ }
146+ >
147+ <EllipsisIcon className="size-3.5" />
148+ </MenuTrigger>
149+ <MenuPopup align="end">
150+ <MenuItem onClick={handleCopyPlan}>
151+ {copied ? "Copied!" : "Copy to clipboard"}
152+ </MenuItem>
153+ <MenuItem onClick={handleDownload}>Download as markdown</MenuItem>
154+ <MenuItem
155+ onClick={handleSaveToWorkspace}
156+ disabled={!workspaceRoot || isSavingToWorkspace}
157+ >
158+ Save to workspace
159+ </MenuItem>
160+ </MenuPopup>
161+ </Menu>
162+ ) : null}
163+ <Button
164+ size="icon-xs"
165+ variant="ghost"
166+ onClick={onClose}
167+ aria-label="Close plan sidebar"
168+ className="text-muted-foreground/50 hover:text-foreground/70"
169+ >
170+ <PanelRightCloseIcon className="size-3.5" />
171+ </Button>
172+ </div>
173+ </div>
174+
175+ {/* Content */}
176+ <ScrollArea className="min-h-0 flex-1">
177+ <div className="p-3 space-y-4">
178+ {/* Explanation */}
179+ {activePlan?.explanation ? (
180+ <p className="text-[13px] leading-relaxed text-muted-foreground/80">
181+ {activePlan.explanation}
182+ </p>
183+ ) : null}
184+
185+ {/* Plan Steps */}
186+ {activePlan && activePlan.steps.length > 0 ? (
187+ <div className="space-y-1">
188+ <p className="mb-2 text-[10px] font-semibold tracking-widest text-muted-foreground/40 uppercase">
189+ Steps
190+ </p>
191+ {activePlan.steps.map((step) => (
192+ <div
193+ key={`${step.status}:${step.step}`}
194+ className={cn(
195+ "flex items-start gap-2.5 rounded-lg px-2.5 py-2 transition-colors duration-200",
196+ step.status === "inProgress" && "bg-blue-500/5",
197+ step.status === "completed" && "bg-emerald-500/5",
198+ )}
199+ >
200+ <div className="mt-0.5">
201+ {stepStatusIcon(step.status)}
202+ </div>
203+ <p
204+ className={cn(
205+ "text-[13px] leading-snug",
206+ step.status === "completed"
207+ ? "text-muted-foreground/50 line-through decoration-muted-foreground/20"
208+ : step.status === "inProgress"
209+ ? "text-foreground/90"
210+ : "text-muted-foreground/70",
211+ )}
212+ >
213+ {step.step}
214+ </p>
215+ </div>
216+ ))}
217+ </div>
218+ ) : null}
219+
220+ {/* Proposed Plan Markdown */}
221+ {planMarkdown ? (
222+ <div className="space-y-2">
223+ <button
224+ type="button"
225+ className="group flex w-full items-center gap-1.5 text-left"
226+ onClick={() => setProposedPlanExpanded((v) => !v)}
227+ >
228+ {proposedPlanExpanded ? (
229+ <ChevronDownIcon className="size-3 shrink-0 text-muted-foreground/40 transition-transform" />
230+ ) : (
231+ <ChevronRightIcon className="size-3 shrink-0 text-muted-foreground/40 transition-transform" />
232+ )}
233+ <span className="text-[10px] font-semibold tracking-widest text-muted-foreground/40 uppercase group-hover:text-muted-foreground/60">
234+ {planTitle ?? "Full Plan"}
235+ </span>
236+ </button>
237+ {proposedPlanExpanded ? (
238+ <div className="rounded-lg border border-border/50 bg-background/50 p-3">
239+ <ChatMarkdown
240+ text={planMarkdown}
241+ cwd={markdownCwd}
242+ isStreaming={false}
243+ />
244+ </div>
245+ ) : null}
246+ </div>
247+ ) : null}
248+
249+ {/* Empty state */}
250+ {!activePlan && !planMarkdown ? (
251+ <div className="flex flex-col items-center justify-center py-12 text-center">
252+ <p className="text-[13px] text-muted-foreground/40">
253+ No active plan yet.
254+ </p>
255+ <p className="mt-1 text-[11px] text-muted-foreground/30">
256+ Plans will appear here when generated.
257+ </p>
258+ </div>
259+ ) : null}
260+ </div>
261+ </ScrollArea>
262+ </div>
263+ );
264+});
265+
266+export default PlanSidebar;
267+export type { PlanSidebarProps };
apps/web/src/components/ProjectScriptsControl.tsx+45−1
@@ -14,7 +14,7 @@ import { · 13 unmodified lines
1414 SettingsIcon,
1515 WrenchIcon,
1616 } from "lucide-react";
17−import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";
17+import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react";
1818
1919 import {
2020 keybindingValueForCommand,
@@ -27,6 +27,15 @@ import { · 6 unmodified lines
2727 } from "~/projectScripts";
2828 import { shortcutLabelForCommand } from "~/keybindings";
2929 import { isMacPlatform } from "~/lib/utils";
30+import {
31+ AlertDialog,
32+ AlertDialogClose,
33+ AlertDialogDescription,
34+ AlertDialogFooter,
35+ AlertDialogHeader,
36+ AlertDialogPopup,
37+ AlertDialogTitle,
38+} from "./ui/alert-dialog";
3039 import { Button } from "./ui/button";
3140 import {
3241 Dialog,
@@ -84,6 +93,7 @@ interface ProjectScriptsControlProps { · 51 unmodified lines
8493 onRunScript: (script: ProjectScript) => void;
8594 onAddScript: (input: NewProjectScriptInput) => Promise<void> | void;
8695 onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void;
96+ onDeleteScript: (scriptId: string) => Promise<void> | void;
8797 }
8898
8999 function normalizeShortcutKeyToken(key: string): string | null {
@@ -144,6 +154,7 @@ export default function ProjectScriptsControl({ · 54 unmodified lines
144154 onRunScript,
145155 onAddScript,
146156 onUpdateScript,
157+ onDeleteScript,
147158 }: ProjectScriptsControlProps) {
148159 const addScriptFormId = React.useId();
149160 const [editingScriptId, setEditingScriptId] = useState<string | null>(null);
@@ -155,6 +166,7 @@ export default function ProjectScriptsControl({ · 5 unmodified lines
155166 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
156167 const [keybinding, setKeybinding] = useState("");
157168 const [validationError, setValidationError] = useState<string | null>(null);
169+ const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
158170
159171 const primaryScript = useMemo(() => {
160172 if (preferredScriptId) {
@@ -247,6 +259,13 @@ export default function ProjectScriptsControl({ · 86 unmodified lines
247259 setDialogOpen(true);
248260 };
249261
262+ const confirmDeleteScript = useCallback(() => {
263+ if (!editingScriptId) return;
264+ setDeleteConfirmOpen(false);
265+ setDialogOpen(false);
266+ void onDeleteScript(editingScriptId);
267+ }, [editingScriptId, onDeleteScript]);
268+
250269 return (
251270 <>
252271 {primaryScript ? (
@@ -440,6 +459,16 @@ export default function ProjectScriptsControl({ · 187 unmodified lines
440459 </form>
441460 </DialogPanel>
442461 <DialogFooter>
462+ {isEditing && (
463+ <Button
464+ type="button"
465+ variant="destructive-outline"
466+ className="mr-auto"
467+ onClick={() => setDeleteConfirmOpen(true)}
468+ >
469+ Delete
470+ </Button>
471+ )}
443472 <Button
444473 type="button"
445474 variant="outline"
@@ -455,6 +484,21 @@ export default function ProjectScriptsControl({ · 9 unmodified lines
455484 </DialogFooter>
456485 </DialogPopup>
457486 </Dialog>
487+
488+ <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
489+ <AlertDialogPopup>
490+ <AlertDialogHeader>
491+ <AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle>
492+ <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
493+ </AlertDialogHeader>
494+ <AlertDialogFooter>
495+ <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
496+ <Button variant="destructive" onClick={confirmDeleteScript}>
497+ Delete action
498+ </Button>
499+ </AlertDialogFooter>
500+ </AlertDialogPopup>
501+ </AlertDialog>
458502 </>
459503 );
460504 }
apps/web/src/components/Sidebar.logic.test.ts+124−0
@@ -0,0 +1,124 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { hasUnseenCompletion, resolveThreadStatusPill } from "./Sidebar.logic";
4+
5+function makeLatestTurn(overrides?: {
6+ completedAt?: string | null;
7+ startedAt?: string | null;
8+}): Parameters<typeof hasUnseenCompletion>[0]["latestTurn"] {
9+ return {
10+ turnId: "turn-1" as never,
11+ state: "completed",
12+ assistantMessageId: null,
13+ requestedAt: "2026-03-09T10:00:00.000Z",
14+ startedAt: overrides?.startedAt ?? "2026-03-09T10:00:00.000Z",
15+ completedAt: overrides?.completedAt ?? "2026-03-09T10:05:00.000Z",
16+ };
17+}
18+
19+describe("hasUnseenCompletion", () => {
20+ it("returns true when a thread completed after its last visit", () => {
21+ expect(
22+ hasUnseenCompletion({
23+ interactionMode: "default",
24+ latestTurn: makeLatestTurn(),
25+ lastVisitedAt: "2026-03-09T10:04:00.000Z",
26+ proposedPlans: [],
27+ session: null,
28+ }),
29+ ).toBe(true);
30+ });
31+});
32+
33+describe("resolveThreadStatusPill", () => {
34+ const baseThread = {
35+ interactionMode: "plan" as const,
36+ latestTurn: null,
37+ lastVisitedAt: undefined,
38+ proposedPlans: [],
39+ session: {
40+ provider: "codex" as const,
41+ status: "running" as const,
42+ createdAt: "2026-03-09T10:00:00.000Z",
43+ updatedAt: "2026-03-09T10:00:00.000Z",
44+ orchestrationStatus: "running" as const,
45+ },
46+ };
47+
48+ it("shows pending approval before all other statuses", () => {
49+ expect(
50+ resolveThreadStatusPill({
51+ thread: baseThread,
52+ hasPendingApprovals: true,
53+ hasPendingUserInput: true,
54+ }),
55+ ).toMatchObject({ label: "Pending Approval", pulse: false });
56+ });
57+
58+ it("shows awaiting input when plan mode is blocked on user answers", () => {
59+ expect(
60+ resolveThreadStatusPill({
61+ thread: baseThread,
62+ hasPendingApprovals: false,
63+ hasPendingUserInput: true,
64+ }),
65+ ).toMatchObject({ label: "Awaiting Input", pulse: false });
66+ });
67+
68+ it("falls back to working when the thread is actively running without blockers", () => {
69+ expect(
70+ resolveThreadStatusPill({
71+ thread: baseThread,
72+ hasPendingApprovals: false,
73+ hasPendingUserInput: false,
74+ }),
75+ ).toMatchObject({ label: "Working", pulse: true });
76+ });
77+
78+ it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => {
79+ expect(
80+ resolveThreadStatusPill({
81+ thread: {
82+ ...baseThread,
83+ latestTurn: makeLatestTurn(),
84+ proposedPlans: [
85+ {
86+ id: "plan-1" as never,
87+ turnId: "turn-1" as never,
88+ createdAt: "2026-03-09T10:00:00.000Z",
89+ updatedAt: "2026-03-09T10:05:00.000Z",
90+ planMarkdown: "# Plan",
91+ },
92+ ],
93+ session: {
94+ ...baseThread.session,
95+ status: "ready",
96+ orchestrationStatus: "ready",
97+ },
98+ },
99+ hasPendingApprovals: false,
100+ hasPendingUserInput: false,
101+ }),
102+ ).toMatchObject({ label: "Plan Ready", pulse: false });
103+ });
104+
105+ it("shows completed when there is an unseen completion and no active blocker", () => {
106+ expect(
107+ resolveThreadStatusPill({
108+ thread: {
109+ ...baseThread,
110+ interactionMode: "default",
111+ latestTurn: makeLatestTurn(),
112+ lastVisitedAt: "2026-03-09T10:04:00.000Z",
113+ session: {
114+ ...baseThread.session,
115+ status: "ready",
116+ orchestrationStatus: "ready",
117+ },
118+ },
119+ hasPendingApprovals: false,
120+ hasPendingUserInput: false,
121+ }),
122+ ).toMatchObject({ label: "Completed", pulse: false });
123+ });
124+});
apps/web/src/components/Sidebar.logic.ts+100−0
@@ -0,0 +1,100 @@
1+import type { Thread } from "../types";
2+import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
3+
4+export interface ThreadStatusPill {
5+ label:
6+ | "Working"
7+ | "Connecting"
8+ | "Completed"
9+ | "Pending Approval"
10+ | "Awaiting Input"
11+ | "Plan Ready";
12+ colorClass: string;
13+ dotClass: string;
14+ pulse: boolean;
15+}
16+
17+type ThreadStatusInput = Pick<
18+ Thread,
19+ "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
20+>;
21+
22+export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
23+ if (!thread.latestTurn?.completedAt) return false;
24+ const completedAt = Date.parse(thread.latestTurn.completedAt);
25+ if (Number.isNaN(completedAt)) return false;
26+ if (!thread.lastVisitedAt) return true;
27+
28+ const lastVisitedAt = Date.parse(thread.lastVisitedAt);
29+ if (Number.isNaN(lastVisitedAt)) return true;
30+ return completedAt > lastVisitedAt;
31+}
32+
33+export function resolveThreadStatusPill(input: {
34+ thread: ThreadStatusInput;
35+ hasPendingApprovals: boolean;
36+ hasPendingUserInput: boolean;
37+}): ThreadStatusPill | null {
38+ const { hasPendingApprovals, hasPendingUserInput, thread } = input;
39+
40+ if (hasPendingApprovals) {
41+ return {
42+ label: "Pending Approval",
43+ colorClass: "text-amber-600 dark:text-amber-300/90",
44+ dotClass: "bg-amber-500 dark:bg-amber-300/90",
45+ pulse: false,
46+ };
47+ }
48+
49+ if (hasPendingUserInput) {
50+ return {
51+ label: "Awaiting Input",
52+ colorClass: "text-indigo-600 dark:text-indigo-300/90",
53+ dotClass: "bg-indigo-500 dark:bg-indigo-300/90",
54+ pulse: false,
55+ };
56+ }
57+
58+ if (thread.session?.status === "running") {
59+ return {
60+ label: "Working",
61+ colorClass: "text-sky-600 dark:text-sky-300/80",
62+ dotClass: "bg-sky-500 dark:bg-sky-300/80",
63+ pulse: true,
64+ };
65+ }
66+
67+ if (thread.session?.status === "connecting") {
68+ return {
69+ label: "Connecting",
70+ colorClass: "text-sky-600 dark:text-sky-300/80",
71+ dotClass: "bg-sky-500 dark:bg-sky-300/80",
72+ pulse: true,
73+ };
74+ }
75+
76+ const hasPlanReadyPrompt =
77+ !hasPendingUserInput &&
78+ thread.interactionMode === "plan" &&
79+ isLatestTurnSettled(thread.latestTurn, thread.session) &&
80+ findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null) !== null;
81+ if (hasPlanReadyPrompt) {
82+ return {
83+ label: "Plan Ready",
84+ colorClass: "text-violet-600 dark:text-violet-300/90",
85+ dotClass: "bg-violet-500 dark:bg-violet-300/90",
86+ pulse: false,
87+ };
88+ }
89+
90+ if (hasUnseenCompletion(thread)) {
91+ return {
92+ label: "Completed",
93+ colorClass: "text-emerald-600 dark:text-emerald-300/90",
94+ dotClass: "bg-emerald-500 dark:bg-emerald-300/90",
95+ pulse: false,
96+ };
97+ }
98+
99+ return null;
100+}
apps/web/src/components/Sidebar.tsx+204−129
@@ -1,10 +1,14 @@
11 import {
2+ ArrowLeftIcon,
23 ChevronRightIcon,
34 FolderIcon,
45 GitPullRequestIcon,
6+ PlusIcon,
57 RocketIcon,
8+ SettingsIcon,
69 SquarePenIcon,
710 TerminalIcon,
11+ TriangleAlertIcon,
812 } from "lucide-react";
913 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1014 import {
@@ -17,15 +21,14 @@ import { · 6 unmodified lines
1721 type ResolvedKeybindingsConfig,
1822 } from "@t3tools/contracts";
1923 import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
20−import { useNavigate, useParams } from "@tanstack/react-router";
24+import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
2125 import { useAppSettings } from "../appSettings";
2226 import { isElectron } from "../env";
2327 import { APP_STAGE_LABEL } from "../branding";
2428 import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
2529 import { useStore } from "../store";
2630 import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";
27−import { type Thread } from "../types";
28−import { derivePendingApprovals } from "../session-logic";
31+import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
2932 import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
3033 import { serverConfigQueryOptions } from "../lib/serverReactQuery";
3134 import { readNativeApi } from "../nativeApi";
@@ -33,14 +36,18 @@ import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraft · 1 unmodified line
3336 import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
3437 import { toastManager } from "./ui/toast";
3538 import {
39+ getArm64IntelBuildWarningDescription,
3640 getDesktopUpdateActionError,
3741 getDesktopUpdateButtonTooltip,
3842 isDesktopUpdateButtonDisabled,
3943 resolveDesktopUpdateButtonAction,
44+ shouldShowArm64IntelBuildWarning,
4045 shouldHighlightDesktopUpdateError,
4146 shouldShowDesktopUpdateButton,
4247 shouldToastDesktopUpdateActionResult,
4348 } from "./desktopUpdate.logic";
49+import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
50+import { Button } from "./ui/button";
4451 import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
4552 import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
4653 import {
@@ -60,6 +67,7 @@ import { · 13 unmodified lines
6067 } from "./ui/sidebar";
6168 import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
6269 import { isNonEmpty as isNonEmptyString } from "effect/String";
70+import { resolveThreadStatusPill } from "./Sidebar.logic";
6371
6472 const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
6573 const THREAD_PREVIEW_LIMIT = 6;
@@ -81,13 +89,6 @@ function formatRelativeTime(iso: string): string { · 15 unmodified lines
8189 return `${Math.floor(hours / 24)}d ago`;
8290 }
8391
84−interface ThreadStatusPill {
85− label: "Working" | "Connecting" | "Completed" | "Pending Approval";
86− colorClass: string;
87− dotClass: string;
88− pulse: boolean;
89−}
90−
9192 interface TerminalStatusIndicator {
9293 label: "Terminal process running";
9394 colorClass: string;
@@ -103,57 +104,6 @@ interface PrStatusIndicator { · 9 unmodified lines
103104
104105 type ThreadPr = GitStatusResult["pr"];
105106
106−function hasUnseenCompletion(thread: Thread): boolean {
107− if (!thread.latestTurn?.completedAt) return false;
108− const completedAt = Date.parse(thread.latestTurn.completedAt);
109− if (Number.isNaN(completedAt)) return false;
110− if (!thread.lastVisitedAt) return true;
111−
112− const lastVisitedAt = Date.parse(thread.lastVisitedAt);
113− if (Number.isNaN(lastVisitedAt)) return true;
114− return completedAt > lastVisitedAt;
115−}
116−
117−function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null {
118− if (hasPendingApprovals) {
119− return {
120− label: "Pending Approval",
121− colorClass: "text-amber-600 dark:text-amber-300/90",
122− dotClass: "bg-amber-500 dark:bg-amber-300/90",
123− pulse: false,
124− };
125− }
126−
127− if (thread.session?.status === "running") {
128− return {
129− label: "Working",
130− colorClass: "text-sky-600 dark:text-sky-300/80",
131− dotClass: "bg-sky-500 dark:bg-sky-300/80",
132− pulse: true,
133− };
134− }
135−
136− if (thread.session?.status === "connecting") {
137− return {
138− label: "Connecting",
139− colorClass: "text-sky-600 dark:text-sky-300/80",
140− dotClass: "bg-sky-500 dark:bg-sky-300/80",
141− pulse: true,
142− };
143− }
144−
145− if (hasUnseenCompletion(thread)) {
146− return {
147− label: "Completed",
148− colorClass: "text-emerald-600 dark:text-emerald-300/90",
149− dotClass: "bg-emerald-500 dark:bg-emerald-300/90",
150− pulse: false,
151− };
152− }
153−
154− return null;
155−}
156−
157107 function terminalStatusFromRunningIds(
158108 runningTerminalIds: string[],
159109 ): TerminalStatusIndicator | null {
@@ -278,6 +228,7 @@ export default function Sidebar() { · 118 unmodified lines
278228 (store) => store.clearProjectDraftThreadById,
279229 );
280230 const navigate = useNavigate();
231+ const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" });
281232 const { settings: appSettings } = useAppSettings();
282233 const routeThreadId = useParams({
283234 strict: false,
@@ -293,6 +244,8 @@ export default function Sidebar() { · 9 unmodified lines
293244 const [newCwd, setNewCwd] = useState("");
294245 const [isPickingFolder, setIsPickingFolder] = useState(false);
295246 const [isAddingProject, setIsAddingProject] = useState(false);
247+ const [addProjectError, setAddProjectError] = useState<string | null>(null);
248+ const addProjectInputRef = useRef<HTMLInputElement | null>(null);
296249 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
297250 const [renamingTitle, setRenamingTitle] = useState("");
298251 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
@@ -301,6 +254,8 @@ export default function Sidebar() { · 2 unmodified lines
301254 const renamingCommittedRef = useRef(false);
302255 const renamingInputRef = useRef<HTMLInputElement | null>(null);
303256 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
257+ const shouldBrowseForProjectImmediately = isElectron;
258+ const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
304259 const pendingApprovalByThreadId = useMemo(() => {
305260 const map = new Map<ThreadId, boolean>();
306261 for (const thread of threads) {
@@ -308,6 +263,13 @@ export default function Sidebar() { · 1 unmodified line
308263 }
309264 return map;
310265 }, [threads]);
266+ const pendingUserInputByThreadId = useMemo(() => {
267+ const map = new Map<ThreadId, boolean>();
268+ for (const thread of threads) {
269+ map.set(thread.id, derivePendingUserInputs(thread.activities).length > 0);
270+ }
271+ return map;
272+ }, [threads]);
311273 const projectCwdById = useMemo(
312274 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),
313275 [projects],
@@ -486,6 +448,7 @@ export default function Sidebar() { · 172 unmodified lines
486448 const finishAddingProject = () => {
487449 setIsAddingProject(false);
488450 setNewCwd("");
451+ setAddProjectError(null);
489452 setAddingProject(false);
490453 };
491454
@@ -511,18 +474,29 @@ export default function Sidebar() { · 19 unmodified lines
511474 });
512475 await handleNewThread(projectId).catch(() => undefined);
513476 } catch (error) {
477+ const description =
478+ error instanceof Error ? error.message : "An error occurred while adding the project.";
514479 setIsAddingProject(false);
515− toastManager.add({
516− type: "error",
517− title: "Unable to add project",
518− description:
519− error instanceof Error ? error.message : "An error occurred while adding the project.",
520− });
480+ if (shouldBrowseForProjectImmediately) {
481+ toastManager.add({
482+ type: "error",
483+ title: "Failed to add project",
484+ description,
485+ });
486+ } else {
487+ setAddProjectError(description);
488+ }
521489 return;
522490 }
523491 finishAddingProject();
524492 },
525− [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects],
493+ [
494+ focusMostRecentThreadForProject,
495+ handleNewThread,
496+ isAddingProject,
497+ projects,
498+ shouldBrowseForProjectImmediately,
499+ ],
526500 );
527501
528502 const handleAddProject = () => {
@@ -541,10 +515,21 @@ export default function Sidebar() { · 12 unmodified lines
541515 }
542516 if (pickedPath) {
543517 await addProjectFromPath(pickedPath);
518+ } else if (!shouldBrowseForProjectImmediately) {
519+ addProjectInputRef.current?.focus();
544520 }
545521 setIsPickingFolder(false);
546522 };
547523
524+ const handleStartAddProject = () => {
525+ setAddProjectError(null);
526+ if (shouldBrowseForProjectImmediately) {
527+ void handlePickFolder();
528+ return;
529+ }
530+ setAddingProject((prev) => !prev);
531+ };
532+
548533 const cancelRename = useCallback(() => {
549534 setRenamingThreadId(null);
550535 renamingInputRef.current = null;
@@ -881,6 +866,12 @@ export default function Sidebar() { · 330 unmodified lines
881866 const desktopUpdateButtonAction = desktopUpdateState
882867 ? resolveDesktopUpdateButtonAction(desktopUpdateState)
883868 : "none";
869+ const showArm64IntelBuildWarning =
870+ isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState);
871+ const arm64IntelBuildWarningDescription =
872+ desktopUpdateState && showArm64IntelBuildWarning
873+ ? getArm64IntelBuildWarningDescription(desktopUpdateState)
874+ : null;
884875 const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled
885876 ? "cursor-not-allowed opacity-60"
886877 : "hover:bg-accent hover:text-foreground";
@@ -978,7 +969,7 @@ export default function Sidebar() { · 91 unmodified lines
978969 const wordmark = (
979970 <div className="flex items-center gap-2">
980971 <SidebarTrigger className="shrink-0 md:hidden" />
981− <div className="flex min-w-0 flex-1 items-center gap-1 mt-2 ml-1">
972+ <div className="flex min-w-0 flex-1 items-center gap-1 mt-1.5 ml-1">
982973 <T3Wordmark />
983974 <span className="truncate text-sm font-medium tracking-tight text-muted-foreground">
984975 Code
@@ -994,7 +985,7 @@ export default function Sidebar() { · 9 unmodified lines
994985 <>
995986 {isElectron ? (
996987 <>
997− <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[82px]">
988+ <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">
998989 {wordmark}
999990 {showDesktopUpdateButton && (
1000991 <Tooltip>
@@ -1005,7 +996,7 @@ export default function Sidebar() { · 4 unmodified lines
1005996 aria-label={desktopUpdateTooltip}
1006997 aria-disabled={desktopUpdateButtonDisabled || undefined}
1007998 disabled={desktopUpdateButtonDisabled}
1008− className={`inline-flex size-7 ml-auto mt-2 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`}
999+ className={`inline-flex size-7 ml-auto mt-1.5 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`}
10091000 onClick={handleDesktopUpdateButtonClick}
10101001 >
10111002 <RocketIcon className="size-3.5" />
@@ -1024,7 +1015,121 @@ export default function Sidebar() { · 12 unmodified lines
10241015 )}
10251016
10261017 <SidebarContent className="gap-0">
1018+ {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
1019+ <SidebarGroup className="px-2 pt-2 pb-0">
1020+ <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
1021+ <TriangleAlertIcon />
1022+ <AlertTitle>Intel build on Apple Silicon</AlertTitle>
1023+ <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>
1024+ {desktopUpdateButtonAction !== "none" ? (
1025+ <AlertAction>
1026+ <Button
1027+ size="xs"
1028+ variant="outline"
1029+ disabled={desktopUpdateButtonDisabled}
1030+ onClick={handleDesktopUpdateButtonClick}
1031+ >
1032+ {desktopUpdateButtonAction === "download"
1033+ ? "Download ARM build"
1034+ : "Install ARM build"}
1035+ </Button>
1036+ </AlertAction>
1037+ ) : null}
1038+ </Alert>
1039+ </SidebarGroup>
1040+ ) : null}
10271041 <SidebarGroup className="px-2 py-2">
1042+ <div className="mb-1 flex items-center justify-between px-2">
1043+ <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
1044+ Projects
1045+ </span>
1046+ <Tooltip>
1047+ <TooltipTrigger
1048+ render={
1049+ <button
1050+ type="button"
1051+ aria-label="Add project"
1052+ aria-pressed={shouldShowProjectPathEntry}
1053+ className="inline-flex size-5 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
1054+ onClick={handleStartAddProject}
1055+ />
1056+ }
1057+ >
1058+ <PlusIcon
1059+ className={`size-3.5 transition-transform duration-150 ${
1060+ shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
1061+ }`}
1062+ />
1063+ </TooltipTrigger>
1064+ <TooltipPopup side="right">Add project</TooltipPopup>
1065+ </Tooltip>
1066+ </div>
1067+
1068+ {shouldShowProjectPathEntry && (
1069+ <div className="mb-2 px-1">
1070+ {isElectron && (
1071+ <button
1072+ type="button"
1073+ className="mb-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-border bg-secondary py-1.5 text-xs text-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
1074+ onClick={() => void handlePickFolder()}
1075+ disabled={isPickingFolder || isAddingProject}
1076+ >
1077+ <FolderIcon className="size-3.5" />
1078+ {isPickingFolder ? "Picking folder..." : "Browse for folder"}
1079+ </button>
1080+ )}
1081+ <div className="flex gap-1.5">
1082+ <input
1083+ ref={addProjectInputRef}
1084+ className={`min-w-0 flex-1 rounded-md border bg-secondary px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none ${
1085+ addProjectError
1086+ ? "border-red-500/70 focus:border-red-500"
1087+ : "border-border focus:border-ring"
1088+ }`}
1089+ placeholder="/path/to/project"
1090+ value={newCwd}
1091+ onChange={(event) => {
1092+ setNewCwd(event.target.value);
1093+ setAddProjectError(null);
1094+ }}
1095+ onKeyDown={(event) => {
1096+ if (event.key === "Enter") handleAddProject();
1097+ if (event.key === "Escape") {
1098+ setAddingProject(false);
1099+ setAddProjectError(null);
1100+ }
1101+ }}
1102+ autoFocus
1103+ />
1104+ <button
1105+ type="button"
1106+ className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60"
1107+ onClick={handleAddProject}
1108+ disabled={isAddingProject}
1109+ >
1110+ {isAddingProject ? "Adding..." : "Add"}
1111+ </button>
1112+ </div>
1113+ {addProjectError && (
1114+ <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400">
1115+ {addProjectError}
1116+ </p>
1117+ )}
1118+ <div className="mt-1.5 px-0.5">
1119+ <button
1120+ type="button"
1121+ className="text-[11px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
1122+ onClick={() => {
1123+ setAddingProject(false);
1124+ setAddProjectError(null);
1125+ }}
1126+ >
1127+ Cancel
1128+ </button>
1129+ </div>
1130+ </div>
1131+ )}
1132+
10281133 <SidebarMenu>
10291134 {projects.map((project) => {
10301135 const projectThreads = threads
@@ -1112,10 +1217,11 @@ export default function Sidebar() { · 81 unmodified lines
11121217 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">
11131218 {visibleThreads.map((thread) => {
11141219 const isActive = routeThreadId === thread.id;
1115− const threadStatus = threadStatusPill(
1220+ const threadStatus = resolveThreadStatusPill({
11161221 thread,
1117− pendingApprovalByThreadId.get(thread.id) === true,
1118− );
1222+ hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true,
1223+ hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true,
1224+ });
11191225 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
11201226 const terminalStatus = terminalStatusFromRunningIds(
11211227 selectThreadTerminalState(terminalStateByThreadId, thread.id)
@@ -1286,70 +1392,39 @@ export default function Sidebar() { · 164 unmodified lines
12861392 })}
12871393 </SidebarMenu>
12881394
1289− {projects.length === 0 && !addingProject && (
1395+ {projects.length === 0 && !shouldShowProjectPathEntry && (
12901396 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
1291− No projects yet.
1292− <br />
1293− Add one to get started.
1397+ No projects yet
12941398 </div>
12951399 )}
12961400 </SidebarGroup>
12971401 </SidebarContent>
12981402
12991403 <SidebarSeparator />
1300− <SidebarFooter className="gap-0 p-3">
1301− {addingProject ? (
1302− <>
1303− <p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
1304− Add project
1305− </p>
1306− <input
1307− className="mb-2 w-full rounded-md border border-border bg-secondary px-2 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-ring focus:outline-none"
1308− placeholder="/path/to/project"
1309− value={newCwd}
1310− onChange={(event) => setNewCwd(event.target.value)}
1311− onKeyDown={(event) => {
1312− if (event.key === "Enter") handleAddProject();
1313− if (event.key === "Escape") setAddingProject(false);
1314− }}
1315− />
1316− {isElectron && (
1317− <button
1318− type="button"
1319− className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
1320− onClick={() => void handlePickFolder()}
1321− disabled={isPickingFolder || isAddingProject}
1404+ <SidebarFooter className="p-2">
1405+ <SidebarMenu>
1406+ <SidebarMenuItem>
1407+ {isOnSettings ? (
1408+ <SidebarMenuButton
1409+ size="sm"
1410+ className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
1411+ onClick={() => window.history.back()}
13221412 >
1323− {isPickingFolder ? "Picking folder..." : "Browse for folder"}
1324− </button>
1325− )}
1326− <div className="flex gap-2">
1327− <button
1328− type="button"
1329− className="flex-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90"
1330− onClick={handleAddProject}
1331− disabled={isAddingProject}
1332− >
1333− {isAddingProject ? "Adding..." : "Add"}
1334− </button>
1335− <button
1336− type="button"
1337− className="flex-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground/80 transition-colors duration-150 hover:bg-secondary"
1338− onClick={() => setAddingProject(false)}
1413+ <ArrowLeftIcon className="size-3.5" />
1414+ <span className="text-xs">Back</span>
1415+ </SidebarMenuButton>
1416+ ) : (
1417+ <SidebarMenuButton
1418+ size="sm"
1419+ className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
1420+ onClick={() => void navigate({ to: "/settings" })}
13391421 >
1340− Cancel
1341− </button>
1342− </div>
1343− </>
1344− ) : (
1345− <button
1346− type="button"
1347− className="flex w-full items-center justify-center gap-1 rounded-md border border-dashed border-border py-2 text-xs text-muted-foreground/70 transition-colors duration-150 hover:border-ring hover:text-muted-foreground"
1348− onClick={() => setAddingProject(true)}
1349− >
1350− + Add project
1351− </button>
1352− )}
1422+ <SettingsIcon className="size-3.5" />
1423+ <span className="text-xs">Settings</span>
1424+ </SidebarMenuButton>
1425+ )}
1426+ </SidebarMenuItem>
1427+ </SidebarMenu>
13531428 </SidebarFooter>
13541429 </>
13551430 );
apps/web/src/components/desktopUpdate.logic.test.ts+31−0
@@ -2,11 +2,13 @@ import { describe, expect, it } from "vitest"; · 1 unmodified line
22 import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";
33
44 import {
5+ getArm64IntelBuildWarningDescription,
56 getDesktopUpdateActionError,
67 getDesktopUpdateButtonTooltip,
78 isDesktopUpdateButtonDisabled,
89 resolveDesktopUpdateButtonAction,
910 shouldHighlightDesktopUpdateError,
11+ shouldShowArm64IntelBuildWarning,
1012 shouldShowDesktopUpdateButton,
1113 shouldToastDesktopUpdateActionResult,
1214 } from "./desktopUpdate.logic";
@@ -15,6 +17,9 @@ const baseState: DesktopUpdateState = { · 2 unmodified lines
1517 enabled: true,
1618 status: "idle",
1719 currentVersion: "1.0.0",
20+ hostArch: "x64",
21+ appArch: "x64",
22+ runningUnderArm64Translation: false,
1823 availableVersion: null,
1924 downloadedVersion: null,
2025 downloadPercent: null,
@@ -171,4 +176,30 @@ describe("desktop update UI helpers", () => { · 150 unmodified lines
171176 }),
172177 ).toBe(false);
173178 });
179+
180+ it("shows an Apple Silicon warning for Intel builds under Rosetta", () => {
181+ const state: DesktopUpdateState = {
182+ ...baseState,
183+ hostArch: "arm64",
184+ appArch: "x64",
185+ runningUnderArm64Translation: true,
186+ };
187+
188+ expect(shouldShowArm64IntelBuildWarning(state)).toBe(true);
189+ expect(getArm64IntelBuildWarningDescription(state)).toContain("Apple Silicon");
190+ expect(getArm64IntelBuildWarningDescription(state)).toContain("Intel build");
191+ });
192+
193+ it("changes the warning copy when a native build update is ready to download", () => {
194+ const state: DesktopUpdateState = {
195+ ...baseState,
196+ hostArch: "arm64",
197+ appArch: "x64",
198+ runningUnderArm64Translation: true,
199+ status: "available",
200+ availableVersion: "1.1.0",
201+ };
202+
203+ expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update");
204+ });
174205 });
apps/web/src/components/desktopUpdate.logic.ts+19−0
@@ -32,10 +32,29 @@ export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): · 31 unmodified lines
3232 return resolveDesktopUpdateButtonAction(state) !== "none";
3333 }
3434
35+export function shouldShowArm64IntelBuildWarning(state: DesktopUpdateState | null): boolean {
36+ return state?.hostArch === "arm64" && state.appArch === "x64";
37+}
38+
3539 export function isDesktopUpdateButtonDisabled(state: DesktopUpdateState | null): boolean {
3640 return state?.status === "downloading";
3741 }
3842
43+export function getArm64IntelBuildWarningDescription(state: DesktopUpdateState): string {
44+ if (!shouldShowArm64IntelBuildWarning(state)) {
45+ return "This install is using the correct architecture.";
46+ }
47+
48+ const action = resolveDesktopUpdateButtonAction(state);
49+ if (action === "download") {
50+ return "This Mac has Apple Silicon, but T3 Code is still running the Intel build under Rosetta. Download the available update to switch to the native Apple Silicon build.";
51+ }
52+ if (action === "install") {
53+ return "This Mac has Apple Silicon, but T3 Code is still running the Intel build under Rosetta. Restart to install the downloaded Apple Silicon build.";
54+ }
55+ return "This Mac has Apple Silicon, but T3 Code is still running the Intel build under Rosetta. The next app update will replace it with the native Apple Silicon build.";
56+}
57+
3958 export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string {
4059 if (state.status === "available") {
4160 return `Update ${state.availableVersion ?? "available"} ready to download`;
apps/web/src/components/ui/sidebar.tsx+2−1
@@ -566,7 +566,8 @@ function SidebarRail({ · 565 unmodified lines
566566 <button
567567 aria-label={railLabel}
568568 className={cn(
569− "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex",
569+ /* disable pointer events only when offcanvas sidebar is collapsed, that's when the rail sits over the native scrollbar on windows and linux. icon mode stays fully clickable. */
570+ "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none",
570571 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
571572 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
572573 "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
apps/web/src/components/ui/toast.logic.test.ts+40−1
@@ -1,5 +1,5 @@
11 import { assert, describe, it } from "vitest";
2−import { shouldHideCollapsedToastContent } from "./toast.logic";
2+import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic";
33
44 describe("shouldHideCollapsedToastContent", () => {
55 it("keeps a single visible toast readable", () => {
@@ -14,3 +14,42 @@ describe("shouldHideCollapsedToastContent", () => { · 8 unmodified lines
1414 assert.equal(shouldHideCollapsedToastContent(1, 3), true);
1515 });
1616 });
17+
18+describe("buildVisibleToastLayout", () => {
19+ it("computes indices and offsets from the visible subset", () => {
20+ const visibleToasts = [{ id: "a", height: 48 }, { id: "b", height: 72 }, { id: "c", height: 24 }];
21+
22+ const layout = buildVisibleToastLayout(visibleToasts);
23+
24+ assert.equal(layout.frontmostHeight, 48);
25+ assert.deepEqual(
26+ layout.items.map(({ toast, visibleIndex, offsetY }) => ({
27+ id: toast.id,
28+ visibleIndex,
29+ offsetY,
30+ })),
31+ [
32+ { id: "a", visibleIndex: 0, offsetY: 0 },
33+ { id: "b", visibleIndex: 1, offsetY: 48 },
34+ { id: "c", visibleIndex: 2, offsetY: 120 },
35+ ],
36+ );
37+ });
38+
39+ it("treats missing heights as zero", () => {
40+ const layout = buildVisibleToastLayout([{ id: "a" }, { id: "b", height: undefined }, { id: "c", height: 30 }]);
41+
42+ assert.equal(layout.frontmostHeight, 0);
43+ assert.deepEqual(
44+ layout.items.map(({ toast, offsetY }) => ({
45+ id: toast.id,
46+ offsetY,
47+ })),
48+ [
49+ { id: "a", offsetY: 0 },
50+ { id: "b", offsetY: 0 },
51+ { id: "c", offsetY: 0 },
52+ ],
53+ );
54+ });
55+});
apps/web/src/components/ui/toast.logic.ts+37−0
@@ -7,3 +7,40 @@ export function shouldHideCollapsedToastContent( · 6 unmodified lines
77 if (visibleToastCount <= 1) return false;
88 return visibleToastIndex > 0;
99 }
10+
11+type ToastWithHeight = {
12+ height?: number | null | undefined;
13+};
14+
15+type VisibleToastLayoutItem<TToast extends object> = {
16+ toast: TToast;
17+ visibleIndex: number;
18+ offsetY: number;
19+};
20+
21+export function buildVisibleToastLayout<TToast extends object>(
22+ visibleToasts: readonly (TToast & ToastWithHeight)[],
23+): {
24+ frontmostHeight: number;
25+ items: VisibleToastLayoutItem<TToast & ToastWithHeight>[];
26+} {
27+ let offsetY = 0;
28+
29+ return {
30+ frontmostHeight: normalizeToastHeight(visibleToasts[0]?.height),
31+ items: visibleToasts.map((toast, visibleIndex) => {
32+ const item = {
33+ toast,
34+ visibleIndex,
35+ offsetY,
36+ };
37+
38+ offsetY += normalizeToastHeight(toast.height);
39+ return item;
40+ }),
41+ };
42+}
43+
44+function normalizeToastHeight(height: number | null | undefined): number {
45+ return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : 0;
46+}
apps/web/src/components/ui/toast.tsx+16−4
@@ -1,7 +1,7 @@
11 "use client";
22
33 import { Toast } from "@base-ui/react/toast";
4−import { useEffect } from "react";
4+import { useEffect, type CSSProperties } from "react";
55 import { useParams } from "@tanstack/react-router";
66 import { ThreadId } from "@t3tools/contracts";
77 import {
@@ -14,7 +14,7 @@ import { · 6 unmodified lines
1414
1515 import { cn } from "~/lib/utils";
1616 import { buttonVariants } from "~/components/ui/button";
17−import { shouldHideCollapsedToastContent } from "./toast.logic";
17+import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic";
1818
1919 type ThreadToastData = {
2020 threadId?: ThreadId | null;
@@ -158,6 +158,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 137 unmodified lines
158158 const visibleToasts = toasts.filter((toast) =>
159159 shouldRenderForActiveThread(toast.data, activeThreadId),
160160 );
161+ const visibleToastLayout = buildVisibleToastLayout(visibleToasts);
161162
162163 useEffect(() => {
163164 const activeToastIds = new Set(toasts.map((toast) => toast.id));
@@ -183,12 +184,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 19 unmodified lines
183184 )}
184185 data-position={position}
185186 data-slot="toast-viewport"
187+ style={
188+ {
189+ "--toast-frontmost-height": `${visibleToastLayout.frontmostHeight}px`,
190+ } as CSSProperties
191+ }
186192 >
187− {visibleToasts.map((toast, visibleIndex) => {
193+ {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => {
188194 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
189195 const hideCollapsedContent = shouldHideCollapsedToastContent(
190196 visibleIndex,
191− visibleToasts.length,
197+ visibleToastLayout.items.length,
192198 );
193199
194200 return (
@@ -241,6 +247,12 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 46 unmodified lines
241247 )}
242248 data-position={position}
243249 key={toast.id}
250+ style={
251+ {
252+ "--toast-index": visibleIndex,
253+ "--toast-offset-y": `${offsetY}px`,
254+ } as CSSProperties
255+ }
244256 swipeDirection={
245257 position.includes("center")
246258 ? [isTop ? "up" : "down"]
apps/web/src/proposedPlan.ts+16−0
@@ -49,3 +49,19 @@ export function buildProposedPlanMarkdownFilename(planMarkdown: string): string · 48 unmodified lines
4949 const title = proposedPlanTitle(planMarkdown);
5050 return `${sanitizePlanFileSegment(title ?? "plan")}.md`;
5151 }
52+
53+export function normalizePlanMarkdownForExport(planMarkdown: string): string {
54+ return `${planMarkdown.trimEnd()}\n`;
55+}
56+
57+export function downloadPlanAsTextFile(filename: string, contents: string): void {
58+ const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" });
59+ const url = URL.createObjectURL(blob);
60+ const anchor = document.createElement("a");
61+ anchor.href = url;
62+ anchor.download = filename;
63+ anchor.click();
64+ window.setTimeout(() => {
65+ URL.revokeObjectURL(url);
66+ }, 0);
67+}
apps/web/src/routes/_chat.settings.tsx+5−55
@@ -3,14 +3,8 @@ import { useQuery } from "@tanstack/react-query"; · 2 unmodified lines
33 import { useCallback, useState } from "react";
44 import { type ProviderKind } from "@t3tools/contracts";
55 import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
6−import { ZapIcon } from "lucide-react";
7−
8−import {
9− APP_SERVICE_TIER_OPTIONS,
10− MAX_CUSTOM_MODEL_LENGTH,
11− shouldShowFastTierIcon,
12− useAppSettings,
13−} from "../appSettings";
6+
7+import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
148 import { isElectron } from "../env";
159 import { useTheme } from "../hooks/useTheme";
1610 import { serverConfigQueryOptions } from "../lib/serverReactQuery";
@@ -18,7 +12,6 @@ import { ensureNativeApi } from "../nativeApi"; · 1 unmodified line
1812 import { preferredTerminalEditor } from "../terminal-links";
1913 import { Button } from "../components/ui/button";
2014 import { Input } from "../components/ui/input";
21−import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../components/ui/select";
2215 import { Switch } from "../components/ui/switch";
2316 import { SidebarInset } from "~/components/ui/sidebar";
2417
@@ -103,7 +96,6 @@ function SettingsRouteView() { · 78 unmodified lines
10396
10497 const codexBinaryPath = settings.codexBinaryPath;
10598 const codexHomePath = settings.codexHomePath;
106− const codexServiceTier = settings.codexServiceTier;
10799 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
108100
109101 const openKeybindingsFile = useCallback(() => {
@@ -310,43 +302,6 @@ function SettingsRouteView() { · 200 unmodified lines
310302 </div>
311303
312304 <div className="space-y-5">
313− <label className="block space-y-1">
314− <span className="text-xs font-medium text-foreground">Default service tier</span>
315− <Select
316− items={APP_SERVICE_TIER_OPTIONS.map((option) => ({
317− label: option.label,
318− value: option.value,
319− }))}
320− value={codexServiceTier}
321− onValueChange={(value) => {
322− if (!value) return;
323− updateSettings({ codexServiceTier: value });
324− }}
325− >
326− <SelectTrigger>
327− <SelectValue />
328− </SelectTrigger>
329− <SelectPopup alignItemWithTrigger={false}>
330− {APP_SERVICE_TIER_OPTIONS.map((option) => (
331− <SelectItem key={option.value} value={option.value}>
332− <div className="flex min-w-0 items-center gap-2">
333− {option.value === "fast" ? (
334− <ZapIcon className="size-3.5 text-amber-500" />
335− ) : (
336− <span className="size-3.5 shrink-0" aria-hidden="true" />
337− )}
338− <span className="truncate">{option.label}</span>
339− </div>
340− </SelectItem>
341− ))}
342− </SelectPopup>
343− </Select>
344− <span className="text-xs text-muted-foreground">
345− {APP_SERVICE_TIER_OPTIONS.find((option) => option.value === codexServiceTier)
346− ?.description ?? "Use Codex defaults without forcing a service tier."}
347− </span>
348− </label>
349−
350305 {MODEL_PROVIDER_SETTINGS.map((providerSettings) => {
351306 const provider = providerSettings.provider;
352307 const customModels = getCustomModelsForProvider(settings, provider);
@@ -445,14 +400,9 @@ function SettingsRouteView() { · 92 unmodified lines
445400 key={`${provider}:${slug}`}
446401 className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2"
447402 >
448− <div className="flex min-w-0 flex-1 items-center gap-2">
449− {provider === "codex" && shouldShowFastTierIcon(slug, codexServiceTier) ? (
450− <ZapIcon className="size-3.5 shrink-0 text-amber-500" />
451− ) : null}
452− <code className="min-w-0 flex-1 truncate text-xs text-foreground">
453− {slug}
454− </code>
455− </div>
403+ <code className="min-w-0 flex-1 truncate text-xs text-foreground">
404+ {slug}
405+ </code>
456406 <Button
457407 size="xs"
458408 variant="ghost"
apps/web/src/store.ts+1−0
@@ -25,6 +25,7 @@ export interface AppState { · 24 unmodified lines
2525
2626 const PERSISTED_STATE_KEY = "t3code:renderer-state:v8";
2727 const LEGACY_PERSISTED_STATE_KEYS = [
28+ "t3code:renderer-state:v7",
2829 "t3code:renderer-state:v6",
2930 "t3code:renderer-state:v5",
3031 "t3code:renderer-state:v4",
apps/web/vite.config.ts+9−0
@@ -4,6 +4,14 @@ import { tanstackRouter } from "@tanstack/router-plugin/vite"; · 3 unmodified lines
44 import { defineConfig } from "vite";
55
66 const port = Number(process.env.PORT ?? 5733);
7+const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();
8+
9+const buildSourcemap =
10+ sourcemapEnv === "0" || sourcemapEnv === "false"
11+ ? false
12+ : sourcemapEnv === "hidden"
13+ ? "hidden"
14+ : true;
715
816 export default defineConfig({
917 plugins: [
@@ -42,5 +50,6 @@ export default defineConfig({ · 32 unmodified lines
4250 build: {
4351 outDir: "dist",
4452 emptyOutDir: true,
53+ sourcemap: buildSourcemap,
4554 },
4655 });
bun.lock+4−4
@@ -14,7 +14,7 @@ · 13 unmodified lines
1414 },
1515 "apps/desktop": {
1616 "name": "@t3tools/desktop",
17− "version": "0.0.5",
17+ "version": "0.0.9",
1818 "dependencies": {
1919 "effect": "catalog:",
2020 "electron": "40.6.0",
@@ -43,7 +43,7 @@ · 22 unmodified lines
4343 },
4444 "apps/server": {
4545 "name": "t3",
46− "version": "0.0.5",
46+ "version": "0.0.9",
4747 "bin": {
4848 "t3": "./dist/index.mjs",
4949 },
@@ -72,7 +72,7 @@ · 22 unmodified lines
7272 },
7373 "apps/web": {
7474 "name": "@t3tools/web",
75− "version": "0.0.5",
75+ "version": "0.0.9",
7676 "dependencies": {
7777 "@base-ui/react": "^1.2.0",
7878 "@lexical/react": "^0.41.0",
@@ -116,7 +116,7 @@ · 37 unmodified lines
116116 },
117117 "packages/contracts": {
118118 "name": "@t3tools/contracts",
119− "version": "0.0.5",
119+ "version": "0.0.9",
120120 "dependencies": {
121121 "effect": "catalog:",
122122 },
package.json+1−0
@@ -45,6 +45,7 @@ · 44 unmodified lines
4545 "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64",
4646 "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64",
4747 "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64",
48+ "release:smoke": "node scripts/release-smoke.ts",
4849 "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",
4950 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"
5051 },
packages/contracts/package.json+1−1
@@ -1,6 +1,6 @@
11 {
22 "name": "@t3tools/contracts",
3− "version": "0.0.5",
3+ "version": "0.0.9",
44 "private": true,
55 "files": [
66 "dist"
packages/contracts/src/ipc.ts+11−0
@@ -58,10 +58,21 @@ export type DesktopUpdateStatus = · 57 unmodified lines
5858 | "downloaded"
5959 | "error";
6060
61+export type DesktopRuntimeArch = "arm64" | "x64" | "other";
62+
63+export interface DesktopRuntimeInfo {
64+ hostArch: DesktopRuntimeArch;
65+ appArch: DesktopRuntimeArch;
66+ runningUnderArm64Translation: boolean;
67+}
68+
6169 export interface DesktopUpdateState {
6270 enabled: boolean;
6371 status: DesktopUpdateStatus;
6472 currentVersion: string;
73+ hostArch: DesktopRuntimeArch;
74+ appArch: DesktopRuntimeArch;
75+ runningUnderArm64Translation: boolean;
6576 availableVersion: string | null;
6677 downloadedVersion: string | null;
6778 downloadPercent: number | null;
packages/contracts/src/orchestration.ts+0−123
@@ -42,8 +42,6 @@ export const ProviderSandboxMode = Schema.Literals([ · 41 unmodified lines
4242 "danger-full-access",
4343 ]);
4444 export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
45−export const ProviderServiceTier = Schema.Literals(["fast", "flex"]);
46−export type ProviderServiceTier = typeof ProviderServiceTier.Type;
4745 export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
4846 const CodexProviderStartOptions = Schema.Struct({
4947 binaryPath: Schema.optional(TrimmedNonEmptyString),
@@ -371,7 +369,6 @@ export const ThreadTurnStartCommand = Schema.Struct({ · 321 unmodified lines
371369 }),
372370 provider: Schema.optional(ProviderKind),
373371 model: Schema.optional(TrimmedNonEmptyString),
374− serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
375372 modelOptions: Schema.optional(ProviderModelOptions),
376373 providerOptions: Schema.optional(ProviderStartOptions),
377374 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
@@ -394,7 +391,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({ · 16 unmodified lines
394391 }),
395392 provider: Schema.optional(ProviderKind),
396393 model: Schema.optional(TrimmedNonEmptyString),
397− serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
398394 modelOptions: Schema.optional(ProviderModelOptions),
399395 providerOptions: Schema.optional(ProviderStartOptions),
400396 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
@@ -675,7 +671,6 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ · 274 unmodified lines
675671 messageId: MessageId,
676672 provider: Schema.optional(ProviderKind),
677673 model: Schema.optional(TrimmedNonEmptyString),
678− serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
679674 modelOptions: Schema.optional(ProviderModelOptions),
680675 providerOptions: Schema.optional(ProviderStartOptions),
681676 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
@@ -769,20 +764,6 @@ const EventBaseFields = { · 87 unmodified lines
769764 metadata: OrchestrationEventMetadata,
770765 } as const;
771766
772−const PersistedEventBaseFields = {
773− sequence: NonNegativeInt,
774− eventId: EventId,
775− aggregateKind: OrchestrationAggregateKind,
776− streamId: Schema.Union([ProjectId, ThreadId]),
777− streamVersion: NonNegativeInt,
778− occurredAt: IsoDateTime,
779− commandId: Schema.NullOr(CommandId),
780− causationEventId: Schema.NullOr(EventId),
781− correlationId: Schema.NullOr(CommandId),
782− actorKind: OrchestrationActorKind,
783− metadata: OrchestrationEventMetadata,
784−} as const;
785−
786767 export const OrchestrationEvent = Schema.Union([
787768 Schema.Struct({
788769 ...EventBaseFields,
@@ -887,110 +868,6 @@ export const OrchestrationEvent = Schema.Union([ · 98 unmodified lines
887868 ]);
888869 export type OrchestrationEvent = typeof OrchestrationEvent.Type;
889870
890−export const OrchestrationPersistedEvent = Schema.Union([
891− Schema.Struct({
892− ...PersistedEventBaseFields,
893− eventType: Schema.Literal("project.created"),
894− payload: ProjectCreatedPayload,
895− }),
896− Schema.Struct({
897− ...PersistedEventBaseFields,
898− eventType: Schema.Literal("project.meta-updated"),
899− payload: ProjectMetaUpdatedPayload,
900− }),
901− Schema.Struct({
902− ...PersistedEventBaseFields,
903− eventType: Schema.Literal("project.deleted"),
904− payload: ProjectDeletedPayload,
905− }),
906− Schema.Struct({
907− ...PersistedEventBaseFields,
908− eventType: Schema.Literal("thread.created"),
909− payload: ThreadCreatedPayload,
910− }),
911− Schema.Struct({
912− ...PersistedEventBaseFields,
913− eventType: Schema.Literal("thread.deleted"),
914− payload: ThreadDeletedPayload,
915− }),
916− Schema.Struct({
917− ...PersistedEventBaseFields,
918− eventType: Schema.Literal("thread.meta-updated"),
919− payload: ThreadMetaUpdatedPayload,
920− }),
921− Schema.Struct({
922− ...PersistedEventBaseFields,
923− eventType: Schema.Literal("thread.runtime-mode-set"),
924− payload: ThreadRuntimeModeSetPayload,
925− }),
926− Schema.Struct({
927− ...PersistedEventBaseFields,
928− eventType: Schema.Literal("thread.interaction-mode-set"),
929− payload: ThreadInteractionModeSetPayload,
930− }),
931− Schema.Struct({
932− ...PersistedEventBaseFields,
933− eventType: Schema.Literal("thread.message-sent"),
934− payload: ThreadMessageSentPayload,
935− }),
936− Schema.Struct({
937− ...PersistedEventBaseFields,
938− eventType: Schema.Literal("thread.turn-start-requested"),
939− payload: ThreadTurnStartRequestedPayload,
940− }),
941− Schema.Struct({
942− ...PersistedEventBaseFields,
943− eventType: Schema.Literal("thread.turn-interrupt-requested"),
944− payload: ThreadTurnInterruptRequestedPayload,
945− }),
946− Schema.Struct({
947− ...PersistedEventBaseFields,
948− eventType: Schema.Literal("thread.approval-response-requested"),
949− payload: ThreadApprovalResponseRequestedPayload,
950− }),
951− Schema.Struct({
952− ...PersistedEventBaseFields,
953− eventType: Schema.Literal("thread.user-input-response-requested"),
954− payload: ThreadUserInputResponseRequestedPayload,
955− }),
956− Schema.Struct({
957− ...PersistedEventBaseFields,
958− eventType: Schema.Literal("thread.checkpoint-revert-requested"),
959− payload: ThreadCheckpointRevertRequestedPayload,
960− }),
961− Schema.Struct({
962− ...PersistedEventBaseFields,
963− eventType: Schema.Literal("thread.reverted"),
964− payload: ThreadRevertedPayload,
965− }),
966− Schema.Struct({
967− ...PersistedEventBaseFields,
968− eventType: Schema.Literal("thread.session-stop-requested"),
969− payload: ThreadSessionStopRequestedPayload,
970− }),
971− Schema.Struct({
972− ...PersistedEventBaseFields,
973− eventType: Schema.Literal("thread.session-set"),
974− payload: ThreadSessionSetPayload,
975− }),
976− Schema.Struct({
977− ...PersistedEventBaseFields,
978− eventType: Schema.Literal("thread.proposed-plan-upserted"),
979− payload: ThreadProposedPlanUpsertedPayload,
980− }),
981− Schema.Struct({
982− ...PersistedEventBaseFields,
983− eventType: Schema.Literal("thread.turn-diff-completed"),
984− payload: ThreadTurnDiffCompletedPayload,
985− }),
986− Schema.Struct({
987− ...PersistedEventBaseFields,
988− eventType: Schema.Literal("thread.activity-appended"),
989− payload: ThreadActivityAppendedPayload,
990− }),
991−]);
992−export type OrchestrationPersistedEvent = typeof OrchestrationPersistedEvent.Type;
993−
994871 export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]);
995872 export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type;
996873
packages/contracts/src/provider.ts+0−3
@@ -19,7 +19,6 @@ import { · 18 unmodified lines
1919 ProviderKind,
2020 ProviderRequestKind,
2121 ProviderSandboxMode,
22− ProviderServiceTier,
2322 ProviderUserInputAnswers,
2423 RuntimeMode,
2524 } from "./orchestration";
@@ -65,7 +64,6 @@ export const ProviderSessionStartInput = Schema.Struct({ · 39 unmodified lines
6564 model: Schema.optional(TrimmedNonEmptyStringSchema),
6665 modelOptions: Schema.optional(ProviderModelOptions),
6766 resumeCursor: Schema.optional(Schema.Unknown),
68− serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
6967 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
7068 sandboxMode: Schema.optional(ProviderSandboxMode),
7169 providerOptions: Schema.optional(ProviderStartOptions),
@@ -82,7 +80,6 @@ export const ProviderSendTurnInput = Schema.Struct({ · 10 unmodified lines
8280 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
8381 ),
8482 model: Schema.optional(TrimmedNonEmptyStringSchema),
85− serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
8683 modelOptions: Schema.optional(ProviderModelOptions),
8784 interactionMode: Schema.optional(ProviderInteractionMode),
8885 });
scripts/build-desktop-artifact.ts+6−0
@@ -580,6 +580,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 579 unmodified lines
580580 ChildProcess.make({
581581 cwd: repoRoot,
582582 ...commandOutputOptions(options.verbose),
583+ // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).
584+ shell: process.platform === "win32",
583585 })`bun run build:desktop`,
584586 );
585587 }
@@ -642,6 +644,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 56 unmodified lines
642644 ChildProcess.make({
643645 cwd: stageAppDir,
644646 ...commandOutputOptions(options.verbose),
647+ // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).
648+ shell: process.platform === "win32",
645649 })`bun install --production`,
646650 );
647651
@@ -680,6 +684,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 32 unmodified lines
680684 cwd: stageAppDir,
681685 env: buildEnv,
682686 ...commandOutputOptions(options.verbose),
687+ // Windows needs shell mode to resolve .cmd shims.
688+ shell: process.platform === "win32",
683689 })`bunx electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`,
684690 );
685691
scripts/dev-runner.ts+2−0
@@ -455,6 +455,8 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { · 454 unmodified lines
455455 stderr: "inherit",
456456 env,
457457 extendEnv: false,
458+ // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).
459+ shell: process.platform === "win32",
458460 // Keep turbo in the same process group so terminal signals (Ctrl+C)
459461 // reach it directly. Effect defaults to detached: true on non-Windows,
460462 // which would put turbo in a new group and require manual forwarding.
scripts/release-smoke.ts+113−0
@@ -0,0 +1,113 @@
1+import { execFileSync } from "node:child_process";
2+import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+import { tmpdir } from "node:os";
4+import { dirname, join, resolve } from "node:path";
5+import { fileURLToPath } from "node:url";
6+
7+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8+
9+const workspaceFiles = [
10+ "package.json",
11+ "bun.lock",
12+ "apps/server/package.json",
13+ "apps/desktop/package.json",
14+ "apps/web/package.json",
15+ "apps/marketing/package.json",
16+ "packages/contracts/package.json",
17+ "packages/shared/package.json",
18+ "scripts/package.json",
19+] as const;
20+
21+function copyWorkspaceManifestFixture(targetRoot: string): void {
22+ for (const relativePath of workspaceFiles) {
23+ const sourcePath = resolve(repoRoot, relativePath);
24+ const destinationPath = resolve(targetRoot, relativePath);
25+ mkdirSync(dirname(destinationPath), { recursive: true });
26+ cpSync(sourcePath, destinationPath);
27+ }
28+}
29+
30+function writeMacManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } {
31+ const assetDirectory = resolve(targetRoot, "release-assets");
32+ mkdirSync(assetDirectory, { recursive: true });
33+
34+ const arm64Path = resolve(assetDirectory, "latest-mac.yml");
35+ const x64Path = resolve(assetDirectory, "latest-mac-x64.yml");
36+
37+ writeFileSync(
38+ arm64Path,
39+ `version: 9.9.9-smoke.0
40+files:
41+ - url: T3-Code-9.9.9-smoke.0-arm64.zip
42+ sha512: arm64zip
43+ size: 125621344
44+ - url: T3-Code-9.9.9-smoke.0-arm64.dmg
45+ sha512: arm64dmg
46+ size: 131754935
47+path: T3-Code-9.9.9-smoke.0-arm64.zip
48+sha512: arm64zip
49+releaseDate: '2026-03-08T10:32:14.587Z'
50+`,
51+ );
52+
53+ writeFileSync(
54+ x64Path,
55+ `version: 9.9.9-smoke.0
56+files:
57+ - url: T3-Code-9.9.9-smoke.0-x64.zip
58+ sha512: x64zip
59+ size: 132000112
60+ - url: T3-Code-9.9.9-smoke.0-x64.dmg
61+ sha512: x64dmg
62+ size: 138148807
63+path: T3-Code-9.9.9-smoke.0-x64.zip
64+sha512: x64zip
65+releaseDate: '2026-03-08T10:36:07.540Z'
66+`,
67+ );
68+
69+ return { arm64Path, x64Path };
70+}
71+
72+function assertContains(haystack: string, needle: string, message: string): void {
73+ if (!haystack.includes(needle)) {
74+ throw new Error(message);
75+ }
76+}
77+
78+const tempRoot = mkdtempSync(join(tmpdir(), "t3-release-smoke-"));
79+
80+try {
81+ copyWorkspaceManifestFixture(tempRoot);
82+
83+ execFileSync(
84+ process.execPath,
85+ [resolve(repoRoot, "scripts/update-release-package-versions.ts"), "9.9.9-smoke.0", "--root", tempRoot],
86+ {
87+ cwd: repoRoot,
88+ stdio: "inherit",
89+ },
90+ );
91+
92+ execFileSync("bun", ["install", "--lockfile-only", "--ignore-scripts"], {
93+ cwd: tempRoot,
94+ stdio: "inherit",
95+ });
96+
97+ const lockfile = readFileSync(resolve(tempRoot, "bun.lock"), "utf8");
98+ assertContains(lockfile, `"version": "9.9.9-smoke.0"`, "Expected bun.lock to contain the smoke version.");
99+
100+ const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot);
101+ execFileSync(process.execPath, [resolve(repoRoot, "scripts/merge-mac-update-manifests.ts"), arm64Path, x64Path], {
102+ cwd: repoRoot,
103+ stdio: "inherit",
104+ });
105+
106+ const mergedManifest = readFileSync(arm64Path, "utf8");
107+ assertContains(mergedManifest, "T3-Code-9.9.9-smoke.0-arm64.zip", "Merged manifest is missing the arm64 asset.");
108+ assertContains(mergedManifest, "T3-Code-9.9.9-smoke.0-x64.zip", "Merged manifest is missing the x64 asset.");
109+
110+ console.log("Release smoke checks passed.");
111+} finally {
112+ rmSync(tempRoot, { recursive: true, force: true });
113+}
scripts/update-release-package-versions.ts+111−0
@@ -0,0 +1,111 @@
1+import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
2+import { resolve } from "node:path";
3+import { fileURLToPath } from "node:url";
4+
5+export const releasePackageFiles = [
6+ "apps/server/package.json",
7+ "apps/desktop/package.json",
8+ "apps/web/package.json",
9+ "packages/contracts/package.json",
10+] as const;
11+
12+interface UpdateReleasePackageVersionsOptions {
13+ readonly rootDir?: string;
14+}
15+
16+interface MutablePackageJson {
17+ version?: string;
18+ [key: string]: unknown;
19+}
20+
21+export function updateReleasePackageVersions(
22+ version: string,
23+ options: UpdateReleasePackageVersionsOptions = {},
24+): { changed: boolean } {
25+ const rootDir = resolve(options.rootDir ?? process.cwd());
26+ let changed = false;
27+
28+ for (const relativePath of releasePackageFiles) {
29+ const filePath = resolve(rootDir, relativePath);
30+ const packageJson = JSON.parse(readFileSync(filePath, "utf8")) as MutablePackageJson;
31+ if (packageJson.version === version) {
32+ continue;
33+ }
34+
35+ packageJson.version = version;
36+ writeFileSync(filePath, `${JSON.stringify(packageJson, null, 2)}\n`);
37+ changed = true;
38+ }
39+
40+ return { changed };
41+}
42+
43+function parseArgs(argv: ReadonlyArray<string>): {
44+ version: string;
45+ rootDir: string | undefined;
46+ writeGithubOutput: boolean;
47+} {
48+ let version: string | undefined;
49+ let rootDir: string | undefined;
50+ let writeGithubOutput = false;
51+
52+ for (let index = 0; index < argv.length; index += 1) {
53+ const argument = argv[index];
54+ if (argument === undefined) {
55+ continue;
56+ }
57+
58+ if (argument === "--github-output") {
59+ writeGithubOutput = true;
60+ continue;
61+ }
62+
63+ if (argument === "--root") {
64+ rootDir = argv[index + 1];
65+ if (!rootDir) {
66+ throw new Error("Missing value for --root.");
67+ }
68+ index += 1;
69+ continue;
70+ }
71+
72+ if (argument.startsWith("--")) {
73+ throw new Error(`Unknown argument: ${argument}`);
74+ }
75+
76+ if (version !== undefined) {
77+ throw new Error("Only one release version can be provided.");
78+ }
79+ version = argument;
80+ }
81+
82+ if (!version) {
83+ throw new Error(
84+ "Usage: node scripts/update-release-package-versions.ts <version> [--root <path>] [--github-output]",
85+ );
86+ }
87+
88+ return { version, rootDir, writeGithubOutput };
89+}
90+
91+const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
92+
93+if (isMain) {
94+ const { version, rootDir, writeGithubOutput } = parseArgs(process.argv.slice(2));
95+ const { changed } = updateReleasePackageVersions(
96+ version,
97+ rootDir === undefined ? {} : { rootDir },
98+ );
99+
100+ if (!changed) {
101+ console.log("All package.json versions already match release version.");
102+ }
103+
104+ if (writeGithubOutput) {
105+ const githubOutputPath = process.env.GITHUB_OUTPUT;
106+ if (!githubOutputPath) {
107+ throw new Error("GITHUB_OUTPUT is required when --github-output is set.");
108+ }
109+ appendFileSync(githubOutputPath, `changed=${changed}\n`);
110+ }
111+}