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
3This 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
19The 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
23The 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
27A 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
33The 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
37A 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
41A 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
45Orchestration 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
49The 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
53A 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].
54Examples include `thread.create`, `thread.turn.start`, and `thread.checkpoint.revert`.
55
56#### Domain Event
57
58A 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.
59Examples include `thread.created`, `thread.message-sent`, and `thread.turn-diff-completed`.
60
61#### Decider
62
63The 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
67A read-optimized view derived from events. See [projector.ts][4], [ProjectionPipeline.ts][11], and [ProjectionSnapshotQuery.ts][10].
68
69#### Projector
70
71The 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
75The 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
79A 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
83A lightweight typed runtime signal emitted when an async milestone completes. See [RuntimeReceiptBus.ts][13].
84Examples 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
92The 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
96The backend agent runtime that actually performs work. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17].
97
98#### Session
99
100The 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
104The 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
108The 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
112Controls 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
116A 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
120Checkpointing 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
124A 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
128The 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
132The 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
136The 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
140The 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.
12github:adityavardhansharma
13github:binbandit
14github:chuks-qua
15github:cursoragent
16github:gbarros-dev
17github:github-actions[bot]
18github:hwanseoc
19github:jamesx0416
20github:jasonLaster
21github:JoeEverest
22github:nmggithub
23github:Noojuno
24github:notkainoa
25github:PatrickBauer
26github:realAhmedRoach
27github:shiroyasha9
28github:Yash-Singh1
.github/pull_request_template.md+33−0
@@ -0,0 +1,33 @@
1<!--
2⚠️ READ BEFORE OPENING ⚠️
3
4We are not actively accepting contributions right now.
5
6You can still open a PR, but please do so knowing there is a high chance
7we 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
69 run: |69 run: |
70 test -f apps/desktop/dist-electron/preload.js70 test -f apps/desktop/dist-electron/preload.js
71 grep -nE "desktopBridge|getWsUrl|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js71 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 @@
1name: PR Vouch
2
3on:
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
15permissions:
16 contents: read
17 issues: write
18 pull-requests: write
19
20jobs:
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
255 needs: [preflight, build, publish_cli]255 needs: [preflight, build, publish_cli]
256 runs-on: ubuntu-24.04256 runs-on: ubuntu-24.04
257 steps:257 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
258 - name: Download all desktop artifacts268 - name: Download all desktop artifacts
259 uses: actions/download-artifact@v4269 uses: actions/download-artifact@v4
260 with:270 with:
@@ -312,33 +322,7 @@ jobs: · 51 unmodified lines
312 name: Update version strings322 name: Update version strings
313 env:323 env:
314 RELEASE_VERSION: ${{ needs.preflight.outputs.version }}324 RELEASE_VERSION: ${{ needs.preflight.outputs.version }}
315 run: |325 run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output
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 '
342 326
343 - name: Format package.json files327 - name: Format package.json files
344 if: steps.update_versions.outputs.changed == 'true'328 if: steps.update_versions.outputs.changed == 'true'
@@ -346,7 +330,7 @@ jobs: · 1 unmodified line
346 330
347 - name: Refresh lockfile331 - name: Refresh lockfile
348 if: steps.update_versions.outputs.changed == 'true'332 if: steps.update_versions.outputs.changed == 'true'
349 run: bun install333 run: bun install --lockfile-only --ignore-scripts
350 334
351 - name: Commit and push version bump335 - name: Commit and push version bump
352 if: steps.update_versions.outputs.changed == 'true'336 if: steps.update_versions.outputs.changed == 'true'
CONTRIBUTING.md+65−0
@@ -0,0 +1,65 @@
1# Contributing
2
3## Read This First
4
5We are not actively accepting contributions right now.
6
7You 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
9If 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
11PRs are automatically labeled with a `vouch:*` trust status.
12
13If 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
17Small, focused bug fixes.
18
19Small reliability fixes.
20
21Small performance improvements.
22
23Tightly scoped maintenance work that clearly improves the project without changing its direction.
24
25## What We Are Least Likely To Accept
26
27Large PRs.
28
29Drive-by feature work.
30
31Opinionated rewrites.
32
33Anything that expands product scope without us asking for it first.
34
35If 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
39Keep it small.
40
41Explain exactly what changed.
42
43Explain exactly why the change should exist.
44
45Do not mix unrelated fixes together.
46
47If the PR makes anything resembling a UI change, include clear before/after images.
48
49If the change depends on motion, timing, transitions, or interaction details, include a short video.
50
51If we have to guess what changed, we are much less likely to review it.
52
53## Issues First
54
55If you are thinking about a non-trivial change, open an issue first.
56
57That 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
61Opening a PR does not create an obligation on our side.
62
63We may close it. We may ignore it. We may ask you to shrink it. We may reimplement the idea ourselves later.
64
65If 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
21 21
22We are not accepting contributions yet.22We are not accepting contributions yet.
23 23
24## If you REALLY want to contribute still.... read this first
25
26Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.
27
24Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).28Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).
apps/desktop/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@t3tools/desktop",2 "name": "@t3tools/desktop",
3 "version": "0.0.5",3 "version": "0.0.9",
4 "private": true,4 "private": true,
5 "main": "dist-electron/main.js",5 "main": "dist-electron/main.js",
6 "scripts": {6 "scripts": {
apps/desktop/src/main.ts+110−16
@@ -31,6 +31,7 @@ import { · 30 unmodified lines
31 reduceDesktopUpdateStateOnNoUpdate,31 reduceDesktopUpdateStateOnNoUpdate,
32 reduceDesktopUpdateStateOnUpdateAvailable,32 reduceDesktopUpdateStateOnUpdateAvailable,
33} from "./updateMachine";33} from "./updateMachine";
34import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
34 35
35fixPath();36fixPath();
36 37
@@ -50,6 +51,8 @@ const ROOT_DIR = Path.resolve(__dirname, "../../.."); · 13 unmodified lines
50const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);51const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
51const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";52const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
52const APP_USER_MODEL_ID = "com.t3tools.t3code";53const APP_USER_MODEL_ID = "com.t3tools.t3code";
54const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code";
55const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
53const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;56const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;
54const COMMIT_HASH_DISPLAY_LENGTH = 12;57const COMMIT_HASH_DISPLAY_LENGTH = 12;
55const LOG_DIR = Path.join(STATE_DIR, "logs");58const LOG_DIR = Path.join(STATE_DIR, "logs");
@@ -78,7 +81,13 @@ let backendLogSink: RotatingFileSink | null = null; · 22 unmodified lines
78let restoreStdIoCapture: (() => void) | null = null;81let restoreStdIoCapture: (() => void) | null = null;
79 82
80let destructiveMenuIconCache: Electron.NativeImage | null | undefined;83let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
81const initialUpdateState = (): DesktopUpdateState => createInitialDesktopUpdateState(app.getVersion());84const desktopRuntimeInfo = resolveDesktopRuntimeInfo({
85 platform: process.platform,
86 processArch: process.arch,
87 runningUnderArm64Translation: app.runningUnderARM64Translation === true,
88});
89const initialUpdateState = (): DesktopUpdateState =>
90 createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo);
82 91
83function logTimestamp(): string {92function logTimestamp(): string {
84 return new Date().toISOString();93 return new Date().toISOString();
@@ -112,6 +121,25 @@ function formatErrorMessage(error: unknown): string { · 27 unmodified lines
112 return String(error);121 return String(error);
113}122}
114 123
124function 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
115function writeDesktopStreamChunk(143function writeDesktopStreamChunk(
116 streamName: "stdout" | "stderr",144 streamName: "stdout" | "stderr",
117 chunk: unknown,145 chunk: unknown,
@@ -574,6 +602,34 @@ function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { · 456 unmodified lines
574 return resolveResourcePath(`icon.${ext}`);602 return resolveResourcePath(`icon.${ext}`);
575}603}
576 604
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 */
617function 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
577function configureAppIdentity(): void {633function configureAppIdentity(): void {
578 app.setName(APP_DISPLAY_NAME);634 app.setName(APP_DISPLAY_NAME);
579 const commitHash = resolveAboutCommitHash();635 const commitHash = resolveAboutCommitHash();
@@ -659,6 +715,7 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed · 79 unmodified lines
659 }715 }
660 updateDownloadInFlight = true;716 updateDownloadInFlight = true;
661 setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState));717 setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState));
718 autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
662 console.info("[desktop-updater] Downloading update...");719 console.info("[desktop-updater] Downloading update...");
663 720
664 try {721 try {
@@ -697,7 +754,7 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed · 32 unmodified lines
697function configureAutoUpdater(): void {754function configureAutoUpdater(): void {
698 const enabled = shouldEnableAutoUpdates();755 const enabled = shouldEnableAutoUpdates();
699 setUpdateState({756 setUpdateState({
700 ...createInitialDesktopUpdateState(app.getVersion()),757 ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo),
701 enabled,758 enabled,
702 status: enabled ? "idle" : "disabled",759 status: enabled ? "idle" : "disabled",
703 });760 });
@@ -731,8 +788,15 @@ function configureAutoUpdater(): void { · 27 unmodified lines
731 autoUpdater.channel = DESKTOP_UPDATE_CHANNEL;788 autoUpdater.channel = DESKTOP_UPDATE_CHANNEL;
732 autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE;789 autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE;
733 autoUpdater.allowDowngrade = false;790 autoUpdater.allowDowngrade = false;
791 autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
734 let lastLoggedDownloadMilestone = -1;792 let lastLoggedDownloadMilestone = -1;
735 793
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
736 autoUpdater.on("checking-for-update", () => {800 autoUpdater.on("checking-for-update", () => {
737 console.info("[desktop-updater] Looking for updates...");801 console.info("[desktop-updater] Looking for updates...");
738 });802 });
@@ -1033,23 +1097,13 @@ function registerIpcHandlers(): void { · 294 unmodified lines
1033 1097
1034 ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL);1098 ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL);
1035 ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => {1099 ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => {
1036 if (typeof rawUrl !== "string" || rawUrl.length === 0) {1100 const externalUrl = getSafeExternalUrl(rawUrl);
1037 return false;1101 if (!externalUrl) {
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:") {
1048 return false;1102 return false;
1049 }1103 }
1050 1104
1051 try {1105 try {
1052 await shell.openExternal(parsedUrl.toString());1106 await shell.openExternal(externalUrl);
1053 return true;1107 return true;
1054 } catch {1108 } catch {
1055 return false;1109 return false;
@@ -1114,7 +1168,42 @@ function createWindow(): BrowserWindow { · 58 unmodified lines
1114 },1168 },
1115 });1169 });
1116 1170
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
1118 window.on("page-title-updated", (event) => {1207 window.on("page-title-updated", (event) => {
1119 event.preventDefault();1208 event.preventDefault();
1120 window.setTitle(APP_DISPLAY_NAME);1209 window.setTitle(APP_DISPLAY_NAME);
@@ -1143,6 +1232,11 @@ function createWindow(): BrowserWindow { · 22 unmodified lines
1143 return window;1232 return window;
1144}1233}
1145 1234
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()`.
1238app.setPath("userData", resolveUserDataPath());
1239
1146configureAppIdentity();1240configureAppIdentity();
1147 1241
1148async function bootstrap(): Promise<void> {1242async function bootstrap(): Promise<void> {
apps/desktop/src/runtimeArch.test.ts+49−0
@@ -0,0 +1,49 @@
1import { describe, expect, it } from "vitest";
2
3import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
4
5describe("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 @@
1import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts";
2
3interface ResolveDesktopRuntimeInfoInput {
4 readonly platform: NodeJS.Platform;
5 readonly processArch: string;
6 readonly runningUnderArm64Translation: boolean;
7}
8
9function normalizeDesktopArch(arch: string): DesktopRuntimeArch {
10 if (arch === "arm64") return "arm64";
11 if (arch === "x64") return "x64";
12 return "other";
13}
14
15export 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
38export 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
13 reduceDesktopUpdateStateOnUpdateAvailable,13 reduceDesktopUpdateStateOnUpdateAvailable,
14} from "./updateMachine";14} from "./updateMachine";
15 15
16const runtimeInfo = {
17 hostArch: "x64",
18 appArch: "x64",
19 runningUnderArm64Translation: false,
20} as const;
21
16describe("updateMachine", () => {22describe("updateMachine", () => {
17 it("clears transient errors when a check starts", () => {23 it("clears transient errors when a check starts", () => {
18 const state = reduceDesktopUpdateStateOnCheckStart(24 const state = reduceDesktopUpdateStateOnCheckStart(
19 {25 {
20 ...createInitialDesktopUpdateState("1.0.0"),26 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
21 enabled: true,27 enabled: true,
22 status: "error",28 status: "error",
23 message: "network",29 message: "network",
@@ -36,7 +42,7 @@ describe("updateMachine", () => { · 12 unmodified lines
36 it("records a check failure without exposing an action", () => {42 it("records a check failure without exposing an action", () => {
37 const state = reduceDesktopUpdateStateOnCheckFailure(43 const state = reduceDesktopUpdateStateOnCheckFailure(
38 {44 {
39 ...createInitialDesktopUpdateState("1.0.0"),45 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
40 enabled: true,46 enabled: true,
41 status: "checking",47 status: "checking",
42 },48 },
@@ -52,7 +58,7 @@ describe("updateMachine", () => { · 9 unmodified lines
52 it("preserves available version on download failure for retry", () => {58 it("preserves available version on download failure for retry", () => {
53 const state = reduceDesktopUpdateStateOnDownloadFailure(59 const state = reduceDesktopUpdateStateOnDownloadFailure(
54 {60 {
55 ...createInitialDesktopUpdateState("1.0.0"),61 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
56 enabled: true,62 enabled: true,
57 status: "downloading",63 status: "downloading",
58 availableVersion: "1.1.0",64 availableVersion: "1.1.0",
@@ -70,7 +76,7 @@ describe("updateMachine", () => { · 11 unmodified lines
70 it("transitions to downloaded and then preserves install retry state", () => {76 it("transitions to downloaded and then preserves install retry state", () => {
71 const downloaded = reduceDesktopUpdateStateOnDownloadComplete(77 const downloaded = reduceDesktopUpdateStateOnDownloadComplete(
72 {78 {
73 ...createInitialDesktopUpdateState("1.0.0"),79 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
74 enabled: true,80 enabled: true,
75 status: "downloading",81 status: "downloading",
76 availableVersion: "1.1.0",82 availableVersion: "1.1.0",
@@ -89,7 +95,7 @@ describe("updateMachine", () => { · 12 unmodified lines
89 it("clears stale download state when no update is available", () => {95 it("clears stale download state when no update is available", () => {
90 const state = reduceDesktopUpdateStateOnNoUpdate(96 const state = reduceDesktopUpdateStateOnNoUpdate(
91 {97 {
92 ...createInitialDesktopUpdateState("1.0.0"),98 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
93 enabled: true,99 enabled: true,
94 status: "error",100 status: "error",
95 availableVersion: "1.1.0",101 availableVersion: "1.1.0",
@@ -111,7 +117,7 @@ describe("updateMachine", () => { · 15 unmodified lines
111 it("tracks available, download start, and progress cleanly", () => {117 it("tracks available, download start, and progress cleanly", () => {
112 const available = reduceDesktopUpdateStateOnUpdateAvailable(118 const available = reduceDesktopUpdateStateOnUpdateAvailable(
113 {119 {
114 ...createInitialDesktopUpdateState("1.0.0"),120 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
115 enabled: true,121 enabled: true,
116 status: "checking",122 status: "checking",
117 },123 },
apps/desktop/src/updateMachine.ts+8−3
@@ -1,12 +1,18 @@
1import type { DesktopUpdateState } from "@t3tools/contracts";1import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts";
2 2
3import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState";3import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState";
4 4
5export function createInitialDesktopUpdateState(currentVersion: string): DesktopUpdateState {5export function createInitialDesktopUpdateState(
6 currentVersion: string,
7 runtimeInfo: DesktopRuntimeInfo,
8): DesktopUpdateState {
6 return {9 return {
7 enabled: false,10 enabled: false,
8 status: "disabled",11 status: "disabled",
9 currentVersion,12 currentVersion,
13 hostArch: runtimeInfo.hostArch,
14 appArch: runtimeInfo.appArch,
15 runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation,
10 availableVersion: null,16 availableVersion: null,
11 downloadedVersion: null,17 downloadedVersion: null,
12 downloadPercent: null,18 downloadPercent: null,
@@ -152,4 +158,3 @@ export function reduceDesktopUpdateStateOnInstallFailure( · 139 unmodified lines
152 canRetry: true,158 canRetry: true,
153 };159 };
154}160}
155
apps/desktop/src/updateState.test.ts+3−0
@@ -12,6 +12,9 @@ const baseState: DesktopUpdateState = { · 11 unmodified lines
12 enabled: true,12 enabled: true,
13 status: "idle",13 status: "idle",
14 currentVersion: "1.0.0",14 currentVersion: "1.0.0",
15 hostArch: "x64",
16 appArch: "x64",
17 runningUnderArm64Translation: false,
15 availableVersion: null,18 availableVersion: null,
16 downloadedVersion: null,19 downloadedVersion: null,
17 downloadPercent: null,20 downloadPercent: null,
apps/marketing/src/pages/index.astro+1−3
@@ -77,12 +77,10 @@ · 76 unmodified lines
77 const ua = navigator.userAgent;77 const ua = navigator.userAgent;
78 if (/Win/i.test(ua)) return { os: "win", label: "Download for Windows" };78 if (/Win/i.test(ua)) return { os: "win", label: "Download for Windows" };
79 if (/Mac/i.test(ua)) {79 if (/Mac/i.test(ua)) {
80 const isArm =
81 /arm|aarch/i.test(navigator.platform ?? "") || ua.includes("ARM");
82 return {80 return {
83 os: "mac",81 os: "mac",
84 label: `Download for macOS`,82 label: `Download for macOS`,
85 arch: isArm ? "arm64" : "x64",83 arch: "arm64",
86 };84 };
87 }85 }
88 if (/Linux/i.test(ua)) return { os: "linux", label: "Download for Linux" };86 if (/Linux/i.test(ua)) return { os: "linux", label: "Download for Linux" };
apps/server/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "t3",2 "name": "t3",
3 "version": "0.0.5",3 "version": "0.0.9",
4 "repository": {4 "repository": {
5 "type": "git",5 "type": "git",
6 "url": "https://github.com/pingdotgg/t3code",6 "url": "https://github.com/pingdotgg/t3code",
apps/server/scripts/cli.ts+2−0
@@ -132,6 +132,7 @@ const buildCmd = Command.make( · 131 unmodified lines
132 cwd: serverDir,132 cwd: serverDir,
133 stdout: config.verbose ? "inherit" : "ignore",133 stdout: config.verbose ? "inherit" : "ignore",
134 stderr: "inherit",134 stderr: "inherit",
135 // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).
135 shell: process.platform === "win32",136 shell: process.platform === "win32",
136 })`bun tsdown`,137 })`bun tsdown`,
137 );138 );
@@ -226,6 +227,7 @@ const publishCmd = Command.make( · 88 unmodified lines
226 cwd: serverDir,227 cwd: serverDir,
227 stdout: config.verbose ? "inherit" : "ignore",228 stdout: config.verbose ? "inherit" : "ignore",
228 stderr: "inherit",229 stderr: "inherit",
230 // Windows needs shell mode to resolve .cmd shims.
229 shell: process.platform === "win32",231 shell: process.platform === "win32",
230 }),232 }),
231 );233 );
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+0−9
@@ -5,7 +5,6 @@ import { · 4 unmodified lines
5 type OrchestrationEvent,5 type OrchestrationEvent,
6 type ProviderModelOptions,6 type ProviderModelOptions,
7 type ProviderKind,7 type ProviderKind,
8 type ProviderServiceTier,
9 type ProviderStartOptions,8 type ProviderStartOptions,
10 type OrchestrationSession,9 type OrchestrationSession,
11 ThreadId,10 ThreadId,
@@ -206,7 +205,6 @@ const make = Effect.gen(function* () { · 194 unmodified lines
206 readonly provider?: ProviderKind;205 readonly provider?: ProviderKind;
207 readonly model?: string;206 readonly model?: string;
208 readonly modelOptions?: ProviderModelOptions;207 readonly modelOptions?: ProviderModelOptions;
209 readonly serviceTier?: ProviderServiceTier | null;
210 readonly providerOptions?: ProviderStartOptions;208 readonly providerOptions?: ProviderStartOptions;
211 },209 },
212 ) {210 ) {
@@ -242,7 +240,6 @@ const make = Effect.gen(function* () { · 29 unmodified lines
242 : {}),240 : {}),
243 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),241 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
244 ...(desiredModel ? { model: desiredModel } : {}),242 ...(desiredModel ? { model: desiredModel } : {}),
245 ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}),
246 ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}),243 ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}),
247 ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}),244 ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}),
248 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),245 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
@@ -328,7 +325,6 @@ const make = Effect.gen(function* () { · 79 unmodified lines
328 readonly attachments?: ReadonlyArray<ChatAttachment>;325 readonly attachments?: ReadonlyArray<ChatAttachment>;
329 readonly provider?: ProviderKind;326 readonly provider?: ProviderKind;
330 readonly model?: string;327 readonly model?: string;
331 readonly serviceTier?: ProviderServiceTier | null;
332 readonly modelOptions?: ProviderModelOptions;328 readonly modelOptions?: ProviderModelOptions;
333 readonly providerOptions?: ProviderStartOptions;329 readonly providerOptions?: ProviderStartOptions;
334 readonly interactionMode?: "default" | "plan";330 readonly interactionMode?: "default" | "plan";
@@ -344,7 +340,6 @@ const make = Effect.gen(function* () { · 9 unmodified lines
344 yield* ensureSessionForThread(input.threadId, input.createdAt, {340 yield* ensureSessionForThread(input.threadId, input.createdAt, {
345 ...(input.provider !== undefined ? { provider: input.provider } : {}),341 ...(input.provider !== undefined ? { provider: input.provider } : {}),
346 ...(input.model !== undefined ? { model: input.model } : {}),342 ...(input.model !== undefined ? { model: input.model } : {}),
347 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
348 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),343 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
349 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),344 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
350 });345 });
@@ -366,7 +361,6 @@ const make = Effect.gen(function* () { · 15 unmodified lines
366 ...(normalizedInput ? { input: normalizedInput } : {}),361 ...(normalizedInput ? { input: normalizedInput } : {}),
367 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),362 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
368 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),363 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
369 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
370 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),364 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
371 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),365 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
372 });366 });
@@ -481,9 +475,6 @@ const make = Effect.gen(function* () { · 108 unmodified lines
481 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),475 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
482 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),476 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),
483 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),477 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
484 ...(event.payload.serviceTier !== undefined
485 ? { serviceTier: event.payload.serviceTier }
486 : {}),
487 ...(event.payload.modelOptions !== undefined478 ...(event.payload.modelOptions !== undefined
488 ? { modelOptions: event.payload.modelOptions }479 ? { modelOptions: event.payload.modelOptions }
489 : {}),480 : {}),
apps/server/src/orchestration/decider.ts+0−1
@@ -301,7 +301,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 300 unmodified lines
301 messageId: command.message.messageId,301 messageId: command.message.messageId,
302 ...(command.provider !== undefined ? { provider: command.provider } : {}),302 ...(command.provider !== undefined ? { provider: command.provider } : {}),
303 ...(command.model !== undefined ? { model: command.model } : {}),303 ...(command.model !== undefined ? { model: command.model } : {}),
304 ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}),
305 ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}),304 ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}),
306 ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}),305 ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}),
307 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,306 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
1354 threadId: input.threadId,1354 threadId: input.threadId,
1355 ...(input.input !== undefined ? { input: input.input } : {}),1355 ...(input.input !== undefined ? { input: input.input } : {}),
1356 ...(input.model !== undefined ? { model: input.model } : {}),1356 ...(input.model !== undefined ? { model: input.model } : {}),
1357 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
1358 ...(input.modelOptions?.codex?.reasoningEffort !== undefined1357 ...(input.modelOptions?.codex?.reasoningEffort !== undefined
1359 ? { effort: input.modelOptions.codex.reasoningEffort }1358 ? { effort: input.modelOptions.codex.reasoningEffort }
1360 : {}),1359 : {}),
apps/web/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@t3tools/web",2 "name": "@t3tools/web",
3 "version": "0.0.5",3 "version": "0.0.9",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
apps/web/src/appSettings.test.ts+0−21
@@ -4,8 +4,6 @@ import { · 3 unmodified lines
4 getAppModelOptions,4 getAppModelOptions,
5 getSlashModelOptions,5 getSlashModelOptions,
6 normalizeCustomModelSlugs,6 normalizeCustomModelSlugs,
7 resolveAppServiceTier,
8 shouldShowFastTierIcon,
9 resolveAppModelSelection,7 resolveAppModelSelection,
10} from "./appSettings";8} from "./appSettings";
11 9
@@ -84,22 +82,3 @@ describe("getSlashModelOptions", () => { · 72 unmodified lines
84 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);82 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
85 });83 });
86});84});
87
88describe("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
99describe("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 @@
1import { useCallback, useSyncExternalStore } from "react";1import { useCallback, useSyncExternalStore } from "react";
2import { Option, Schema } from "effect";2import { Option, Schema } from "effect";
3import { type ProviderKind, type ProviderServiceTier } from "@t3tools/contracts";3import { type ProviderKind } from "@t3tools/contracts";
4import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";4import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
5 5
6const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";6const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
7const MAX_CUSTOM_MODEL_COUNT = 32;7const MAX_CUSTOM_MODEL_COUNT = 32;
8export const MAX_CUSTOM_MODEL_LENGTH = 256;8export const MAX_CUSTOM_MODEL_LENGTH = 256;
9export 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;
26export type AppServiceTier = (typeof APP_SERVICE_TIER_OPTIONS)[number]["value"];
27const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]);
28const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]);
29const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {9const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
30 codex: new Set(getModelOptions("codex").map((option) => option.slug)),10 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
31};11};
@@ -41,7 +21,6 @@ const AppSettingsSchema = Schema.Struct({ · 9 unmodified lines
41 enableAssistantStreaming: Schema.Boolean.pipe(21 enableAssistantStreaming: Schema.Boolean.pipe(
42 Schema.withConstructorDefault(() => Option.some(false)),22 Schema.withConstructorDefault(() => Option.some(false)),
43 ),23 ),
44 codexServiceTier: AppServiceTierSchema.pipe(Schema.withConstructorDefault(() => Option.some("auto"))),
45 customCodexModels: Schema.Array(Schema.String).pipe(24 customCodexModels: Schema.Array(Schema.String).pipe(
46 Schema.withConstructorDefault(() => Option.some([])),25 Schema.withConstructorDefault(() => Option.some([])),
47 ),26 ),
@@ -53,22 +32,6 @@ export interface AppModelOption { · 5 unmodified lines
53 isCustom: boolean;32 isCustom: boolean;
54}33}
55 34
56export function resolveAppServiceTier(serviceTier: AppServiceTier): ProviderServiceTier | null {
57 return serviceTier === "auto" ? null : serviceTier;
58}
59
60export 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
72const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});35const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
73 36
74let listeners: Array<() => void> = [];37let listeners: Array<() => void> = [];
apps/web/src/components/ChatView.tsx+270−112
@@ -92,6 +92,8 @@ import { · 91 unmodified lines
92 buildPlanImplementationThreadTitle,92 buildPlanImplementationThreadTitle,
93 buildPlanImplementationPrompt,93 buildPlanImplementationPrompt,
94 buildProposedPlanMarkdownFilename,94 buildProposedPlanMarkdownFilename,
95 downloadPlanAsTextFile,
96 normalizePlanMarkdownForExport,
95 proposedPlanTitle,97 proposedPlanTitle,
96 resolvePlanFollowUpSubmission,98 resolvePlanFollowUpSubmission,
97} from "../proposedPlan";99} from "../proposedPlan";
@@ -122,8 +124,9 @@ import { · 24 unmodified lines
122 shortcutLabelForCommand,124 shortcutLabelForCommand,
123} from "../keybindings";125} from "../keybindings";
124import ChatMarkdown from "./ChatMarkdown";126import ChatMarkdown from "./ChatMarkdown";
127import PlanSidebar from "./PlanSidebar";
125import ThreadTerminalDrawer from "./ThreadTerminalDrawer";128import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
126import { Alert, AlertDescription, AlertTitle } from "./ui/alert";129import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
127import {130import {
128 BotIcon,131 BotIcon,
129 ChevronDownIcon,132 ChevronDownIcon,
@@ -135,13 +138,13 @@ import { · 5 unmodified lines
135 DiffIcon,138 DiffIcon,
136 EllipsisIcon,139 EllipsisIcon,
137 FolderClosedIcon,140 FolderClosedIcon,
141 ListTodoIcon,
138 LockIcon,142 LockIcon,
139 LockOpenIcon,143 LockOpenIcon,
140 Undo2Icon,144 Undo2Icon,
141 XIcon,145 XIcon,
142 CopyIcon,146 CopyIcon,
143 CheckIcon,147 CheckIcon,
144 ZapIcon,
145} from "lucide-react";148} from "lucide-react";
146import { Button } from "./ui/button";149import { Button } from "./ui/button";
147import { Input } from "./ui/input";150import { Input } from "./ui/input";
@@ -201,9 +204,6 @@ import { readNativeApi } from "~/nativeApi"; · 53 unmodified lines
201import {204import {
202 getAppModelOptions,205 getAppModelOptions,
203 resolveAppModelSelection,206 resolveAppModelSelection,
204 resolveAppServiceTier,
205 shouldShowFastTierIcon,
206 type AppServiceTier,
207 useAppSettings,207 useAppSettings,
208} from "../appSettings";208} from "../appSettings";
209import {209import {
@@ -291,21 +291,7 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { · 81 unmodified lines
291 return "text-muted-foreground/40";291 return "text-muted-foreground/40";
292}292}
293 293
294function normalizePlanMarkdownForExport(planMarkdown: string): string {
295 return `${planMarkdown.trimEnd()}\n`;
296}
297 294
298function 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}
309 295
310interface ExpandedImageItem {296interface ExpandedImageItem {
311 src: string;297 src: string;
@@ -420,7 +406,6 @@ type ComposerCommandItem = · 108 unmodified lines
420 model: ModelSlug;406 model: ModelSlug;
421 label: string;407 label: string;
422 description: string;408 description: string;
423 showFastBadge: boolean;
424 };409 };
425 410
426type SendPhase = "idle" | "preparing-worktree" | "sending-turn";411type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
@@ -531,9 +516,6 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { · 104 unmodified lines
531 </Badge>516 </Badge>
532 ) : null}517 ) : null}
533 <span className="flex min-w-0 items-center gap-1.5 truncate">518 <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}
537 <span className="truncate">{props.item.label}</span>519 <span className="truncate">{props.item.label}</span>
538 </span>520 </span>
539 <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span>521 <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
656 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =638 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
657 useState<Record<string, number>>({});639 useState<Record<string, number>>({});
658 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});640 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);
659 const [nowTick, setNowTick] = useState(() => Date.now());647 const [nowTick, setNowTick] = useState(() => Date.now());
660 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);648 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
661 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);649 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
@@ -757,11 +745,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 95 unmodified lines
757 composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;745 composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
758 const isServerThread = serverThread !== undefined;746 const isServerThread = serverThread !== undefined;
759 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;747 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
760 const diffSearch = useMemo(748 const diffOpen = rawSearch.diff === "1";
761 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
762 [rawSearch],
763 );
764 const diffOpen = diffSearch.diff === "1";
765 const activeThreadId = activeThread?.id ?? null;749 const activeThreadId = activeThread?.id ?? null;
766 const activeLatestTurn = activeThread?.latestTurn ?? null;750 const activeLatestTurn = activeThread?.latestTurn ?? null;
767 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);751 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
@@ -793,8 +777,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 25 unmodified lines
793 activeThread.messages.length > 0 ||777 activeThread.messages.length > 0 ||
794 activeThread.session !== null),778 activeThread.session !== null),
795 );779 );
796 const selectedServiceTierSetting = settings.codexServiceTier;
797 const selectedServiceTier = resolveAppServiceTier(selectedServiceTierSetting);
798 const lockedProvider: ProviderKind | null = hasThreadStarted780 const lockedProvider: ProviderKind | null = hasThreadStarted
799 ? (sessionProvider ?? selectedProviderByThreadId ?? null)781 ? (sessionProvider ?? selectedProviderByThreadId ?? null)
800 : null;782 : null;
@@ -1262,10 +1244,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 461 unmodified lines
1262 model: slug,1244 model: slug,
1263 label: name,1245 label: name,
1264 description: `${providerLabel} · ${slug}`,1246 description: `${providerLabel} · ${slug}`,
1265 showFastBadge:
1266 provider === "codex" && shouldShowFastTierIcon(slug, selectedServiceTierSetting),
1267 }));1247 }));
1268 }, [composerTrigger, searchableModelOptions, selectedServiceTierSetting, workspaceEntries]);1248 }, [composerTrigger, searchableModelOptions, workspaceEntries]);
1269 const composerMenuOpen = Boolean(composerTrigger);1249 const composerMenuOpen = Boolean(composerTrigger);
1270 const activeComposerMenuItem = useMemo(1250 const activeComposerMenuItem = useMemo(
1271 () =>1251 () =>
@@ -1621,6 +1601,36 @@ export default function ChatView({ threadId }: ChatViewProps) { · 349 unmodified lines
1621 },1601 },
1622 [activeProject, persistProjectScripts],1602 [activeProject, persistProjectScripts],
1623 );1603 );
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 );
1624 1634
1625 const handleRuntimeModeChange = useCallback(1635 const handleRuntimeModeChange = useCallback(
1626 (mode: RuntimeMode) => {1636 (mode: RuntimeMode) => {
@@ -1763,6 +1773,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 136 unmodified lines
1763 "button, summary, [role='button'], [data-scroll-anchor-target]",1773 "button, summary, [role='button'], [data-scroll-anchor-target]",
1764 );1774 );
1765 if (!trigger || !scrollContainer.contains(trigger)) return;1775 if (!trigger || !scrollContainer.contains(trigger)) return;
1776 if (trigger.closest("[data-scroll-anchor-ignore]")) return;
1766 1777
1767 pendingInteractionAnchorRef.current = {1778 pendingInteractionAnchorRef.current = {
1768 element: trigger,1779 element: trigger,
@@ -1911,8 +1922,17 @@ export default function ChatView({ threadId }: ChatViewProps) { · 142 unmodified lines
1911 1922
1912 useEffect(() => {1923 useEffect(() => {
1913 setExpandedWorkGroups({});1924 setExpandedWorkGroups({});
1925 if (planSidebarOpenOnNextThreadRef.current) {
1926 planSidebarOpenOnNextThreadRef.current = false;
1927 setPlanSidebarOpen(true);
1928 } else {
1929 setPlanSidebarOpen(false);
1930 }
1931 planSidebarDismissedForTurnRef.current = null;
1914 }, [activeThread?.id]);1932 }, [activeThread?.id]);
1915 1933
1934
1935
1916 useEffect(() => {1936 useEffect(() => {
1917 if (!composerMenuOpen) {1937 if (!composerMenuOpen) {
1918 setComposerHighlightedItemId(null);1938 setComposerHighlightedItemId(null);
@@ -2633,7 +2653,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 714 unmodified lines
2633 attachments: turnAttachments,2653 attachments: turnAttachments,
2634 },2654 },
2635 model: selectedModel || undefined,2655 model: selectedModel || undefined,
2636 serviceTier: selectedServiceTier,
2637 ...(selectedModelOptionsForDispatch2656 ...(selectedModelOptionsForDispatch
2638 ? { modelOptions: selectedModelOptionsForDispatch }2657 ? { modelOptions: selectedModelOptionsForDispatch }
2639 : {}),2658 : {}),
@@ -2925,6 +2944,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 285 unmodified lines
2925 interactionMode: nextInteractionMode,2944 interactionMode: nextInteractionMode,
2926 createdAt: messageCreatedAt,2945 createdAt: messageCreatedAt,
2927 });2946 });
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 }
2928 sendInFlightRef.current = false;2954 sendInFlightRef.current = false;
2929 } catch (err) {2955 } catch (err) {
2930 setOptimisticUserMessages((existing) =>2956 setOptimisticUserMessages((existing) =>
@@ -3033,6 +3059,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 102 unmodified lines
3033 .then(() => api.orchestration.getSnapshot())3059 .then(() => api.orchestration.getSnapshot())
3034 .then((snapshot) => {3060 .then((snapshot) => {
3035 syncServerReadModel(snapshot);3061 syncServerReadModel(snapshot);
3062 // Signal that the plan sidebar should open on the new thread.
3063 planSidebarOpenOnNextThreadRef.current = true;
3036 return navigate({3064 return navigate({
3037 to: "/$threadId",3065 to: "/$threadId",
3038 params: { threadId: nextThreadId },3066 params: { threadId: nextThreadId },
@@ -3428,14 +3456,21 @@ export default function ChatView({ threadId }: ChatViewProps) { · 389 unmodified lines
3428 }}3456 }}
3429 onAddProjectScript={saveProjectScript}3457 onAddProjectScript={saveProjectScript}
3430 onUpdateProjectScript={updateProjectScript}3458 onUpdateProjectScript={updateProjectScript}
3459 onDeleteProjectScript={deleteProjectScript}
3431 onToggleDiff={onToggleDiff}3460 onToggleDiff={onToggleDiff}
3432 />3461 />
3433 </header>3462 </header>
3434 3463
3435 {/* Error banner */}3464 {/* Error banner */}
3436 <ProviderHealthBanner status={activeProviderStatus} />3465 <ProviderHealthBanner status={activeProviderStatus} />
3437 <ThreadErrorBanner error={activeThread.error} />3466 <ThreadErrorBanner
3438 <PlanModePanel activePlan={activePlan} />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">
3439 3474
3440 {/* Messages */}3475 {/* Messages */}
3441 <div3476 <div
@@ -3509,6 +3544,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 67 unmodified lines
3509 answers={activePendingDraftAnswers}3544 answers={activePendingDraftAnswers}
3510 questionIndex={activePendingQuestionIndex}3545 questionIndex={activePendingQuestionIndex}
3511 onSelectOption={onSelectActivePendingUserInputOption}3546 onSelectOption={onSelectActivePendingUserInputOption}
3547 onAdvance={onAdvanceActivePendingUserInput}
3512 />3548 />
3513 </div>3549 </div>
3514 ) : showPlanFollowUpPrompt && activeProposedPlan ? (3550 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
@@ -3651,7 +3687,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 136 unmodified lines
3651 model={selectedModelForPickerWithCustomFallback}3687 model={selectedModelForPickerWithCustomFallback}
3652 lockedProvider={lockedProvider}3688 lockedProvider={lockedProvider}
3653 modelOptionsByProvider={modelOptionsByProvider}3689 modelOptionsByProvider={modelOptionsByProvider}
3654 serviceTierSetting={selectedServiceTierSetting}
3655 onProviderModelChange={onProviderModelSelect}3690 onProviderModelChange={onProviderModelSelect}
3656 />3691 />
3657 3692
@@ -3715,6 +3750,43 @@ export default function ChatView({ threadId }: ChatViewProps) { · 57 unmodified lines
3715 {runtimeMode === "full-access" ? "Full access" : "Supervised"}3750 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
3716 </span>3751 </span>
3717 </Button>3752 </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}
3718 </div>3790 </div>
3719 3791
3720 {/* Right side: send / stop button */}3792 {/* Right side: send / stop button */}
@@ -3881,6 +3953,27 @@ export default function ChatView({ threadId }: ChatViewProps) { · 160 unmodified lines
3881 </form>3953 </form>
3882 </div>3954 </div>
3883 3955
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
3884 {isGitRepo && (3977 {isGitRepo && (
3885 <BranchToolbar3978 <BranchToolbar
3886 threadId={activeThread.id}3979 threadId={activeThread.id}
@@ -4005,6 +4098,7 @@ interface ChatHeaderProps { · 118 unmodified lines
4005 onRunProjectScript: (script: ProjectScript) => void;4098 onRunProjectScript: (script: ProjectScript) => void;
4006 onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;4099 onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;
4007 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;4100 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;
4101 onDeleteProjectScript: (scriptId: string) => Promise<void>;
4008 onToggleDiff: () => void;4102 onToggleDiff: () => void;
4009}4103}
4010 4104
@@ -4024,6 +4118,7 @@ const ChatHeader = memo(function ChatHeader({ · 13 unmodified lines
4024 onRunProjectScript,4118 onRunProjectScript,
4025 onAddProjectScript,4119 onAddProjectScript,
4026 onUpdateProjectScript,4120 onUpdateProjectScript,
4121 onDeleteProjectScript,
4027 onToggleDiff,4122 onToggleDiff,
4028}: ChatHeaderProps) {4123}: ChatHeaderProps) {
4029 return (4124 return (
@@ -4056,6 +4151,7 @@ const ChatHeader = memo(function ChatHeader({ · 26 unmodified lines
4056 onRunScript={onRunProjectScript}4151 onRunScript={onRunProjectScript}
4057 onAddScript={onAddProjectScript}4152 onAddScript={onAddProjectScript}
4058 onUpdateScript={onUpdateProjectScript}4153 onUpdateScript={onUpdateProjectScript}
4154 onDeleteScript={onDeleteProjectScript}
4059 />4155 />
4060 )}4156 )}
4061 {activeProjectName && (4157 {activeProjectName && (
@@ -4095,7 +4191,13 @@ const ChatHeader = memo(function ChatHeader({ · 33 unmodified lines
4095 );4191 );
4096});4192});
4097 4193
4098const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: string | null }) {4194const ThreadErrorBanner = memo(function ThreadErrorBanner({
4195 error,
4196 onDismiss,
4197}: {
4198 error: string | null;
4199 onDismiss?: () => void;
4200}) {
4099 if (!error) return null;4201 if (!error) return null;
4100 return (4202 return (
4101 <div className="pt-3 mx-auto max-w-3xl">4203 <div className="pt-3 mx-auto max-w-3xl">
@@ -4104,6 +4206,18 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st · 2 unmodified lines
4104 <AlertDescription className="line-clamp-3" title={error}>4206 <AlertDescription className="line-clamp-3" title={error}>
4105 {error}4207 {error}
4106 </AlertDescription>4208 </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 )}
4107 </Alert>4221 </Alert>
4108 </div>4222 </div>
4109 );4223 );
@@ -4219,61 +4333,13 @@ const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActi · 109 unmodified lines
4219 );4333 );
4220});4334});
4221 4335
4222interface PlanModePanelProps {
4223 activePlan: ReturnType<typeof deriveActivePlanState>;
4224}
4225
4226const 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
4271interface PendingUserInputPanelProps {4336interface PendingUserInputPanelProps {
4272 pendingUserInputs: PendingUserInput[];4337 pendingUserInputs: PendingUserInput[];
4273 respondingRequestIds: ApprovalRequestId[];4338 respondingRequestIds: ApprovalRequestId[];
4274 answers: Record<string, PendingUserInputDraftAnswer>;4339 answers: Record<string, PendingUserInputDraftAnswer>;
4275 questionIndex: number;4340 questionIndex: number;
4276 onSelectOption: (questionId: string, optionLabel: string) => void;4341 onSelectOption: (questionId: string, optionLabel: string) => void;
4342 onAdvance: () => void;
4277}4343}
4278 4344
4279const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({4345const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
@@ -4282,6 +4348,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane · 2 unmodified lines
4282 answers,4348 answers,
4283 questionIndex,4349 questionIndex,
4284 onSelectOption,4350 onSelectOption,
4351 onAdvance,
4285}: PendingUserInputPanelProps) {4352}: PendingUserInputPanelProps) {
4286 if (pendingUserInputs.length === 0) return null;4353 if (pendingUserInputs.length === 0) return null;
4287 const activePrompt = pendingUserInputs[0];4354 const activePrompt = pendingUserInputs[0];
@@ -4295,6 +4362,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane · 7 unmodified lines
4295 answers={answers}4362 answers={answers}
4296 questionIndex={questionIndex}4363 questionIndex={questionIndex}
4297 onSelectOption={onSelectOption}4364 onSelectOption={onSelectOption}
4365 onAdvance={onAdvance}
4298 />4366 />
4299 );4367 );
4300});4368});
@@ -4305,42 +4373,135 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 4 unmodified lines
4305 answers,4373 answers,
4306 questionIndex,4374 questionIndex,
4307 onSelectOption,4375 onSelectOption,
4376 onAdvance,
4308}: {4377}: {
4309 prompt: PendingUserInput;4378 prompt: PendingUserInput;
4310 isResponding: boolean;4379 isResponding: boolean;
4311 answers: Record<string, PendingUserInputDraftAnswer>;4380 answers: Record<string, PendingUserInputDraftAnswer>;
4312 questionIndex: number;4381 questionIndex: number;
4313 onSelectOption: (questionId: string, optionLabel: string) => void;4382 onSelectOption: (questionId: string, optionLabel: string) => void;
4383 onAdvance: () => void;
4314}) {4384}) {
4315 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);4385 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
4316 const activeQuestion = progress.activeQuestion;4386 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]);
4317 4445
4318 if (!activeQuestion) {4446 if (!activeQuestion) {
4319 return null;4447 return null;
4320 }4448 }
4321 4449
4322 return (4450 return (
4323 <div className="px-4 py-4 sm:px-5">4451 <div className="px-4 py-3 sm:px-5">
4324 <div className="flex gap-2">4452 <div className="flex items-center gap-3">
4325 <span className="uppercase text-sm tracking-[0.2em]">4453 <div className="flex items-center gap-2">
4326 {questionIndex + 1}/{prompt.questions.length} {activeQuestion.header}4454 {prompt.questions.length > 1 ? (
4327 </span>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">
4328 <div className="text-sm font-medium">{activeQuestion.question}</div>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>
4329 </div>4463 </div>
4330 <div className="mt-3 flex flex-wrap gap-2">4464 <p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p>
4331 {activeQuestion.options.map((option) => {4465 <div className="mt-3 space-y-1">
4466 {activeQuestion.options.map((option, index) => {
4332 const isSelected = progress.selectedOptionLabel === option.label;4467 const isSelected = progress.selectedOptionLabel === option.label;
4468 const shortcutKey = index < 9 ? index + 1 : null;
4333 return (4469 return (
4334 <Button4470 <button
4335 key={`${activeQuestion.id}:${option.label}`}4471 key={`${activeQuestion.id}:${option.label}`}
4336 size="sm"4472 type="button"
4337 variant={isSelected ? "default" : "outline"}
4338 disabled={isResponding}4473 disabled={isResponding}
4339 onClick={() => onSelectOption(activeQuestion.id, option.label)}4474 onClick={() => selectOptionAndAutoAdvance(activeQuestion.id, option.label)}
4340 title={option.description}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 )}
4341 >4482 >
4342 {option.label}4483 {shortcutKey !== null ? (
4343 </Button>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>
4344 );4505 );
4345 })}4506 })}
4346 </div>4507 </div>
@@ -4554,7 +4715,7 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ · 207 unmodified lines
4554 const saveContents = normalizePlanMarkdownForExport(planMarkdown);4715 const saveContents = normalizePlanMarkdownForExport(planMarkdown);
4555 4716
4556 const handleDownload = () => {4717 const handleDownload = () => {
4557 downloadTextFile(downloadFilename, saveContents);4718 downloadPlanAsTextFile(downloadFilename, saveContents);
4558 };4719 };
4559 4720
4560 const openSaveDialog = () => {4721 const openSaveDialog = () => {
@@ -4646,7 +4807,12 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ · 85 unmodified lines
4646 </div>4807 </div>
4647 {canCollapse ? (4808 {canCollapse ? (
4648 <div className="mt-4 flex justify-center">4809 <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 >
4650 {expanded ? "Collapse plan" : "Expand plan"}4816 {expanded ? "Collapse plan" : "Expand plan"}
4651 </Button>4817 </Button>
4652 </div>4818 </div>
@@ -5368,7 +5534,6 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 715 unmodified lines
5368 model: ModelSlug;5534 model: ModelSlug;
5369 lockedProvider: ProviderKind | null;5535 lockedProvider: ProviderKind | null;
5370 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;5536 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
5371 serviceTierSetting: AppServiceTier;
5372 disabled?: boolean;5537 disabled?: boolean;
5373 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;5538 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
5374}) {5539}) {
@@ -5401,9 +5566,6 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 26 unmodified lines
5401 >5566 >
5402 <span className="flex min-w-0 items-center gap-2">5567 <span className="flex min-w-0 items-center gap-2">
5403 <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" />5568 <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}
5407 <span className="truncate">{selectedModelLabel}</span>5569 <span className="truncate">{selectedModelLabel}</span>
5408 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />5570 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
5409 </span>5571 </span>
@@ -5446,10 +5608,6 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 36 unmodified lines
5446 value={modelOption.slug}5608 value={modelOption.slug}
5447 onClick={() => setIsMenuOpen(false)}5609 onClick={() => setIsMenuOpen(false)}
5448 >5610 >
5449 {option.value === "codex" &&
5450 shouldShowFastTierIcon(modelOption.slug, props.serviceTierSetting) ? (
5451 <ZapIcon className="size-3.5 shrink-0 text-amber-500" />
5452 ) : null}
5453 {modelOption.name}5611 {modelOption.name}
5454 </MenuRadioItem>5612 </MenuRadioItem>
5455 ))}5613 ))}
apps/web/src/components/PlanSidebar.tsx+267−0
@@ -0,0 +1,267 @@
1import { memo, useState, useCallback } from "react";
2import { Badge } from "./ui/badge";
3import { Button } from "./ui/button";
4import { ScrollArea } from "./ui/scroll-area";
5import ChatMarkdown from "./ChatMarkdown";
6import {
7 CheckIcon,
8 ChevronDownIcon,
9 ChevronRightIcon,
10 EllipsisIcon,
11 LoaderIcon,
12 PanelRightCloseIcon,
13} from "lucide-react";
14import { cn } from "~/lib/utils";
15import { formatTimestamp } from "../session-logic";
16import type { ActivePlanState } from "../session-logic";
17import type { LatestProposedPlanState } from "../session-logic";
18import {
19 proposedPlanTitle,
20 buildProposedPlanMarkdownFilename,
21 normalizePlanMarkdownForExport,
22 downloadPlanAsTextFile,
23} from "../proposedPlan";
24import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
25import { readNativeApi } from "~/nativeApi";
26import { toastManager } from "./ui/toast";
27
28function 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
50interface PlanSidebarProps {
51 activePlan: ActivePlanState | null;
52 activeProposedPlan: LatestProposedPlanState | null;
53 markdownCwd: string | undefined;
54 workspaceRoot: string | undefined;
55 onClose: () => void;
56}
57
58const 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
266export default PlanSidebar;
267export type { PlanSidebarProps };
apps/web/src/components/ProjectScriptsControl.tsx+45−1
@@ -14,7 +14,7 @@ import { · 13 unmodified lines
14 SettingsIcon,14 SettingsIcon,
15 WrenchIcon,15 WrenchIcon,
16} from "lucide-react";16} from "lucide-react";
17import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";17import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react";
18 18
19import {19import {
20 keybindingValueForCommand,20 keybindingValueForCommand,
@@ -27,6 +27,15 @@ import { · 6 unmodified lines
27} from "~/projectScripts";27} from "~/projectScripts";
28import { shortcutLabelForCommand } from "~/keybindings";28import { shortcutLabelForCommand } from "~/keybindings";
29import { isMacPlatform } from "~/lib/utils";29import { isMacPlatform } from "~/lib/utils";
30import {
31 AlertDialog,
32 AlertDialogClose,
33 AlertDialogDescription,
34 AlertDialogFooter,
35 AlertDialogHeader,
36 AlertDialogPopup,
37 AlertDialogTitle,
38} from "./ui/alert-dialog";
30import { Button } from "./ui/button";39import { Button } from "./ui/button";
31import {40import {
32 Dialog,41 Dialog,
@@ -84,6 +93,7 @@ interface ProjectScriptsControlProps { · 51 unmodified lines
84 onRunScript: (script: ProjectScript) => void;93 onRunScript: (script: ProjectScript) => void;
85 onAddScript: (input: NewProjectScriptInput) => Promise<void> | void;94 onAddScript: (input: NewProjectScriptInput) => Promise<void> | void;
86 onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void;95 onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void;
96 onDeleteScript: (scriptId: string) => Promise<void> | void;
87}97}
88 98
89function normalizeShortcutKeyToken(key: string): string | null {99function normalizeShortcutKeyToken(key: string): string | null {
@@ -144,6 +154,7 @@ export default function ProjectScriptsControl({ · 54 unmodified lines
144 onRunScript,154 onRunScript,
145 onAddScript,155 onAddScript,
146 onUpdateScript,156 onUpdateScript,
157 onDeleteScript,
147}: ProjectScriptsControlProps) {158}: ProjectScriptsControlProps) {
148 const addScriptFormId = React.useId();159 const addScriptFormId = React.useId();
149 const [editingScriptId, setEditingScriptId] = useState<string | null>(null);160 const [editingScriptId, setEditingScriptId] = useState<string | null>(null);
@@ -155,6 +166,7 @@ export default function ProjectScriptsControl({ · 5 unmodified lines
155 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);166 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
156 const [keybinding, setKeybinding] = useState("");167 const [keybinding, setKeybinding] = useState("");
157 const [validationError, setValidationError] = useState<string | null>(null);168 const [validationError, setValidationError] = useState<string | null>(null);
169 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
158 170
159 const primaryScript = useMemo(() => {171 const primaryScript = useMemo(() => {
160 if (preferredScriptId) {172 if (preferredScriptId) {
@@ -247,6 +259,13 @@ export default function ProjectScriptsControl({ · 86 unmodified lines
247 setDialogOpen(true);259 setDialogOpen(true);
248 };260 };
249 261
262 const confirmDeleteScript = useCallback(() => {
263 if (!editingScriptId) return;
264 setDeleteConfirmOpen(false);
265 setDialogOpen(false);
266 void onDeleteScript(editingScriptId);
267 }, [editingScriptId, onDeleteScript]);
268
250 return (269 return (
251 <>270 <>
252 {primaryScript ? (271 {primaryScript ? (
@@ -440,6 +459,16 @@ export default function ProjectScriptsControl({ · 187 unmodified lines
440 </form>459 </form>
441 </DialogPanel>460 </DialogPanel>
442 <DialogFooter>461 <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 )}
443 <Button472 <Button
444 type="button"473 type="button"
445 variant="outline"474 variant="outline"
@@ -455,6 +484,21 @@ export default function ProjectScriptsControl({ · 9 unmodified lines
455 </DialogFooter>484 </DialogFooter>
456 </DialogPopup>485 </DialogPopup>
457 </Dialog>486 </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>
458 </>502 </>
459 );503 );
460}504}
apps/web/src/components/Sidebar.logic.test.ts+124−0
@@ -0,0 +1,124 @@
1import { describe, expect, it } from "vitest";
2
3import { hasUnseenCompletion, resolveThreadStatusPill } from "./Sidebar.logic";
4
5function 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
19describe("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
33describe("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 @@
1import type { Thread } from "../types";
2import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
3
4export 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
17type ThreadStatusInput = Pick<
18 Thread,
19 "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
20>;
21
22export 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
33export 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 @@
1import {1import {
2 ArrowLeftIcon,
2 ChevronRightIcon,3 ChevronRightIcon,
3 FolderIcon,4 FolderIcon,
4 GitPullRequestIcon,5 GitPullRequestIcon,
6 PlusIcon,
5 RocketIcon,7 RocketIcon,
8 SettingsIcon,
6 SquarePenIcon,9 SquarePenIcon,
7 TerminalIcon,10 TerminalIcon,
11 TriangleAlertIcon,
8} from "lucide-react";12} from "lucide-react";
9import { useCallback, useEffect, useMemo, useRef, useState } from "react";13import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10import {14import {
@@ -17,15 +21,14 @@ import { · 6 unmodified lines
17 type ResolvedKeybindingsConfig,21 type ResolvedKeybindingsConfig,
18} from "@t3tools/contracts";22} from "@t3tools/contracts";
19import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";23import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
20import { useNavigate, useParams } from "@tanstack/react-router";24import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
21import { useAppSettings } from "../appSettings";25import { useAppSettings } from "../appSettings";
22import { isElectron } from "../env";26import { isElectron } from "../env";
23import { APP_STAGE_LABEL } from "../branding";27import { APP_STAGE_LABEL } from "../branding";
24import { newCommandId, newProjectId, newThreadId } from "../lib/utils";28import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
25import { useStore } from "../store";29import { useStore } from "../store";
26import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";30import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";
27import { type Thread } from "../types";31import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
28import { derivePendingApprovals } from "../session-logic";
29import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";32import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
30import { serverConfigQueryOptions } from "../lib/serverReactQuery";33import { serverConfigQueryOptions } from "../lib/serverReactQuery";
31import { readNativeApi } from "../nativeApi";34import { readNativeApi } from "../nativeApi";
@@ -33,14 +36,18 @@ import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraft · 1 unmodified line
33import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";36import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
34import { toastManager } from "./ui/toast";37import { toastManager } from "./ui/toast";
35import {38import {
39 getArm64IntelBuildWarningDescription,
36 getDesktopUpdateActionError,40 getDesktopUpdateActionError,
37 getDesktopUpdateButtonTooltip,41 getDesktopUpdateButtonTooltip,
38 isDesktopUpdateButtonDisabled,42 isDesktopUpdateButtonDisabled,
39 resolveDesktopUpdateButtonAction,43 resolveDesktopUpdateButtonAction,
44 shouldShowArm64IntelBuildWarning,
40 shouldHighlightDesktopUpdateError,45 shouldHighlightDesktopUpdateError,
41 shouldShowDesktopUpdateButton,46 shouldShowDesktopUpdateButton,
42 shouldToastDesktopUpdateActionResult,47 shouldToastDesktopUpdateActionResult,
43} from "./desktopUpdate.logic";48} from "./desktopUpdate.logic";
49import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
50import { Button } from "./ui/button";
44import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";51import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
45import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";52import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
46import {53import {
@@ -60,6 +67,7 @@ import { · 13 unmodified lines
60} from "./ui/sidebar";67} from "./ui/sidebar";
61import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";68import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
62import { isNonEmpty as isNonEmptyString } from "effect/String";69import { isNonEmpty as isNonEmptyString } from "effect/String";
70import { resolveThreadStatusPill } from "./Sidebar.logic";
63 71
64const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];72const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
65const THREAD_PREVIEW_LIMIT = 6;73const THREAD_PREVIEW_LIMIT = 6;
@@ -81,13 +89,6 @@ function formatRelativeTime(iso: string): string { · 15 unmodified lines
81 return `${Math.floor(hours / 24)}d ago`;89 return `${Math.floor(hours / 24)}d ago`;
82}90}
83 91
84interface ThreadStatusPill {
85 label: "Working" | "Connecting" | "Completed" | "Pending Approval";
86 colorClass: string;
87 dotClass: string;
88 pulse: boolean;
89}
90
91interface TerminalStatusIndicator {92interface TerminalStatusIndicator {
92 label: "Terminal process running";93 label: "Terminal process running";
93 colorClass: string;94 colorClass: string;
@@ -103,57 +104,6 @@ interface PrStatusIndicator { · 9 unmodified lines
103 104
104type ThreadPr = GitStatusResult["pr"];105type ThreadPr = GitStatusResult["pr"];
105 106
106function 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
117function 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
157function terminalStatusFromRunningIds(107function terminalStatusFromRunningIds(
158 runningTerminalIds: string[],108 runningTerminalIds: string[],
159): TerminalStatusIndicator | null {109): TerminalStatusIndicator | null {
@@ -278,6 +228,7 @@ export default function Sidebar() { · 118 unmodified lines
278 (store) => store.clearProjectDraftThreadById,228 (store) => store.clearProjectDraftThreadById,
279 );229 );
280 const navigate = useNavigate();230 const navigate = useNavigate();
231 const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" });
281 const { settings: appSettings } = useAppSettings();232 const { settings: appSettings } = useAppSettings();
282 const routeThreadId = useParams({233 const routeThreadId = useParams({
283 strict: false,234 strict: false,
@@ -293,6 +244,8 @@ export default function Sidebar() { · 9 unmodified lines
293 const [newCwd, setNewCwd] = useState("");244 const [newCwd, setNewCwd] = useState("");
294 const [isPickingFolder, setIsPickingFolder] = useState(false);245 const [isPickingFolder, setIsPickingFolder] = useState(false);
295 const [isAddingProject, setIsAddingProject] = useState(false);246 const [isAddingProject, setIsAddingProject] = useState(false);
247 const [addProjectError, setAddProjectError] = useState<string | null>(null);
248 const addProjectInputRef = useRef<HTMLInputElement | null>(null);
296 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);249 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
297 const [renamingTitle, setRenamingTitle] = useState("");250 const [renamingTitle, setRenamingTitle] = useState("");
298 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<251 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
@@ -301,6 +254,8 @@ export default function Sidebar() { · 2 unmodified lines
301 const renamingCommittedRef = useRef(false);254 const renamingCommittedRef = useRef(false);
302 const renamingInputRef = useRef<HTMLInputElement | null>(null);255 const renamingInputRef = useRef<HTMLInputElement | null>(null);
303 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);256 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
257 const shouldBrowseForProjectImmediately = isElectron;
258 const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
304 const pendingApprovalByThreadId = useMemo(() => {259 const pendingApprovalByThreadId = useMemo(() => {
305 const map = new Map<ThreadId, boolean>();260 const map = new Map<ThreadId, boolean>();
306 for (const thread of threads) {261 for (const thread of threads) {
@@ -308,6 +263,13 @@ export default function Sidebar() { · 1 unmodified line
308 }263 }
309 return map;264 return map;
310 }, [threads]);265 }, [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]);
311 const projectCwdById = useMemo(273 const projectCwdById = useMemo(
312 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),274 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),
313 [projects],275 [projects],
@@ -486,6 +448,7 @@ export default function Sidebar() { · 172 unmodified lines
486 const finishAddingProject = () => {448 const finishAddingProject = () => {
487 setIsAddingProject(false);449 setIsAddingProject(false);
488 setNewCwd("");450 setNewCwd("");
451 setAddProjectError(null);
489 setAddingProject(false);452 setAddingProject(false);
490 };453 };
491 454
@@ -511,18 +474,29 @@ export default function Sidebar() { · 19 unmodified lines
511 });474 });
512 await handleNewThread(projectId).catch(() => undefined);475 await handleNewThread(projectId).catch(() => undefined);
513 } catch (error) {476 } catch (error) {
477 const description =
478 error instanceof Error ? error.message : "An error occurred while adding the project.";
514 setIsAddingProject(false);479 setIsAddingProject(false);
515 toastManager.add({480 if (shouldBrowseForProjectImmediately) {
516 type: "error",481 toastManager.add({
517 title: "Unable to add project",482 type: "error",
518 description:483 title: "Failed to add project",
519 error instanceof Error ? error.message : "An error occurred while adding the project.",484 description,
520 });485 });
486 } else {
487 setAddProjectError(description);
488 }
521 return;489 return;
522 }490 }
523 finishAddingProject();491 finishAddingProject();
524 },492 },
525 [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects],493 [
494 focusMostRecentThreadForProject,
495 handleNewThread,
496 isAddingProject,
497 projects,
498 shouldBrowseForProjectImmediately,
499 ],
526 );500 );
527 501
528 const handleAddProject = () => {502 const handleAddProject = () => {
@@ -541,10 +515,21 @@ export default function Sidebar() { · 12 unmodified lines
541 }515 }
542 if (pickedPath) {516 if (pickedPath) {
543 await addProjectFromPath(pickedPath);517 await addProjectFromPath(pickedPath);
518 } else if (!shouldBrowseForProjectImmediately) {
519 addProjectInputRef.current?.focus();
544 }520 }
545 setIsPickingFolder(false);521 setIsPickingFolder(false);
546 };522 };
547 523
524 const handleStartAddProject = () => {
525 setAddProjectError(null);
526 if (shouldBrowseForProjectImmediately) {
527 void handlePickFolder();
528 return;
529 }
530 setAddingProject((prev) => !prev);
531 };
532
548 const cancelRename = useCallback(() => {533 const cancelRename = useCallback(() => {
549 setRenamingThreadId(null);534 setRenamingThreadId(null);
550 renamingInputRef.current = null;535 renamingInputRef.current = null;
@@ -881,6 +866,12 @@ export default function Sidebar() { · 330 unmodified lines
881 const desktopUpdateButtonAction = desktopUpdateState866 const desktopUpdateButtonAction = desktopUpdateState
882 ? resolveDesktopUpdateButtonAction(desktopUpdateState)867 ? resolveDesktopUpdateButtonAction(desktopUpdateState)
883 : "none";868 : "none";
869 const showArm64IntelBuildWarning =
870 isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState);
871 const arm64IntelBuildWarningDescription =
872 desktopUpdateState && showArm64IntelBuildWarning
873 ? getArm64IntelBuildWarningDescription(desktopUpdateState)
874 : null;
884 const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled875 const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled
885 ? "cursor-not-allowed opacity-60"876 ? "cursor-not-allowed opacity-60"
886 : "hover:bg-accent hover:text-foreground";877 : "hover:bg-accent hover:text-foreground";
@@ -978,7 +969,7 @@ export default function Sidebar() { · 91 unmodified lines
978 const wordmark = (969 const wordmark = (
979 <div className="flex items-center gap-2">970 <div className="flex items-center gap-2">
980 <SidebarTrigger className="shrink-0 md:hidden" />971 <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">
982 <T3Wordmark />973 <T3Wordmark />
983 <span className="truncate text-sm font-medium tracking-tight text-muted-foreground">974 <span className="truncate text-sm font-medium tracking-tight text-muted-foreground">
984 Code975 Code
@@ -994,7 +985,7 @@ export default function Sidebar() { · 9 unmodified lines
994 <>985 <>
995 {isElectron ? (986 {isElectron ? (
996 <>987 <>
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]">
998 {wordmark}989 {wordmark}
999 {showDesktopUpdateButton && (990 {showDesktopUpdateButton && (
1000 <Tooltip>991 <Tooltip>
@@ -1005,7 +996,7 @@ export default function Sidebar() { · 4 unmodified lines
1005 aria-label={desktopUpdateTooltip}996 aria-label={desktopUpdateTooltip}
1006 aria-disabled={desktopUpdateButtonDisabled || undefined}997 aria-disabled={desktopUpdateButtonDisabled || undefined}
1007 disabled={desktopUpdateButtonDisabled}998 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}`}
1009 onClick={handleDesktopUpdateButtonClick}1000 onClick={handleDesktopUpdateButtonClick}
1010 >1001 >
1011 <RocketIcon className="size-3.5" />1002 <RocketIcon className="size-3.5" />
@@ -1024,7 +1015,121 @@ export default function Sidebar() { · 12 unmodified lines
1024 )}1015 )}
1025 1016
1026 <SidebarContent className="gap-0">1017 <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}
1027 <SidebarGroup className="px-2 py-2">1041 <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
1028 <SidebarMenu>1133 <SidebarMenu>
1029 {projects.map((project) => {1134 {projects.map((project) => {
1030 const projectThreads = threads1135 const projectThreads = threads
@@ -1112,10 +1217,11 @@ export default function Sidebar() { · 81 unmodified lines
1112 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">1217 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">
1113 {visibleThreads.map((thread) => {1218 {visibleThreads.map((thread) => {
1114 const isActive = routeThreadId === thread.id;1219 const isActive = routeThreadId === thread.id;
1115 const threadStatus = threadStatusPill(1220 const threadStatus = resolveThreadStatusPill({
1116 thread,1221 thread,
1117 pendingApprovalByThreadId.get(thread.id) === true,1222 hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true,
1118 );1223 hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true,
1224 });
1119 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);1225 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
1120 const terminalStatus = terminalStatusFromRunningIds(1226 const terminalStatus = terminalStatusFromRunningIds(
1121 selectThreadTerminalState(terminalStateByThreadId, thread.id)1227 selectThreadTerminalState(terminalStateByThreadId, thread.id)
@@ -1286,70 +1392,39 @@ export default function Sidebar() { · 164 unmodified lines
1286 })}1392 })}
1287 </SidebarMenu>1393 </SidebarMenu>
1288 1394
1289 {projects.length === 0 && !addingProject && (1395 {projects.length === 0 && !shouldShowProjectPathEntry && (
1290 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">1396 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
1291 No projects yet.1397 No projects yet
1292 <br />
1293 Add one to get started.
1294 </div>1398 </div>
1295 )}1399 )}
1296 </SidebarGroup>1400 </SidebarGroup>
1297 </SidebarContent>1401 </SidebarContent>
1298 1402
1299 <SidebarSeparator />1403 <SidebarSeparator />
1300 <SidebarFooter className="gap-0 p-3">1404 <SidebarFooter className="p-2">
1301 {addingProject ? (1405 <SidebarMenu>
1302 <>1406 <SidebarMenuItem>
1303 <p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">1407 {isOnSettings ? (
1304 Add project1408 <SidebarMenuButton
1305 </p>1409 size="sm"
1306 <input1410 className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
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"1411 onClick={() => window.history.back()}
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}
1322 >1412 >
1323 {isPickingFolder ? "Picking folder..." : "Browse for folder"}1413 <ArrowLeftIcon className="size-3.5" />
1324 </button>1414 <span className="text-xs">Back</span>
1325 )}1415 </SidebarMenuButton>
1326 <div className="flex gap-2">1416 ) : (
1327 <button1417 <SidebarMenuButton
1328 type="button"1418 size="sm"
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"1419 className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
1330 onClick={handleAddProject}1420 onClick={() => void navigate({ to: "/settings" })}
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)}
1339 >1421 >
1340 Cancel1422 <SettingsIcon className="size-3.5" />
1341 </button>1423 <span className="text-xs">Settings</span>
1342 </div>1424 </SidebarMenuButton>
1343 </>1425 )}
1344 ) : (1426 </SidebarMenuItem>
1345 <button1427 </SidebarMenu>
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 )}
1353 </SidebarFooter>1428 </SidebarFooter>
1354 </>1429 </>
1355 );1430 );
apps/web/src/components/desktopUpdate.logic.test.ts+31−0
@@ -2,11 +2,13 @@ import { describe, expect, it } from "vitest"; · 1 unmodified line
2import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";2import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";
3 3
4import {4import {
5 getArm64IntelBuildWarningDescription,
5 getDesktopUpdateActionError,6 getDesktopUpdateActionError,
6 getDesktopUpdateButtonTooltip,7 getDesktopUpdateButtonTooltip,
7 isDesktopUpdateButtonDisabled,8 isDesktopUpdateButtonDisabled,
8 resolveDesktopUpdateButtonAction,9 resolveDesktopUpdateButtonAction,
9 shouldHighlightDesktopUpdateError,10 shouldHighlightDesktopUpdateError,
11 shouldShowArm64IntelBuildWarning,
10 shouldShowDesktopUpdateButton,12 shouldShowDesktopUpdateButton,
11 shouldToastDesktopUpdateActionResult,13 shouldToastDesktopUpdateActionResult,
12} from "./desktopUpdate.logic";14} from "./desktopUpdate.logic";
@@ -15,6 +17,9 @@ const baseState: DesktopUpdateState = { · 2 unmodified lines
15 enabled: true,17 enabled: true,
16 status: "idle",18 status: "idle",
17 currentVersion: "1.0.0",19 currentVersion: "1.0.0",
20 hostArch: "x64",
21 appArch: "x64",
22 runningUnderArm64Translation: false,
18 availableVersion: null,23 availableVersion: null,
19 downloadedVersion: null,24 downloadedVersion: null,
20 downloadPercent: null,25 downloadPercent: null,
@@ -171,4 +176,30 @@ describe("desktop update UI helpers", () => { · 150 unmodified lines
171 }),176 }),
172 ).toBe(false);177 ).toBe(false);
173 });178 });
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 });
174});205});
apps/web/src/components/desktopUpdate.logic.ts+19−0
@@ -32,10 +32,29 @@ export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): · 31 unmodified lines
32 return resolveDesktopUpdateButtonAction(state) !== "none";32 return resolveDesktopUpdateButtonAction(state) !== "none";
33}33}
34 34
35export function shouldShowArm64IntelBuildWarning(state: DesktopUpdateState | null): boolean {
36 return state?.hostArch === "arm64" && state.appArch === "x64";
37}
38
35export function isDesktopUpdateButtonDisabled(state: DesktopUpdateState | null): boolean {39export function isDesktopUpdateButtonDisabled(state: DesktopUpdateState | null): boolean {
36 return state?.status === "downloading";40 return state?.status === "downloading";
37}41}
38 42
43export 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
39export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string {58export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string {
40 if (state.status === "available") {59 if (state.status === "available") {
41 return `Update ${state.availableVersion ?? "available"} ready to download`;60 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
566 <button566 <button
567 aria-label={railLabel}567 aria-label={railLabel}
568 className={cn(568 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",
570 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",571 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
571 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",572 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
572 "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",573 "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 @@
1import { assert, describe, it } from "vitest";1import { assert, describe, it } from "vitest";
2import { shouldHideCollapsedToastContent } from "./toast.logic";2import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic";
3 3
4describe("shouldHideCollapsedToastContent", () => {4describe("shouldHideCollapsedToastContent", () => {
5 it("keeps a single visible toast readable", () => {5 it("keeps a single visible toast readable", () => {
@@ -14,3 +14,42 @@ describe("shouldHideCollapsedToastContent", () => { · 8 unmodified lines
14 assert.equal(shouldHideCollapsedToastContent(1, 3), true);14 assert.equal(shouldHideCollapsedToastContent(1, 3), true);
15 });15 });
16});16});
17
18describe("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
7 if (visibleToastCount <= 1) return false;7 if (visibleToastCount <= 1) return false;
8 return visibleToastIndex > 0;8 return visibleToastIndex > 0;
9}9}
10
11type ToastWithHeight = {
12 height?: number | null | undefined;
13};
14
15type VisibleToastLayoutItem<TToast extends object> = {
16 toast: TToast;
17 visibleIndex: number;
18 offsetY: number;
19};
20
21export 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
44function 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 @@
1"use client";1"use client";
2 2
3import { Toast } from "@base-ui/react/toast";3import { Toast } from "@base-ui/react/toast";
4import { useEffect } from "react";4import { useEffect, type CSSProperties } from "react";
5import { useParams } from "@tanstack/react-router";5import { useParams } from "@tanstack/react-router";
6import { ThreadId } from "@t3tools/contracts";6import { ThreadId } from "@t3tools/contracts";
7import {7import {
@@ -14,7 +14,7 @@ import { · 6 unmodified lines
14 14
15import { cn } from "~/lib/utils";15import { cn } from "~/lib/utils";
16import { buttonVariants } from "~/components/ui/button";16import { buttonVariants } from "~/components/ui/button";
17import { shouldHideCollapsedToastContent } from "./toast.logic";17import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic";
18 18
19type ThreadToastData = {19type ThreadToastData = {
20 threadId?: ThreadId | null;20 threadId?: ThreadId | null;
@@ -158,6 +158,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 137 unmodified lines
158 const visibleToasts = toasts.filter((toast) =>158 const visibleToasts = toasts.filter((toast) =>
159 shouldRenderForActiveThread(toast.data, activeThreadId),159 shouldRenderForActiveThread(toast.data, activeThreadId),
160 );160 );
161 const visibleToastLayout = buildVisibleToastLayout(visibleToasts);
161 162
162 useEffect(() => {163 useEffect(() => {
163 const activeToastIds = new Set(toasts.map((toast) => toast.id));164 const activeToastIds = new Set(toasts.map((toast) => toast.id));
@@ -183,12 +184,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 19 unmodified lines
183 )}184 )}
184 data-position={position}185 data-position={position}
185 data-slot="toast-viewport"186 data-slot="toast-viewport"
187 style={
188 {
189 "--toast-frontmost-height": `${visibleToastLayout.frontmostHeight}px`,
190 } as CSSProperties
191 }
186 >192 >
187 {visibleToasts.map((toast, visibleIndex) => {193 {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => {
188 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;194 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
189 const hideCollapsedContent = shouldHideCollapsedToastContent(195 const hideCollapsedContent = shouldHideCollapsedToastContent(
190 visibleIndex,196 visibleIndex,
191 visibleToasts.length,197 visibleToastLayout.items.length,
192 );198 );
193 199
194 return (200 return (
@@ -241,6 +247,12 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 46 unmodified lines
241 )}247 )}
242 data-position={position}248 data-position={position}
243 key={toast.id}249 key={toast.id}
250 style={
251 {
252 "--toast-index": visibleIndex,
253 "--toast-offset-y": `${offsetY}px`,
254 } as CSSProperties
255 }
244 swipeDirection={256 swipeDirection={
245 position.includes("center")257 position.includes("center")
246 ? [isTop ? "up" : "down"]258 ? [isTop ? "up" : "down"]
apps/web/src/proposedPlan.ts+16−0
@@ -49,3 +49,19 @@ export function buildProposedPlanMarkdownFilename(planMarkdown: string): string · 48 unmodified lines
49 const title = proposedPlanTitle(planMarkdown);49 const title = proposedPlanTitle(planMarkdown);
50 return `${sanitizePlanFileSegment(title ?? "plan")}.md`;50 return `${sanitizePlanFileSegment(title ?? "plan")}.md`;
51}51}
52
53export function normalizePlanMarkdownForExport(planMarkdown: string): string {
54 return `${planMarkdown.trimEnd()}\n`;
55}
56
57export 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
3import { useCallback, useState } from "react";3import { useCallback, useState } from "react";
4import { type ProviderKind } from "@t3tools/contracts";4import { type ProviderKind } from "@t3tools/contracts";
5import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";5import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
6import { ZapIcon } from "lucide-react";6
7 7import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
8import {
9 APP_SERVICE_TIER_OPTIONS,
10 MAX_CUSTOM_MODEL_LENGTH,
11 shouldShowFastTierIcon,
12 useAppSettings,
13} from "../appSettings";
14import { isElectron } from "../env";8import { isElectron } from "../env";
15import { useTheme } from "../hooks/useTheme";9import { useTheme } from "../hooks/useTheme";
16import { serverConfigQueryOptions } from "../lib/serverReactQuery";10import { serverConfigQueryOptions } from "../lib/serverReactQuery";
@@ -18,7 +12,6 @@ import { ensureNativeApi } from "../nativeApi"; · 1 unmodified line
18import { preferredTerminalEditor } from "../terminal-links";12import { preferredTerminalEditor } from "../terminal-links";
19import { Button } from "../components/ui/button";13import { Button } from "../components/ui/button";
20import { Input } from "../components/ui/input";14import { Input } from "../components/ui/input";
21import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../components/ui/select";
22import { Switch } from "../components/ui/switch";15import { Switch } from "../components/ui/switch";
23import { SidebarInset } from "~/components/ui/sidebar";16import { SidebarInset } from "~/components/ui/sidebar";
24 17
@@ -103,7 +96,6 @@ function SettingsRouteView() { · 78 unmodified lines
103 96
104 const codexBinaryPath = settings.codexBinaryPath;97 const codexBinaryPath = settings.codexBinaryPath;
105 const codexHomePath = settings.codexHomePath;98 const codexHomePath = settings.codexHomePath;
106 const codexServiceTier = settings.codexServiceTier;
107 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;99 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
108 100
109 const openKeybindingsFile = useCallback(() => {101 const openKeybindingsFile = useCallback(() => {
@@ -310,43 +302,6 @@ function SettingsRouteView() { · 200 unmodified lines
310 </div>302 </div>
311 303
312 <div className="space-y-5">304 <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
350 {MODEL_PROVIDER_SETTINGS.map((providerSettings) => {305 {MODEL_PROVIDER_SETTINGS.map((providerSettings) => {
351 const provider = providerSettings.provider;306 const provider = providerSettings.provider;
352 const customModels = getCustomModelsForProvider(settings, provider);307 const customModels = getCustomModelsForProvider(settings, provider);
@@ -445,14 +400,9 @@ function SettingsRouteView() { · 92 unmodified lines
445 key={`${provider}:${slug}`}400 key={`${provider}:${slug}`}
446 className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2"401 className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2"
447 >402 >
448 <div className="flex min-w-0 flex-1 items-center gap-2">403 <code className="min-w-0 flex-1 truncate text-xs text-foreground">
449 {provider === "codex" && shouldShowFastTierIcon(slug, codexServiceTier) ? (404 {slug}
450 <ZapIcon className="size-3.5 shrink-0 text-amber-500" />405 </code>
451 ) : null}
452 <code className="min-w-0 flex-1 truncate text-xs text-foreground">
453 {slug}
454 </code>
455 </div>
456 <Button406 <Button
457 size="xs"407 size="xs"
458 variant="ghost"408 variant="ghost"
apps/web/src/store.ts+1−0
@@ -25,6 +25,7 @@ export interface AppState { · 24 unmodified lines
25 25
26const PERSISTED_STATE_KEY = "t3code:renderer-state:v8";26const PERSISTED_STATE_KEY = "t3code:renderer-state:v8";
27const LEGACY_PERSISTED_STATE_KEYS = [27const LEGACY_PERSISTED_STATE_KEYS = [
28 "t3code:renderer-state:v7",
28 "t3code:renderer-state:v6",29 "t3code:renderer-state:v6",
29 "t3code:renderer-state:v5",30 "t3code:renderer-state:v5",
30 "t3code:renderer-state:v4",31 "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
4import { defineConfig } from "vite";4import { defineConfig } from "vite";
5 5
6const port = Number(process.env.PORT ?? 5733);6const port = Number(process.env.PORT ?? 5733);
7const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();
8
9const buildSourcemap =
10 sourcemapEnv === "0" || sourcemapEnv === "false"
11 ? false
12 : sourcemapEnv === "hidden"
13 ? "hidden"
14 : true;
7 15
8export default defineConfig({16export default defineConfig({
9 plugins: [17 plugins: [
@@ -42,5 +50,6 @@ export default defineConfig({ · 32 unmodified lines
42 build: {50 build: {
43 outDir: "dist",51 outDir: "dist",
44 emptyOutDir: true,52 emptyOutDir: true,
53 sourcemap: buildSourcemap,
45 },54 },
46});55});
bun.lock+4−4
@@ -14,7 +14,7 @@ · 13 unmodified lines
14 },14 },
15 "apps/desktop": {15 "apps/desktop": {
16 "name": "@t3tools/desktop",16 "name": "@t3tools/desktop",
17 "version": "0.0.5",17 "version": "0.0.9",
18 "dependencies": {18 "dependencies": {
19 "effect": "catalog:",19 "effect": "catalog:",
20 "electron": "40.6.0",20 "electron": "40.6.0",
@@ -43,7 +43,7 @@ · 22 unmodified lines
43 },43 },
44 "apps/server": {44 "apps/server": {
45 "name": "t3",45 "name": "t3",
46 "version": "0.0.5",46 "version": "0.0.9",
47 "bin": {47 "bin": {
48 "t3": "./dist/index.mjs",48 "t3": "./dist/index.mjs",
49 },49 },
@@ -72,7 +72,7 @@ · 22 unmodified lines
72 },72 },
73 "apps/web": {73 "apps/web": {
74 "name": "@t3tools/web",74 "name": "@t3tools/web",
75 "version": "0.0.5",75 "version": "0.0.9",
76 "dependencies": {76 "dependencies": {
77 "@base-ui/react": "^1.2.0",77 "@base-ui/react": "^1.2.0",
78 "@lexical/react": "^0.41.0",78 "@lexical/react": "^0.41.0",
@@ -116,7 +116,7 @@ · 37 unmodified lines
116 },116 },
117 "packages/contracts": {117 "packages/contracts": {
118 "name": "@t3tools/contracts",118 "name": "@t3tools/contracts",
119 "version": "0.0.5",119 "version": "0.0.9",
120 "dependencies": {120 "dependencies": {
121 "effect": "catalog:",121 "effect": "catalog:",
122 },122 },
package.json+1−0
@@ -45,6 +45,7 @@ · 44 unmodified lines
45 "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64",45 "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64",
46 "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64",46 "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64",
47 "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64",47 "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64",
48 "release:smoke": "node scripts/release-smoke.ts",
48 "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",49 "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",
49 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"50 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"
50 },51 },
packages/contracts/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@t3tools/contracts",2 "name": "@t3tools/contracts",
3 "version": "0.0.5",3 "version": "0.0.9",
4 "private": true,4 "private": true,
5 "files": [5 "files": [
6 "dist"6 "dist"
packages/contracts/src/ipc.ts+11−0
@@ -58,10 +58,21 @@ export type DesktopUpdateStatus = · 57 unmodified lines
58 | "downloaded"58 | "downloaded"
59 | "error";59 | "error";
60 60
61export type DesktopRuntimeArch = "arm64" | "x64" | "other";
62
63export interface DesktopRuntimeInfo {
64 hostArch: DesktopRuntimeArch;
65 appArch: DesktopRuntimeArch;
66 runningUnderArm64Translation: boolean;
67}
68
61export interface DesktopUpdateState {69export interface DesktopUpdateState {
62 enabled: boolean;70 enabled: boolean;
63 status: DesktopUpdateStatus;71 status: DesktopUpdateStatus;
64 currentVersion: string;72 currentVersion: string;
73 hostArch: DesktopRuntimeArch;
74 appArch: DesktopRuntimeArch;
75 runningUnderArm64Translation: boolean;
65 availableVersion: string | null;76 availableVersion: string | null;
66 downloadedVersion: string | null;77 downloadedVersion: string | null;
67 downloadPercent: number | null;78 downloadPercent: number | null;
packages/contracts/src/orchestration.ts+0−123
@@ -42,8 +42,6 @@ export const ProviderSandboxMode = Schema.Literals([ · 41 unmodified lines
42 "danger-full-access",42 "danger-full-access",
43]);43]);
44export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;44export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
45export const ProviderServiceTier = Schema.Literals(["fast", "flex"]);
46export type ProviderServiceTier = typeof ProviderServiceTier.Type;
47export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";45export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
48const CodexProviderStartOptions = Schema.Struct({46const CodexProviderStartOptions = Schema.Struct({
49 binaryPath: Schema.optional(TrimmedNonEmptyString),47 binaryPath: Schema.optional(TrimmedNonEmptyString),
@@ -371,7 +369,6 @@ export const ThreadTurnStartCommand = Schema.Struct({ · 321 unmodified lines
371 }),369 }),
372 provider: Schema.optional(ProviderKind),370 provider: Schema.optional(ProviderKind),
373 model: Schema.optional(TrimmedNonEmptyString),371 model: Schema.optional(TrimmedNonEmptyString),
374 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
375 modelOptions: Schema.optional(ProviderModelOptions),372 modelOptions: Schema.optional(ProviderModelOptions),
376 providerOptions: Schema.optional(ProviderStartOptions),373 providerOptions: Schema.optional(ProviderStartOptions),
377 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),374 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
@@ -394,7 +391,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({ · 16 unmodified lines
394 }),391 }),
395 provider: Schema.optional(ProviderKind),392 provider: Schema.optional(ProviderKind),
396 model: Schema.optional(TrimmedNonEmptyString),393 model: Schema.optional(TrimmedNonEmptyString),
397 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
398 modelOptions: Schema.optional(ProviderModelOptions),394 modelOptions: Schema.optional(ProviderModelOptions),
399 providerOptions: Schema.optional(ProviderStartOptions),395 providerOptions: Schema.optional(ProviderStartOptions),
400 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),396 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
@@ -675,7 +671,6 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ · 274 unmodified lines
675 messageId: MessageId,671 messageId: MessageId,
676 provider: Schema.optional(ProviderKind),672 provider: Schema.optional(ProviderKind),
677 model: Schema.optional(TrimmedNonEmptyString),673 model: Schema.optional(TrimmedNonEmptyString),
678 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
679 modelOptions: Schema.optional(ProviderModelOptions),674 modelOptions: Schema.optional(ProviderModelOptions),
680 providerOptions: Schema.optional(ProviderStartOptions),675 providerOptions: Schema.optional(ProviderStartOptions),
681 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),676 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
@@ -769,20 +764,6 @@ const EventBaseFields = { · 87 unmodified lines
769 metadata: OrchestrationEventMetadata,764 metadata: OrchestrationEventMetadata,
770} as const;765} as const;
771 766
772const 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
786export const OrchestrationEvent = Schema.Union([767export const OrchestrationEvent = Schema.Union([
787 Schema.Struct({768 Schema.Struct({
788 ...EventBaseFields,769 ...EventBaseFields,
@@ -887,110 +868,6 @@ export const OrchestrationEvent = Schema.Union([ · 98 unmodified lines
887]);868]);
888export type OrchestrationEvent = typeof OrchestrationEvent.Type;869export type OrchestrationEvent = typeof OrchestrationEvent.Type;
889 870
890export 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]);
992export type OrchestrationPersistedEvent = typeof OrchestrationPersistedEvent.Type;
993
994export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]);871export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]);
995export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type;872export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type;
996 873
packages/contracts/src/provider.ts+0−3
@@ -19,7 +19,6 @@ import { · 18 unmodified lines
19 ProviderKind,19 ProviderKind,
20 ProviderRequestKind,20 ProviderRequestKind,
21 ProviderSandboxMode,21 ProviderSandboxMode,
22 ProviderServiceTier,
23 ProviderUserInputAnswers,22 ProviderUserInputAnswers,
24 RuntimeMode,23 RuntimeMode,
25} from "./orchestration";24} from "./orchestration";
@@ -65,7 +64,6 @@ export const ProviderSessionStartInput = Schema.Struct({ · 39 unmodified lines
65 model: Schema.optional(TrimmedNonEmptyStringSchema),64 model: Schema.optional(TrimmedNonEmptyStringSchema),
66 modelOptions: Schema.optional(ProviderModelOptions),65 modelOptions: Schema.optional(ProviderModelOptions),
67 resumeCursor: Schema.optional(Schema.Unknown),66 resumeCursor: Schema.optional(Schema.Unknown),
68 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
69 approvalPolicy: Schema.optional(ProviderApprovalPolicy),67 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
70 sandboxMode: Schema.optional(ProviderSandboxMode),68 sandboxMode: Schema.optional(ProviderSandboxMode),
71 providerOptions: Schema.optional(ProviderStartOptions),69 providerOptions: Schema.optional(ProviderStartOptions),
@@ -82,7 +80,6 @@ export const ProviderSendTurnInput = Schema.Struct({ · 10 unmodified lines
82 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),80 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
83 ),81 ),
84 model: Schema.optional(TrimmedNonEmptyStringSchema),82 model: Schema.optional(TrimmedNonEmptyStringSchema),
85 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
86 modelOptions: Schema.optional(ProviderModelOptions),83 modelOptions: Schema.optional(ProviderModelOptions),
87 interactionMode: Schema.optional(ProviderInteractionMode),84 interactionMode: Schema.optional(ProviderInteractionMode),
88});85});
scripts/build-desktop-artifact.ts+6−0
@@ -580,6 +580,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 579 unmodified lines
580 ChildProcess.make({580 ChildProcess.make({
581 cwd: repoRoot,581 cwd: repoRoot,
582 ...commandOutputOptions(options.verbose),582 ...commandOutputOptions(options.verbose),
583 // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).
584 shell: process.platform === "win32",
583 })`bun run build:desktop`,585 })`bun run build:desktop`,
584 );586 );
585 }587 }
@@ -642,6 +644,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 56 unmodified lines
642 ChildProcess.make({644 ChildProcess.make({
643 cwd: stageAppDir,645 cwd: stageAppDir,
644 ...commandOutputOptions(options.verbose),646 ...commandOutputOptions(options.verbose),
647 // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).
648 shell: process.platform === "win32",
645 })`bun install --production`,649 })`bun install --production`,
646 );650 );
647 651
@@ -680,6 +684,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 32 unmodified lines
680 cwd: stageAppDir,684 cwd: stageAppDir,
681 env: buildEnv,685 env: buildEnv,
682 ...commandOutputOptions(options.verbose),686 ...commandOutputOptions(options.verbose),
687 // Windows needs shell mode to resolve .cmd shims.
688 shell: process.platform === "win32",
683 })`bunx electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`,689 })`bunx electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`,
684 );690 );
685 691
scripts/dev-runner.ts+2−0
@@ -455,6 +455,8 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { · 454 unmodified lines
455 stderr: "inherit",455 stderr: "inherit",
456 env,456 env,
457 extendEnv: false,457 extendEnv: false,
458 // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).
459 shell: process.platform === "win32",
458 // Keep turbo in the same process group so terminal signals (Ctrl+C)460 // Keep turbo in the same process group so terminal signals (Ctrl+C)
459 // reach it directly. Effect defaults to detached: true on non-Windows,461 // reach it directly. Effect defaults to detached: true on non-Windows,
460 // which would put turbo in a new group and require manual forwarding.462 // which would put turbo in a new group and require manual forwarding.
scripts/release-smoke.ts+113−0
@@ -0,0 +1,113 @@
1import { execFileSync } from "node:child_process";
2import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { dirname, join, resolve } from "node:path";
5import { fileURLToPath } from "node:url";
6
7const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
9const 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
21function 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
30function 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
40files:
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
47path: T3-Code-9.9.9-smoke.0-arm64.zip
48sha512: arm64zip
49releaseDate: '2026-03-08T10:32:14.587Z'
50`,
51 );
52
53 writeFileSync(
54 x64Path,
55 `version: 9.9.9-smoke.0
56files:
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
63path: T3-Code-9.9.9-smoke.0-x64.zip
64sha512: x64zip
65releaseDate: '2026-03-08T10:36:07.540Z'
66`,
67 );
68
69 return { arm64Path, x64Path };
70}
71
72function assertContains(haystack: string, needle: string, message: string): void {
73 if (!haystack.includes(needle)) {
74 throw new Error(message);
75 }
76}
77
78const tempRoot = mkdtempSync(join(tmpdir(), "t3-release-smoke-"));
79
80try {
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 @@
1import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
2import { resolve } from "node:path";
3import { fileURLToPath } from "node:url";
4
5export 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
12interface UpdateReleasePackageVersionsOptions {
13 readonly rootDir?: string;
14}
15
16interface MutablePackageJson {
17 version?: string;
18 [key: string]: unknown;
19}
20
21export 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
43function 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
91const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
92
93if (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}