Unified · Split
| @@ -0,0 +1,180 @@ | ||
| 1 | +# Encyclopedia | |
| 2 | + | |
| 3 | +This is a living glossary for T3 Code. It explains what common terms mean in this codebase. | |
| 4 | + | |
| 5 | +## Table of contents | |
| 6 | + | |
| 7 | +- [Project and workspace](#project-and-workspace) | |
| 8 | +- [Thread timeline](#thread-timeline) | |
| 9 | +- [Orchestration](#orchestration) | |
| 10 | +- [Provider runtime](#provider-runtime) | |
| 11 | +- [Checkpointing](#checkpointing) | |
| 12 | + | |
| 13 | +## Concepts | |
| 14 | + | |
| 15 | +### Project and workspace | |
| 16 | + | |
| 17 | +#### Project | |
| 18 | + | |
| 19 | +The top-level workspace record in the app. In [the orchestration contracts][1], a project has a `workspaceRoot`, a title, and one or more threads. See [workspace-layout.md][2]. | |
| 20 | + | |
| 21 | +#### Workspace root | |
| 22 | + | |
| 23 | +The root filesystem path for a project. In [the orchestration model][1], it is the base directory for branches and optional worktrees. See [workspace-layout.md][2]. | |
| 24 | + | |
| 25 | +#### Worktree | |
| 26 | + | |
| 27 | +A Git worktree used as an isolated workspace for a thread. If a thread has a `worktreePath` in [the contracts][1], it runs there instead of in the main working tree. Git operations live in [GitCore.ts][3]. | |
| 28 | + | |
| 29 | +### Thread timeline | |
| 30 | + | |
| 31 | +#### Thread | |
| 32 | + | |
| 33 | +The main durable unit of conversation and workspace history. In [the orchestration contracts][1], a thread holds messages, activities, checkpoints, and session-related state. See [projector.ts][4]. | |
| 34 | + | |
| 35 | +#### Turn | |
| 36 | + | |
| 37 | +A single user-to-assistant work cycle inside a thread. It starts with user input and ends when follow-up work like checkpointing settles. See [the contracts][1], [ProviderRuntimeIngestion.ts][5], and [CheckpointReactor.ts][6]. | |
| 38 | + | |
| 39 | +#### Activity | |
| 40 | + | |
| 41 | +A user-visible log item attached to a thread. In [the contracts][1], activities cover important non-message events like approvals, tool actions, and failures. They are projected into thread state in [projector.ts][4]. | |
| 42 | + | |
| 43 | +### Orchestration | |
| 44 | + | |
| 45 | +Orchestration is the server-side domain layer that turns runtime activity into stable app state. The main entry point is [OrchestrationEngine.ts][7], with core logic in [decider.ts][8] and [projector.ts][4]. | |
| 46 | + | |
| 47 | +#### Aggregate | |
| 48 | + | |
| 49 | +The domain object a command or event belongs to. In [the contracts][1], that is usually `project` or `thread`. See [decider.ts][8]. | |
| 50 | + | |
| 51 | +#### Command | |
| 52 | + | |
| 53 | +A typed request to change domain state. In [the contracts][1], commands are validated in [commandInvariants.ts][9] and turned into events by [decider.ts][8]. | |
| 54 | +Examples include `thread.create`, `thread.turn.start`, and `thread.checkpoint.revert`. | |
| 55 | + | |
| 56 | +#### Domain Event | |
| 57 | + | |
| 58 | +A persisted fact that something already happened. In [the contracts][1], events are the source of truth, and [projector.ts][4] shows how they are applied. | |
| 59 | +Examples include `thread.created`, `thread.message-sent`, and `thread.turn-diff-completed`. | |
| 60 | + | |
| 61 | +#### Decider | |
| 62 | + | |
| 63 | +The pure orchestration logic that turns commands plus current state into events. The core implementation is in [decider.ts][8], with preconditions in [commandInvariants.ts][9]. | |
| 64 | + | |
| 65 | +#### Projection | |
| 66 | + | |
| 67 | +A read-optimized view derived from events. See [projector.ts][4], [ProjectionPipeline.ts][11], and [ProjectionSnapshotQuery.ts][10]. | |
| 68 | + | |
| 69 | +#### Projector | |
| 70 | + | |
| 71 | +The logic that applies domain events to the read model or projection tables. See [projector.ts][4] and [ProjectionPipeline.ts][11]. | |
| 72 | + | |
| 73 | +#### Read model | |
| 74 | + | |
| 75 | +The current materialized view of orchestration state. In [the contracts][1], it holds projects, threads, messages, activities, checkpoints, and session state. See [ProjectionSnapshotQuery.ts][10] and [OrchestrationEngine.ts][7]. | |
| 76 | + | |
| 77 | +#### Reactor | |
| 78 | + | |
| 79 | +A side-effecting service that handles follow-up work after events or runtime signals. Examples include [CheckpointReactor.ts][6], [ProviderCommandReactor.ts][12], and [ProviderRuntimeIngestion.ts][5]. | |
| 80 | + | |
| 81 | +#### Receipt | |
| 82 | + | |
| 83 | +A lightweight typed runtime signal emitted when an async milestone completes. See [RuntimeReceiptBus.ts][13]. | |
| 84 | +Examples include `checkpoint.baseline.captured`, `checkpoint.diff.finalized`, and `turn.processing.quiesced`, which are emitted by flows such as [CheckpointReactor.ts][6]. | |
| 85 | + | |
| 86 | +#### Quiesced | |
| 87 | + | |
| 88 | +"Quiesced" means a turn has gone quiet and stable. In [the receipt schema][13], it means the follow-up work has settled, including work in [CheckpointReactor.ts][6]. | |
| 89 | + | |
| 90 | +### Provider runtime | |
| 91 | + | |
| 92 | +The live backend agent implementation and its event stream. The main service is [ProviderService.ts][14], the adapter contract is [ProviderAdapter.ts][15], and the overview is in [provider-architecture.md][16]. | |
| 93 | + | |
| 94 | +#### Provider | |
| 95 | + | |
| 96 | +The backend agent runtime that actually performs work. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17]. | |
| 97 | + | |
| 98 | +#### Session | |
| 99 | + | |
| 100 | +The live provider-backed runtime attached to a thread. Session shape is in [the orchestration contracts][1], and lifecycle is managed in [ProviderService.ts][14]. | |
| 101 | + | |
| 102 | +#### Runtime mode | |
| 103 | + | |
| 104 | +The safety/access mode for a thread or session. In [the contracts][1], the main values are `approval-required` and `full-access`. See [runtime-modes.md][18]. | |
| 105 | + | |
| 106 | +#### Interaction mode | |
| 107 | + | |
| 108 | +The agent interaction style for a thread. In [the contracts][1], the main values are `default` and `plan`. See [runtime-modes.md][18]. | |
| 109 | + | |
| 110 | +#### Assistant delivery mode | |
| 111 | + | |
| 112 | +Controls how assistant text reaches the thread timeline. In [the contracts][1], `streaming` updates incrementally and `buffered` delivers a completed result. See [ProviderService.ts][14]. | |
| 113 | + | |
| 114 | +#### Snapshot | |
| 115 | + | |
| 116 | +A point-in-time view of state. The word is used in multiple layers, including orchestration, provider, and checkpointing. See [ProjectionSnapshotQuery.ts][10], [ProviderAdapter.ts][15], and [CheckpointStore.ts][19]. | |
| 117 | + | |
| 118 | +### Checkpointing | |
| 119 | + | |
| 120 | +Checkpointing captures workspace state over time so the app can diff turns and restore earlier points. The main pieces are [CheckpointStore.ts][19], [CheckpointDiffQuery.ts][20], and [CheckpointReactor.ts][6]. | |
| 121 | + | |
| 122 | +#### Checkpoint | |
| 123 | + | |
| 124 | +A saved snapshot of a thread workspace at a particular turn. In practice it is a hidden Git ref in [CheckpointStore.ts][19] plus a projected summary from [ProjectionCheckpoints.ts][21]. Capture and lifecycle work happen in [CheckpointReactor.ts][6]. | |
| 125 | + | |
| 126 | +#### Checkpoint ref | |
| 127 | + | |
| 128 | +The durable identifier for a filesystem checkpoint, stored as a Git ref. It is typed in [the contracts][1], constructed in [Utils.ts][22], and used by [CheckpointStore.ts][19]. | |
| 129 | + | |
| 130 | +#### Checkpoint baseline | |
| 131 | + | |
| 132 | +The starting checkpoint for diffing a thread timeline. This flow is surfaced through [RuntimeReceiptBus.ts][13], coordinated in [CheckpointReactor.ts][6], and supported by [Utils.ts][22]. | |
| 133 | + | |
| 134 | +#### Checkpoint diff | |
| 135 | + | |
| 136 | +The patch difference between two checkpoints. Query logic lives in [CheckpointDiffQuery.ts][20], diff parsing lives in [Diffs.ts][23], and finalization is coordinated by [CheckpointReactor.ts][6]. | |
| 137 | + | |
| 138 | +#### Turn diff | |
| 139 | + | |
| 140 | +The file patch and changed-file summary for one turn. It is usually computed in [CheckpointDiffQuery.ts][20], represented in [the contracts][1], and recorded into thread state by [projector.ts][4]. | |
| 141 | + | |
| 142 | +## Practical Shortcuts | |
| 143 | + | |
| 144 | +- If you see `requested`, think "intent recorded". | |
| 145 | +- If you see `completed`, think "result applied". | |
| 146 | +- If you see `receipt`, think "async milestone signal". | |
| 147 | +- If you see `checkpoint`, think "workspace snapshot for diff/restore". | |
| 148 | +- If you see `quiesced`, think "all relevant follow-up work has gone idle". | |
| 149 | + | |
| 150 | +## Related Docs | |
| 151 | + | |
| 152 | +- [architecture.md][24] | |
| 153 | +- [provider-architecture.md][16] | |
| 154 | +- [runtime-modes.md][18] | |
| 155 | +- [workspace-layout.md][2] | |
| 156 | + | |
| 157 | +[1]: ../packages/contracts/src/orchestration.ts | |
| 158 | +[2]: ./workspace-layout.md | |
| 159 | +[3]: ../apps/server/src/git/Layers/GitCore.ts | |
| 160 | +[4]: ../apps/server/src/orchestration/projector.ts | |
| 161 | +[5]: ../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts | |
| 162 | +[6]: ../apps/server/src/orchestration/Layers/CheckpointReactor.ts | |
| 163 | +[7]: ../apps/server/src/orchestration/Layers/OrchestrationEngine.ts | |
| 164 | +[8]: ../apps/server/src/orchestration/decider.ts | |
| 165 | +[9]: ../apps/server/src/orchestration/commandInvariants.ts | |
| 166 | +[10]: ../apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts | |
| 167 | +[11]: ../apps/server/src/orchestration/Layers/ProjectionPipeline.ts | |
| 168 | +[12]: ../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts | |
| 169 | +[13]: ../apps/server/src/orchestration/Services/RuntimeReceiptBus.ts | |
| 170 | +[14]: ../apps/server/src/provider/Layers/ProviderService.ts | |
| 171 | +[15]: ../apps/server/src/provider/Services/ProviderAdapter.ts | |
| 172 | +[16]: ./provider-architecture.md | |
| 173 | +[17]: ../apps/server/src/provider/Layers/CodexAdapter.ts | |
| 174 | +[18]: ./runtime-modes.md | |
| 175 | +[19]: ../apps/server/src/checkpointing/Services/CheckpointStore.ts | |
| 176 | +[20]: ../apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts | |
| 177 | +[21]: ../apps/server/src/persistence/Services/ProjectionCheckpoints.ts | |
| 178 | +[22]: ../apps/server/src/checkpointing/Utils.ts | |
| 179 | +[23]: ../apps/server/src/checkpointing/Diffs.ts | |
| 180 | +[24]: ./architecture.md | |
| @@ -0,0 +1,28 @@ | ||
| 1 | +# Trust list for this repository. | |
| 2 | +# | |
| 3 | +# External contributors listed here are treated as trusted by the vouch | |
| 4 | +# workflow. Collaborators with write access are automatically trusted and | |
| 5 | +# do not need to be duplicated in this file. | |
| 6 | +# | |
| 7 | +# Syntax: | |
| 8 | +# github:username | |
| 9 | +# -github:username reason for denouncement | |
| 10 | +# | |
| 11 | +# Keep entries sorted alphabetically. | |
| 12 | +github:adityavardhansharma | |
| 13 | +github:binbandit | |
| 14 | +github:chuks-qua | |
| 15 | +github:cursoragent | |
| 16 | +github:gbarros-dev | |
| 17 | +github:github-actions[bot] | |
| 18 | +github:hwanseoc | |
| 19 | +github:jamesx0416 | |
| 20 | +github:jasonLaster | |
| 21 | +github:JoeEverest | |
| 22 | +github:nmggithub | |
| 23 | +github:Noojuno | |
| 24 | +github:notkainoa | |
| 25 | +github:PatrickBauer | |
| 26 | +github:realAhmedRoach | |
| 27 | +github:shiroyasha9 | |
| 28 | +github:Yash-Singh1 | |
| @@ -0,0 +1,33 @@ | ||
| 1 | +<!-- | |
| 2 | +⚠️ READ BEFORE OPENING ⚠️ | |
| 3 | + | |
| 4 | +We are not actively accepting contributions right now. | |
| 5 | + | |
| 6 | +You can still open a PR, but please do so knowing there is a high chance | |
| 7 | +we may close it without merging it, or never review it. | |
| 8 | + | |
| 9 | +- Small, focused PRs are strongly preferred. Bug fixes are most likely to be merged. | |
| 10 | +- New features will most likely just annoy us. | |
| 11 | +- 1,000+ line PRs with a bunch of new features will probably get you banned from the repo. | |
| 12 | +--> | |
| 13 | + | |
| 14 | +## What Changed | |
| 15 | + | |
| 16 | +<!-- Describe the change clearly and keep scope tight. --> | |
| 17 | + | |
| 18 | +## Why | |
| 19 | + | |
| 20 | +<!-- Explain the problem being solved and why this approach is the right one. --> | |
| 21 | + | |
| 22 | +## UI Changes | |
| 23 | + | |
| 24 | +<!-- If this PR changes UI, include clear before/after screenshots. | |
| 25 | + If the change involves motion or interaction, include a short video. | |
| 26 | + Delete this section if not applicable. --> | |
| 27 | + | |
| 28 | +## Checklist | |
| 29 | + | |
| 30 | +- [ ] This PR is small and focused | |
| 31 | +- [ ] I explained what changed and why | |
| 32 | +- [ ] I included before/after screenshots for any UI changes | |
| 33 | +- [ ] I included a video for animation/interaction changes | |
| @@ -69,3 +69,23 @@ jobs: · 68 unmodified lines | ||
| 69 | 69 | run: | |
| 70 | 70 | test -f apps/desktop/dist-electron/preload.js |
| 71 | 71 | 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 | |
| @@ -0,0 +1,175 @@ | ||
| 1 | +name: PR Vouch | |
| 2 | + | |
| 3 | +on: | |
| 4 | + pull_request_target: | |
| 5 | + types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] | |
| 6 | + issue_comment: | |
| 7 | + types: [created] | |
| 8 | + push: | |
| 9 | + branches: | |
| 10 | + - main | |
| 11 | + paths: | |
| 12 | + - .github/VOUCHED.td | |
| 13 | + - .github/workflows/pr-vouch.yml | |
| 14 | + | |
| 15 | +permissions: | |
| 16 | + contents: read | |
| 17 | + issues: write | |
| 18 | + pull-requests: write | |
| 19 | + | |
| 20 | +jobs: | |
| 21 | + collect-targets: | |
| 22 | + name: Collect PR targets | |
| 23 | + runs-on: ubuntu-24.04 | |
| 24 | + outputs: | |
| 25 | + targets: ${{ steps.collect.outputs.targets }} | |
| 26 | + steps: | |
| 27 | + - id: collect | |
| 28 | + uses: actions/github-script@v7 | |
| 29 | + with: | |
| 30 | + script: | | |
| 31 | + if (context.eventName === "pull_request_target") { | |
| 32 | + const pr = context.payload.pull_request; | |
| 33 | + core.setOutput("targets", JSON.stringify([{ number: pr.number, user: pr.user.login }])); | |
| 34 | + return; | |
| 35 | + } | |
| 36 | + | |
| 37 | + if (context.eventName === "issue_comment") { | |
| 38 | + const issue = context.payload.issue; | |
| 39 | + const body = context.payload.comment?.body ?? ""; | |
| 40 | + if (!issue?.pull_request || !body.includes("/recheck-vouch")) { | |
| 41 | + core.setOutput("targets", "[]"); | |
| 42 | + return; | |
| 43 | + } | |
| 44 | + | |
| 45 | + core.setOutput( | |
| 46 | + "targets", | |
| 47 | + JSON.stringify([{ number: issue.number, user: issue.user.login }]), | |
| 48 | + ); | |
| 49 | + return; | |
| 50 | + } | |
| 51 | + | |
| 52 | + const pulls = await github.paginate(github.rest.pulls.list, { | |
| 53 | + owner: context.repo.owner, | |
| 54 | + repo: context.repo.repo, | |
| 55 | + state: "open", | |
| 56 | + per_page: 100, | |
| 57 | + }); | |
| 58 | + | |
| 59 | + const targets = pulls.map((pull) => ({ | |
| 60 | + number: pull.number, | |
| 61 | + user: pull.user.login, | |
| 62 | + })); | |
| 63 | + core.setOutput("targets", JSON.stringify(targets)); | |
| 64 | + | |
| 65 | + label: | |
| 66 | + name: Label PR ${{ matrix.target.number }} | |
| 67 | + needs: collect-targets | |
| 68 | + if: ${{ needs.collect-targets.outputs.targets != '[]' }} | |
| 69 | + runs-on: ubuntu-24.04 | |
| 70 | + concurrency: | |
| 71 | + group: pr-vouch-${{ matrix.target.number }} | |
| 72 | + cancel-in-progress: true | |
| 73 | + strategy: | |
| 74 | + fail-fast: false | |
| 75 | + matrix: | |
| 76 | + target: ${{ fromJson(needs.collect-targets.outputs.targets) }} | |
| 77 | + steps: | |
| 78 | + - id: vouch | |
| 79 | + name: Check PR author trust | |
| 80 | + uses: mitchellh/vouch/action/check-user@v1 | |
| 81 | + with: | |
| 82 | + user: ${{ matrix.target.user }} | |
| 83 | + allow-fail: true | |
| 84 | + env: | |
| 85 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 86 | + | |
| 87 | + - name: Sync PR labels | |
| 88 | + uses: actions/github-script@v7 | |
| 89 | + env: | |
| 90 | + PR_NUMBER: ${{ matrix.target.number }} | |
| 91 | + VOUCH_STATUS: ${{ steps.vouch.outputs.status }} | |
| 92 | + with: | |
| 93 | + script: | | |
| 94 | + const issueNumber = Number(process.env.PR_NUMBER); | |
| 95 | + const status = process.env.VOUCH_STATUS; | |
| 96 | + const managedLabels = [ | |
| 97 | + { | |
| 98 | + name: "vouch:trusted", | |
| 99 | + color: "1f883d", | |
| 100 | + description: "PR author is trusted by repo permissions or the VOUCHED list.", | |
| 101 | + }, | |
| 102 | + { | |
| 103 | + name: "vouch:unvouched", | |
| 104 | + color: "fbca04", | |
| 105 | + description: "PR author is not yet trusted in the VOUCHED list.", | |
| 106 | + }, | |
| 107 | + { | |
| 108 | + name: "vouch:denounced", | |
| 109 | + color: "d1242f", | |
| 110 | + description: "PR author is explicitly blocked by the VOUCHED list.", | |
| 111 | + }, | |
| 112 | + ]; | |
| 113 | + | |
| 114 | + const managedLabelNames = managedLabels.map((label) => label.name); | |
| 115 | + | |
| 116 | + for (const label of managedLabels) { | |
| 117 | + try { | |
| 118 | + const { data: existing } = await github.rest.issues.getLabel({ | |
| 119 | + owner: context.repo.owner, | |
| 120 | + repo: context.repo.repo, | |
| 121 | + name: label.name, | |
| 122 | + }); | |
| 123 | + | |
| 124 | + if ( | |
| 125 | + existing.color !== label.color || | |
| 126 | + (existing.description ?? "") !== label.description | |
| 127 | + ) { | |
| 128 | + await github.rest.issues.updateLabel({ | |
| 129 | + owner: context.repo.owner, | |
| 130 | + repo: context.repo.repo, | |
| 131 | + name: label.name, | |
| 132 | + color: label.color, | |
| 133 | + description: label.description, | |
| 134 | + }); | |
| 135 | + } | |
| 136 | + } catch (error) { | |
| 137 | + if (error.status !== 404) { | |
| 138 | + throw error; | |
| 139 | + } | |
| 140 | + | |
| 141 | + await github.rest.issues.createLabel({ | |
| 142 | + owner: context.repo.owner, | |
| 143 | + repo: context.repo.repo, | |
| 144 | + name: label.name, | |
| 145 | + color: label.color, | |
| 146 | + description: label.description, | |
| 147 | + }); | |
| 148 | + } | |
| 149 | + } | |
| 150 | + | |
| 151 | + const nextLabelName = | |
| 152 | + status === "denounced" | |
| 153 | + ? "vouch:denounced" | |
| 154 | + : ["bot", "collaborator", "vouched"].includes(status) | |
| 155 | + ? "vouch:trusted" | |
| 156 | + : "vouch:unvouched"; | |
| 157 | + | |
| 158 | + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ | |
| 159 | + owner: context.repo.owner, | |
| 160 | + repo: context.repo.repo, | |
| 161 | + issue_number: issueNumber, | |
| 162 | + }); | |
| 163 | + | |
| 164 | + const preservedLabels = currentLabels | |
| 165 | + .map((label) => label.name) | |
| 166 | + .filter((name) => !managedLabelNames.includes(name)); | |
| 167 | + | |
| 168 | + await github.rest.issues.setLabels({ | |
| 169 | + owner: context.repo.owner, | |
| 170 | + repo: context.repo.repo, | |
| 171 | + issue_number: issueNumber, | |
| 172 | + labels: [...preservedLabels, nextLabelName], | |
| 173 | + }); | |
| 174 | + | |
| 175 | + core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`); | |
| @@ -255,6 +255,16 @@ jobs: · 254 unmodified lines | ||
| 255 | 255 | needs: [preflight, build, publish_cli] |
| 256 | 256 | runs-on: ubuntu-24.04 |
| 257 | 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 | 268 | - name: Download all desktop artifacts |
| 259 | 269 | uses: actions/download-artifact@v4 |
| 260 | 270 | with: |
| @@ -312,33 +322,7 @@ jobs: · 51 unmodified lines | ||
| 312 | 322 | name: Update version strings |
| 313 | 323 | env: |
| 314 | 324 | RELEASE_VERSION: ${{ needs.preflight.outputs.version }} |
| 315 | − run: | | |
| 316 | − node --input-type=module -e ' | |
| 317 | − import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; | |
| 318 | − | |
| 319 | − const files = [ | |
| 320 | − "apps/server/package.json", | |
| 321 | − "apps/desktop/package.json", | |
| 322 | − "apps/web/package.json", | |
| 323 | − "packages/contracts/package.json", | |
| 324 | − ]; | |
| 325 | − | |
| 326 | − let changed = false; | |
| 327 | − for (const file of files) { | |
| 328 | − const packageJson = JSON.parse(readFileSync(file, "utf8")); | |
| 329 | − if (packageJson.version !== process.env.RELEASE_VERSION) { | |
| 330 | − packageJson.version = process.env.RELEASE_VERSION; | |
| 331 | − writeFileSync(file, `${JSON.stringify(packageJson, null, 2)}\n`); | |
| 332 | − changed = true; | |
| 333 | − } | |
| 334 | − } | |
| 335 | − | |
| 336 | − if (!changed) { | |
| 337 | − console.log("All package.json versions already match release version."); | |
| 338 | − } | |
| 339 | − | |
| 340 | − appendFileSync(process.env.GITHUB_OUTPUT, `changed=${changed}\n`); | |
| 341 | − ' | |
| 325 | + run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output | |
| 342 | 326 | |
| 343 | 327 | - name: Format package.json files |
| 344 | 328 | if: steps.update_versions.outputs.changed == 'true' |
| @@ -346,7 +330,7 @@ jobs: · 1 unmodified line | ||
| 346 | 330 | |
| 347 | 331 | - name: Refresh lockfile |
| 348 | 332 | if: steps.update_versions.outputs.changed == 'true' |
| 349 | − run: bun install | |
| 333 | + run: bun install --lockfile-only --ignore-scripts | |
| 350 | 334 | |
| 351 | 335 | - name: Commit and push version bump |
| 352 | 336 | if: steps.update_versions.outputs.changed == 'true' |
| @@ -0,0 +1,65 @@ | ||
| 1 | +# Contributing | |
| 2 | + | |
| 3 | +## Read This First | |
| 4 | + | |
| 5 | +We are not actively accepting contributions right now. | |
| 6 | + | |
| 7 | +You can still open an issue or PR, but please do so knowing there is a high chance we close it, defer it forever, or never look at it. | |
| 8 | + | |
| 9 | +If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control. | |
| 10 | + | |
| 11 | +PRs are automatically labeled with a `vouch:*` trust status. | |
| 12 | + | |
| 13 | +If you are an external contributor, expect `vouch:unvouched` until we explicitly add you to [.github/VOUCHED.td](.github/VOUCHED.td). | |
| 14 | + | |
| 15 | +## What We Are Most Likely To Accept | |
| 16 | + | |
| 17 | +Small, focused bug fixes. | |
| 18 | + | |
| 19 | +Small reliability fixes. | |
| 20 | + | |
| 21 | +Small performance improvements. | |
| 22 | + | |
| 23 | +Tightly scoped maintenance work that clearly improves the project without changing its direction. | |
| 24 | + | |
| 25 | +## What We Are Least Likely To Accept | |
| 26 | + | |
| 27 | +Large PRs. | |
| 28 | + | |
| 29 | +Drive-by feature work. | |
| 30 | + | |
| 31 | +Opinionated rewrites. | |
| 32 | + | |
| 33 | +Anything that expands product scope without us asking for it first. | |
| 34 | + | |
| 35 | +If you open a 1,000+ line PR full of new features, we will probably close it quickly and remember that you ignored the clearly written instructions. | |
| 36 | + | |
| 37 | +## If You Still Want To Open A PR | |
| 38 | + | |
| 39 | +Keep it small. | |
| 40 | + | |
| 41 | +Explain exactly what changed. | |
| 42 | + | |
| 43 | +Explain exactly why the change should exist. | |
| 44 | + | |
| 45 | +Do not mix unrelated fixes together. | |
| 46 | + | |
| 47 | +If the PR makes anything resembling a UI change, include clear before/after images. | |
| 48 | + | |
| 49 | +If the change depends on motion, timing, transitions, or interaction details, include a short video. | |
| 50 | + | |
| 51 | +If we have to guess what changed, we are much less likely to review it. | |
| 52 | + | |
| 53 | +## Issues First | |
| 54 | + | |
| 55 | +If you are thinking about a non-trivial change, open an issue first. | |
| 56 | + | |
| 57 | +That still does not mean we will want the PR, but it gives you a chance to avoid wasting your time. | |
| 58 | + | |
| 59 | +## Be Realistic | |
| 60 | + | |
| 61 | +Opening a PR does not create an obligation on our side. | |
| 62 | + | |
| 63 | +We may close it. We may ignore it. We may ask you to shrink it. We may reimplement the idea ourselves later. | |
| 64 | + | |
| 65 | +If you are fine with that, proceed. | |
| @@ -21,4 +21,8 @@ We are very very early in this project. Expect bugs. · 20 unmodified lines | ||
| 21 | 21 | |
| 22 | 22 | We are not accepting contributions yet. |
| 23 | 23 | |
| 24 | +## If you REALLY want to contribute still.... read this first | |
| 25 | + | |
| 26 | +Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. | |
| 27 | + | |
| 24 | 28 | Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@t3tools/desktop", |
| 3 | − "version": "0.0.5", | |
| 3 | + "version": "0.0.9", | |
| 4 | 4 | "private": true, |
| 5 | 5 | "main": "dist-electron/main.js", |
| 6 | 6 | "scripts": { |
| @@ -31,6 +31,7 @@ import { · 30 unmodified lines | ||
| 31 | 31 | reduceDesktopUpdateStateOnNoUpdate, |
| 32 | 32 | reduceDesktopUpdateStateOnUpdateAvailable, |
| 33 | 33 | } from "./updateMachine"; |
| 34 | +import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; | |
| 34 | 35 | |
| 35 | 36 | fixPath(); |
| 36 | 37 | |
| @@ -50,6 +51,8 @@ const ROOT_DIR = Path.resolve(__dirname, "../../.."); · 13 unmodified lines | ||
| 50 | 51 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 51 | 52 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; |
| 52 | 53 | const APP_USER_MODEL_ID = "com.t3tools.t3code"; |
| 54 | +const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code"; | |
| 55 | +const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | |
| 53 | 56 | const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; |
| 54 | 57 | const COMMIT_HASH_DISPLAY_LENGTH = 12; |
| 55 | 58 | const LOG_DIR = Path.join(STATE_DIR, "logs"); |
| @@ -78,7 +81,13 @@ let backendLogSink: RotatingFileSink | null = null; · 22 unmodified lines | ||
| 78 | 81 | let restoreStdIoCapture: (() => void) | null = null; |
| 79 | 82 | |
| 80 | 83 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; |
| 81 | −const initialUpdateState = (): DesktopUpdateState => createInitialDesktopUpdateState(app.getVersion()); | |
| 84 | +const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ | |
| 85 | + platform: process.platform, | |
| 86 | + processArch: process.arch, | |
| 87 | + runningUnderArm64Translation: app.runningUnderARM64Translation === true, | |
| 88 | +}); | |
| 89 | +const initialUpdateState = (): DesktopUpdateState => | |
| 90 | + createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo); | |
| 82 | 91 | |
| 83 | 92 | function logTimestamp(): string { |
| 84 | 93 | return new Date().toISOString(); |
| @@ -112,6 +121,25 @@ function formatErrorMessage(error: unknown): string { · 27 unmodified lines | ||
| 112 | 121 | return String(error); |
| 113 | 122 | } |
| 114 | 123 | |
| 124 | +function getSafeExternalUrl(rawUrl: unknown): string | null { | |
| 125 | + if (typeof rawUrl !== "string" || rawUrl.length === 0) { | |
| 126 | + return null; | |
| 127 | + } | |
| 128 | + | |
| 129 | + let parsedUrl: URL; | |
| 130 | + try { | |
| 131 | + parsedUrl = new URL(rawUrl); | |
| 132 | + } catch { | |
| 133 | + return null; | |
| 134 | + } | |
| 135 | + | |
| 136 | + if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { | |
| 137 | + return null; | |
| 138 | + } | |
| 139 | + | |
| 140 | + return parsedUrl.toString(); | |
| 141 | +} | |
| 142 | + | |
| 115 | 143 | function writeDesktopStreamChunk( |
| 116 | 144 | streamName: "stdout" | "stderr", |
| 117 | 145 | chunk: unknown, |
| @@ -574,6 +602,34 @@ function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { · 456 unmodified lines | ||
| 574 | 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 | + */ | |
| 617 | +function resolveUserDataPath(): string { | |
| 618 | + const appDataBase = | |
| 619 | + process.platform === "win32" | |
| 620 | + ? process.env.APPDATA || Path.join(OS.homedir(), "AppData", "Roaming") | |
| 621 | + : process.platform === "darwin" | |
| 622 | + ? Path.join(OS.homedir(), "Library", "Application Support") | |
| 623 | + : process.env.XDG_CONFIG_HOME || Path.join(OS.homedir(), ".config"); | |
| 624 | + | |
| 625 | + const legacyPath = Path.join(appDataBase, LEGACY_USER_DATA_DIR_NAME); | |
| 626 | + if (FS.existsSync(legacyPath)) { | |
| 627 | + return legacyPath; | |
| 628 | + } | |
| 629 | + | |
| 630 | + return Path.join(appDataBase, USER_DATA_DIR_NAME); | |
| 631 | +} | |
| 632 | + | |
| 577 | 633 | function configureAppIdentity(): void { |
| 578 | 634 | app.setName(APP_DISPLAY_NAME); |
| 579 | 635 | const commitHash = resolveAboutCommitHash(); |
| @@ -659,6 +715,7 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed · 79 unmodified lines | ||
| 659 | 715 | } |
| 660 | 716 | updateDownloadInFlight = true; |
| 661 | 717 | setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState)); |
| 718 | + autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); | |
| 662 | 719 | console.info("[desktop-updater] Downloading update..."); |
| 663 | 720 | |
| 664 | 721 | try { |
| @@ -697,7 +754,7 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed · 32 unmodified lines | ||
| 697 | 754 | function configureAutoUpdater(): void { |
| 698 | 755 | const enabled = shouldEnableAutoUpdates(); |
| 699 | 756 | setUpdateState({ |
| 700 | − ...createInitialDesktopUpdateState(app.getVersion()), | |
| 757 | + ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo), | |
| 701 | 758 | enabled, |
| 702 | 759 | status: enabled ? "idle" : "disabled", |
| 703 | 760 | }); |
| @@ -731,8 +788,15 @@ function configureAutoUpdater(): void { · 27 unmodified lines | ||
| 731 | 788 | autoUpdater.channel = DESKTOP_UPDATE_CHANNEL; |
| 732 | 789 | autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE; |
| 733 | 790 | autoUpdater.allowDowngrade = false; |
| 791 | + autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); | |
| 734 | 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 | 800 | autoUpdater.on("checking-for-update", () => { |
| 737 | 801 | console.info("[desktop-updater] Looking for updates..."); |
| 738 | 802 | }); |
| @@ -1033,23 +1097,13 @@ function registerIpcHandlers(): void { · 294 unmodified lines | ||
| 1033 | 1097 | |
| 1034 | 1098 | ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); |
| 1035 | 1099 | ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { |
| 1036 | − if (typeof rawUrl !== "string" || rawUrl.length === 0) { | |
| 1037 | − return false; | |
| 1038 | − } | |
| 1039 | − | |
| 1040 | − let parsedUrl: URL; | |
| 1041 | − try { | |
| 1042 | − parsedUrl = new URL(rawUrl); | |
| 1043 | − } catch { | |
| 1044 | − return false; | |
| 1045 | − } | |
| 1046 | − | |
| 1047 | − if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { | |
| 1100 | + const externalUrl = getSafeExternalUrl(rawUrl); | |
| 1101 | + if (!externalUrl) { | |
| 1048 | 1102 | return false; |
| 1049 | 1103 | } |
| 1050 | 1104 | |
| 1051 | 1105 | try { |
| 1052 | − await shell.openExternal(parsedUrl.toString()); | |
| 1106 | + await shell.openExternal(externalUrl); | |
| 1053 | 1107 | return true; |
| 1054 | 1108 | } catch { |
| 1055 | 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 | 1207 | window.on("page-title-updated", (event) => { |
| 1119 | 1208 | event.preventDefault(); |
| 1120 | 1209 | window.setTitle(APP_DISPLAY_NAME); |
| @@ -1143,6 +1232,11 @@ function createWindow(): BrowserWindow { · 22 unmodified lines | ||
| 1143 | 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()`. | |
| 1238 | +app.setPath("userData", resolveUserDataPath()); | |
| 1239 | + | |
| 1146 | 1240 | configureAppIdentity(); |
| 1147 | 1241 | |
| 1148 | 1242 | async function bootstrap(): Promise<void> { |
| @@ -0,0 +1,49 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | + | |
| 3 | +import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; | |
| 4 | + | |
| 5 | +describe("resolveDesktopRuntimeInfo", () => { | |
| 6 | + it("detects Rosetta-translated Intel builds on Apple Silicon", () => { | |
| 7 | + const runtimeInfo = resolveDesktopRuntimeInfo({ | |
| 8 | + platform: "darwin", | |
| 9 | + processArch: "x64", | |
| 10 | + runningUnderArm64Translation: true, | |
| 11 | + }); | |
| 12 | + | |
| 13 | + expect(runtimeInfo).toEqual({ | |
| 14 | + hostArch: "arm64", | |
| 15 | + appArch: "x64", | |
| 16 | + runningUnderArm64Translation: true, | |
| 17 | + }); | |
| 18 | + expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(true); | |
| 19 | + }); | |
| 20 | + | |
| 21 | + it("detects native Apple Silicon builds", () => { | |
| 22 | + const runtimeInfo = resolveDesktopRuntimeInfo({ | |
| 23 | + platform: "darwin", | |
| 24 | + processArch: "arm64", | |
| 25 | + runningUnderArm64Translation: false, | |
| 26 | + }); | |
| 27 | + | |
| 28 | + expect(runtimeInfo).toEqual({ | |
| 29 | + hostArch: "arm64", | |
| 30 | + appArch: "arm64", | |
| 31 | + runningUnderArm64Translation: false, | |
| 32 | + }); | |
| 33 | + expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(false); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + it("passes through non-mac builds without translation", () => { | |
| 37 | + const runtimeInfo = resolveDesktopRuntimeInfo({ | |
| 38 | + platform: "linux", | |
| 39 | + processArch: "x64", | |
| 40 | + runningUnderArm64Translation: true, | |
| 41 | + }); | |
| 42 | + | |
| 43 | + expect(runtimeInfo).toEqual({ | |
| 44 | + hostArch: "x64", | |
| 45 | + appArch: "x64", | |
| 46 | + runningUnderArm64Translation: false, | |
| 47 | + }); | |
| 48 | + }); | |
| 49 | +}); | |
| @@ -0,0 +1,40 @@ | ||
| 1 | +import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts"; | |
| 2 | + | |
| 3 | +interface ResolveDesktopRuntimeInfoInput { | |
| 4 | + readonly platform: NodeJS.Platform; | |
| 5 | + readonly processArch: string; | |
| 6 | + readonly runningUnderArm64Translation: boolean; | |
| 7 | +} | |
| 8 | + | |
| 9 | +function normalizeDesktopArch(arch: string): DesktopRuntimeArch { | |
| 10 | + if (arch === "arm64") return "arm64"; | |
| 11 | + if (arch === "x64") return "x64"; | |
| 12 | + return "other"; | |
| 13 | +} | |
| 14 | + | |
| 15 | +export function resolveDesktopRuntimeInfo( | |
| 16 | + input: ResolveDesktopRuntimeInfoInput, | |
| 17 | +): DesktopRuntimeInfo { | |
| 18 | + const appArch = normalizeDesktopArch(input.processArch); | |
| 19 | + | |
| 20 | + if (input.platform !== "darwin") { | |
| 21 | + return { | |
| 22 | + hostArch: appArch, | |
| 23 | + appArch, | |
| 24 | + runningUnderArm64Translation: false, | |
| 25 | + }; | |
| 26 | + } | |
| 27 | + | |
| 28 | + const hostArch = | |
| 29 | + appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; | |
| 30 | + | |
| 31 | + return { | |
| 32 | + hostArch, | |
| 33 | + appArch, | |
| 34 | + runningUnderArm64Translation: input.runningUnderArm64Translation, | |
| 35 | + }; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean { | |
| 39 | + return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; | |
| 40 | +} | |
| @@ -13,11 +13,17 @@ import { · 12 unmodified lines | ||
| 13 | 13 | reduceDesktopUpdateStateOnUpdateAvailable, |
| 14 | 14 | } from "./updateMachine"; |
| 15 | 15 | |
| 16 | +const runtimeInfo = { | |
| 17 | + hostArch: "x64", | |
| 18 | + appArch: "x64", | |
| 19 | + runningUnderArm64Translation: false, | |
| 20 | +} as const; | |
| 21 | + | |
| 16 | 22 | describe("updateMachine", () => { |
| 17 | 23 | it("clears transient errors when a check starts", () => { |
| 18 | 24 | const state = reduceDesktopUpdateStateOnCheckStart( |
| 19 | 25 | { |
| 20 | − ...createInitialDesktopUpdateState("1.0.0"), | |
| 26 | + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 21 | 27 | enabled: true, |
| 22 | 28 | status: "error", |
| 23 | 29 | message: "network", |
| @@ -36,7 +42,7 @@ describe("updateMachine", () => { · 12 unmodified lines | ||
| 36 | 42 | it("records a check failure without exposing an action", () => { |
| 37 | 43 | const state = reduceDesktopUpdateStateOnCheckFailure( |
| 38 | 44 | { |
| 39 | − ...createInitialDesktopUpdateState("1.0.0"), | |
| 45 | + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 40 | 46 | enabled: true, |
| 41 | 47 | status: "checking", |
| 42 | 48 | }, |
| @@ -52,7 +58,7 @@ describe("updateMachine", () => { · 9 unmodified lines | ||
| 52 | 58 | it("preserves available version on download failure for retry", () => { |
| 53 | 59 | const state = reduceDesktopUpdateStateOnDownloadFailure( |
| 54 | 60 | { |
| 55 | − ...createInitialDesktopUpdateState("1.0.0"), | |
| 61 | + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 56 | 62 | enabled: true, |
| 57 | 63 | status: "downloading", |
| 58 | 64 | availableVersion: "1.1.0", |
| @@ -70,7 +76,7 @@ describe("updateMachine", () => { · 11 unmodified lines | ||
| 70 | 76 | it("transitions to downloaded and then preserves install retry state", () => { |
| 71 | 77 | const downloaded = reduceDesktopUpdateStateOnDownloadComplete( |
| 72 | 78 | { |
| 73 | − ...createInitialDesktopUpdateState("1.0.0"), | |
| 79 | + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 74 | 80 | enabled: true, |
| 75 | 81 | status: "downloading", |
| 76 | 82 | availableVersion: "1.1.0", |
| @@ -89,7 +95,7 @@ describe("updateMachine", () => { · 12 unmodified lines | ||
| 89 | 95 | it("clears stale download state when no update is available", () => { |
| 90 | 96 | const state = reduceDesktopUpdateStateOnNoUpdate( |
| 91 | 97 | { |
| 92 | − ...createInitialDesktopUpdateState("1.0.0"), | |
| 98 | + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 93 | 99 | enabled: true, |
| 94 | 100 | status: "error", |
| 95 | 101 | availableVersion: "1.1.0", |
| @@ -111,7 +117,7 @@ describe("updateMachine", () => { · 15 unmodified lines | ||
| 111 | 117 | it("tracks available, download start, and progress cleanly", () => { |
| 112 | 118 | const available = reduceDesktopUpdateStateOnUpdateAvailable( |
| 113 | 119 | { |
| 114 | − ...createInitialDesktopUpdateState("1.0.0"), | |
| 120 | + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 115 | 121 | enabled: true, |
| 116 | 122 | status: "checking", |
| 117 | 123 | }, |
| @@ -1,12 +1,18 @@ | ||
| 1 | −import type { DesktopUpdateState } from "@t3tools/contracts"; | |
| 1 | +import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts"; | |
| 2 | 2 | |
| 3 | 3 | import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState"; |
| 4 | 4 | |
| 5 | −export function createInitialDesktopUpdateState(currentVersion: string): DesktopUpdateState { | |
| 5 | +export function createInitialDesktopUpdateState( | |
| 6 | + currentVersion: string, | |
| 7 | + runtimeInfo: DesktopRuntimeInfo, | |
| 8 | +): DesktopUpdateState { | |
| 6 | 9 | return { |
| 7 | 10 | enabled: false, |
| 8 | 11 | status: "disabled", |
| 9 | 12 | currentVersion, |
| 13 | + hostArch: runtimeInfo.hostArch, | |
| 14 | + appArch: runtimeInfo.appArch, | |
| 15 | + runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation, | |
| 10 | 16 | availableVersion: null, |
| 11 | 17 | downloadedVersion: null, |
| 12 | 18 | downloadPercent: null, |
| @@ -152,4 +158,3 @@ export function reduceDesktopUpdateStateOnInstallFailure( · 139 unmodified lines | ||
| 152 | 158 | canRetry: true, |
| 153 | 159 | }; |
| 154 | 160 | } |
| 155 | − | |
| @@ -12,6 +12,9 @@ const baseState: DesktopUpdateState = { · 11 unmodified lines | ||
| 12 | 12 | enabled: true, |
| 13 | 13 | status: "idle", |
| 14 | 14 | currentVersion: "1.0.0", |
| 15 | + hostArch: "x64", | |
| 16 | + appArch: "x64", | |
| 17 | + runningUnderArm64Translation: false, | |
| 15 | 18 | availableVersion: null, |
| 16 | 19 | downloadedVersion: null, |
| 17 | 20 | downloadPercent: null, |
| @@ -77,12 +77,10 @@ · 76 unmodified lines | ||
| 77 | 77 | const ua = navigator.userAgent; |
| 78 | 78 | if (/Win/i.test(ua)) return { os: "win", label: "Download for Windows" }; |
| 79 | 79 | if (/Mac/i.test(ua)) { |
| 80 | − const isArm = | |
| 81 | − /arm|aarch/i.test(navigator.platform ?? "") || ua.includes("ARM"); | |
| 82 | 80 | return { |
| 83 | 81 | os: "mac", |
| 84 | 82 | label: `Download for macOS`, |
| 85 | − arch: isArm ? "arm64" : "x64", | |
| 83 | + arch: "arm64", | |
| 86 | 84 | }; |
| 87 | 85 | } |
| 88 | 86 | if (/Linux/i.test(ua)) return { os: "linux", label: "Download for Linux" }; |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "t3", |
| 3 | − "version": "0.0.5", | |
| 3 | + "version": "0.0.9", | |
| 4 | 4 | "repository": { |
| 5 | 5 | "type": "git", |
| 6 | 6 | "url": "https://github.com/pingdotgg/t3code", |
| @@ -132,6 +132,7 @@ const buildCmd = Command.make( · 131 unmodified lines | ||
| 132 | 132 | cwd: serverDir, |
| 133 | 133 | stdout: config.verbose ? "inherit" : "ignore", |
| 134 | 134 | stderr: "inherit", |
| 135 | + // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). | |
| 135 | 136 | shell: process.platform === "win32", |
| 136 | 137 | })`bun tsdown`, |
| 137 | 138 | ); |
| @@ -226,6 +227,7 @@ const publishCmd = Command.make( · 88 unmodified lines | ||
| 226 | 227 | cwd: serverDir, |
| 227 | 228 | stdout: config.verbose ? "inherit" : "ignore", |
| 228 | 229 | stderr: "inherit", |
| 230 | + // Windows needs shell mode to resolve .cmd shims. | |
| 229 | 231 | shell: process.platform === "win32", |
| 230 | 232 | }), |
| 231 | 233 | ); |
| @@ -5,7 +5,6 @@ import { · 4 unmodified lines | ||
| 5 | 5 | type OrchestrationEvent, |
| 6 | 6 | type ProviderModelOptions, |
| 7 | 7 | type ProviderKind, |
| 8 | − type ProviderServiceTier, | |
| 9 | 8 | type ProviderStartOptions, |
| 10 | 9 | type OrchestrationSession, |
| 11 | 10 | ThreadId, |
| @@ -206,7 +205,6 @@ const make = Effect.gen(function* () { · 194 unmodified lines | ||
| 206 | 205 | readonly provider?: ProviderKind; |
| 207 | 206 | readonly model?: string; |
| 208 | 207 | readonly modelOptions?: ProviderModelOptions; |
| 209 | − readonly serviceTier?: ProviderServiceTier | null; | |
| 210 | 208 | readonly providerOptions?: ProviderStartOptions; |
| 211 | 209 | }, |
| 212 | 210 | ) { |
| @@ -242,7 +240,6 @@ const make = Effect.gen(function* () { · 29 unmodified lines | ||
| 242 | 240 | : {}), |
| 243 | 241 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), |
| 244 | 242 | ...(desiredModel ? { model: desiredModel } : {}), |
| 245 | − ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}), | |
| 246 | 243 | ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), |
| 247 | 244 | ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}), |
| 248 | 245 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), |
| @@ -328,7 +325,6 @@ const make = Effect.gen(function* () { · 79 unmodified lines | ||
| 328 | 325 | readonly attachments?: ReadonlyArray<ChatAttachment>; |
| 329 | 326 | readonly provider?: ProviderKind; |
| 330 | 327 | readonly model?: string; |
| 331 | − readonly serviceTier?: ProviderServiceTier | null; | |
| 332 | 328 | readonly modelOptions?: ProviderModelOptions; |
| 333 | 329 | readonly providerOptions?: ProviderStartOptions; |
| 334 | 330 | readonly interactionMode?: "default" | "plan"; |
| @@ -344,7 +340,6 @@ const make = Effect.gen(function* () { · 9 unmodified lines | ||
| 344 | 340 | yield* ensureSessionForThread(input.threadId, input.createdAt, { |
| 345 | 341 | ...(input.provider !== undefined ? { provider: input.provider } : {}), |
| 346 | 342 | ...(input.model !== undefined ? { model: input.model } : {}), |
| 347 | − ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), | |
| 348 | 343 | ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), |
| 349 | 344 | ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), |
| 350 | 345 | }); |
| @@ -366,7 +361,6 @@ const make = Effect.gen(function* () { · 15 unmodified lines | ||
| 366 | 361 | ...(normalizedInput ? { input: normalizedInput } : {}), |
| 367 | 362 | ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), |
| 368 | 363 | ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), |
| 369 | − ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), | |
| 370 | 364 | ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), |
| 371 | 365 | ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), |
| 372 | 366 | }); |
| @@ -481,9 +475,6 @@ const make = Effect.gen(function* () { · 108 unmodified lines | ||
| 481 | 475 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), |
| 482 | 476 | ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), |
| 483 | 477 | ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), |
| 484 | − ...(event.payload.serviceTier !== undefined | |
| 485 | − ? { serviceTier: event.payload.serviceTier } | |
| 486 | − : {}), | |
| 487 | 478 | ...(event.payload.modelOptions !== undefined |
| 488 | 479 | ? { modelOptions: event.payload.modelOptions } |
| 489 | 480 | : {}), |
| @@ -301,7 +301,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 300 unmodified lines | ||
| 301 | 301 | messageId: command.message.messageId, |
| 302 | 302 | ...(command.provider !== undefined ? { provider: command.provider } : {}), |
| 303 | 303 | ...(command.model !== undefined ? { model: command.model } : {}), |
| 304 | − ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}), | |
| 305 | 304 | ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), |
| 306 | 305 | ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}), |
| 307 | 306 | assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, |
| @@ -1354,7 +1354,6 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => · 1353 unmodified lines | ||
| 1354 | 1354 | threadId: input.threadId, |
| 1355 | 1355 | ...(input.input !== undefined ? { input: input.input } : {}), |
| 1356 | 1356 | ...(input.model !== undefined ? { model: input.model } : {}), |
| 1357 | − ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), | |
| 1358 | 1357 | ...(input.modelOptions?.codex?.reasoningEffort !== undefined |
| 1359 | 1358 | ? { effort: input.modelOptions.codex.reasoningEffort } |
| 1360 | 1359 | : {}), |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@t3tools/web", |
| 3 | − "version": "0.0.5", | |
| 3 | + "version": "0.0.9", | |
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| @@ -4,8 +4,6 @@ import { · 3 unmodified lines | ||
| 4 | 4 | getAppModelOptions, |
| 5 | 5 | getSlashModelOptions, |
| 6 | 6 | normalizeCustomModelSlugs, |
| 7 | − resolveAppServiceTier, | |
| 8 | − shouldShowFastTierIcon, | |
| 9 | 7 | resolveAppModelSelection, |
| 10 | 8 | } from "./appSettings"; |
| 11 | 9 | |
| @@ -84,22 +82,3 @@ describe("getSlashModelOptions", () => { · 72 unmodified lines | ||
| 84 | 82 | expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); |
| 85 | 83 | }); |
| 86 | 84 | }); |
| 87 | − | |
| 88 | −describe("resolveAppServiceTier", () => { | |
| 89 | − it("maps automatic to no override", () => { | |
| 90 | − expect(resolveAppServiceTier("auto")).toBeNull(); | |
| 91 | − }); | |
| 92 | − | |
| 93 | − it("preserves explicit service tier overrides", () => { | |
| 94 | − expect(resolveAppServiceTier("fast")).toBe("fast"); | |
| 95 | − expect(resolveAppServiceTier("flex")).toBe("flex"); | |
| 96 | − }); | |
| 97 | −}); | |
| 98 | − | |
| 99 | −describe("shouldShowFastTierIcon", () => { | |
| 100 | − it("shows the fast-tier icon only for gpt-5.4 on fast tier", () => { | |
| 101 | − expect(shouldShowFastTierIcon("gpt-5.4", "fast")).toBe(true); | |
| 102 | − expect(shouldShowFastTierIcon("gpt-5.4", "auto")).toBe(false); | |
| 103 | − expect(shouldShowFastTierIcon("gpt-5.3-codex", "fast")).toBe(false); | |
| 104 | − }); | |
| 105 | −}); | |
| @@ -1,31 +1,11 @@ | ||
| 1 | 1 | import { useCallback, useSyncExternalStore } from "react"; |
| 2 | 2 | import { Option, Schema } from "effect"; |
| 3 | −import { type ProviderKind, type ProviderServiceTier } from "@t3tools/contracts"; | |
| 3 | +import { type ProviderKind } from "@t3tools/contracts"; | |
| 4 | 4 | import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; |
| 5 | 5 | |
| 6 | 6 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; |
| 7 | 7 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| 8 | 8 | export const MAX_CUSTOM_MODEL_LENGTH = 256; |
| 9 | −export const APP_SERVICE_TIER_OPTIONS = [ | |
| 10 | − { | |
| 11 | − value: "auto", | |
| 12 | − label: "Automatic", | |
| 13 | − description: "Use Codex defaults without forcing a service tier.", | |
| 14 | − }, | |
| 15 | − { | |
| 16 | − value: "fast", | |
| 17 | − label: "Fast", | |
| 18 | − description: "Request the fast service tier when the model supports it.", | |
| 19 | − }, | |
| 20 | − { | |
| 21 | − value: "flex", | |
| 22 | − label: "Flex", | |
| 23 | − description: "Request the flex service tier when the model supports it.", | |
| 24 | − }, | |
| 25 | −] as const; | |
| 26 | −export type AppServiceTier = (typeof APP_SERVICE_TIER_OPTIONS)[number]["value"]; | |
| 27 | −const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]); | |
| 28 | −const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]); | |
| 29 | 9 | const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = { |
| 30 | 10 | codex: new Set(getModelOptions("codex").map((option) => option.slug)), |
| 31 | 11 | }; |
| @@ -41,7 +21,6 @@ const AppSettingsSchema = Schema.Struct({ · 9 unmodified lines | ||
| 41 | 21 | enableAssistantStreaming: Schema.Boolean.pipe( |
| 42 | 22 | Schema.withConstructorDefault(() => Option.some(false)), |
| 43 | 23 | ), |
| 44 | − codexServiceTier: AppServiceTierSchema.pipe(Schema.withConstructorDefault(() => Option.some("auto"))), | |
| 45 | 24 | customCodexModels: Schema.Array(Schema.String).pipe( |
| 46 | 25 | Schema.withConstructorDefault(() => Option.some([])), |
| 47 | 26 | ), |
| @@ -53,22 +32,6 @@ export interface AppModelOption { · 5 unmodified lines | ||
| 53 | 32 | isCustom: boolean; |
| 54 | 33 | } |
| 55 | 34 | |
| 56 | −export function resolveAppServiceTier(serviceTier: AppServiceTier): ProviderServiceTier | null { | |
| 57 | − return serviceTier === "auto" ? null : serviceTier; | |
| 58 | −} | |
| 59 | − | |
| 60 | −export function shouldShowFastTierIcon( | |
| 61 | − model: string | null | undefined, | |
| 62 | − serviceTier: AppServiceTier, | |
| 63 | −): boolean { | |
| 64 | − const normalizedModel = normalizeModelSlug(model); | |
| 65 | − return ( | |
| 66 | − resolveAppServiceTier(serviceTier) === "fast" && | |
| 67 | − normalizedModel !== null && | |
| 68 | − MODELS_WITH_FAST_SUPPORT.has(normalizedModel) | |
| 69 | − ); | |
| 70 | −} | |
| 71 | − | |
| 72 | 35 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); |
| 73 | 36 | |
| 74 | 37 | let listeners: Array<() => void> = []; |
| @@ -92,6 +92,8 @@ import { · 91 unmodified lines | ||
| 92 | 92 | buildPlanImplementationThreadTitle, |
| 93 | 93 | buildPlanImplementationPrompt, |
| 94 | 94 | buildProposedPlanMarkdownFilename, |
| 95 | + downloadPlanAsTextFile, | |
| 96 | + normalizePlanMarkdownForExport, | |
| 95 | 97 | proposedPlanTitle, |
| 96 | 98 | resolvePlanFollowUpSubmission, |
| 97 | 99 | } from "../proposedPlan"; |
| @@ -122,8 +124,9 @@ import { · 24 unmodified lines | ||
| 122 | 124 | shortcutLabelForCommand, |
| 123 | 125 | } from "../keybindings"; |
| 124 | 126 | import ChatMarkdown from "./ChatMarkdown"; |
| 127 | +import PlanSidebar from "./PlanSidebar"; | |
| 125 | 128 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 126 | −import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; | |
| 129 | +import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; | |
| 127 | 130 | import { |
| 128 | 131 | BotIcon, |
| 129 | 132 | ChevronDownIcon, |
| @@ -135,13 +138,13 @@ import { · 5 unmodified lines | ||
| 135 | 138 | DiffIcon, |
| 136 | 139 | EllipsisIcon, |
| 137 | 140 | FolderClosedIcon, |
| 141 | + ListTodoIcon, | |
| 138 | 142 | LockIcon, |
| 139 | 143 | LockOpenIcon, |
| 140 | 144 | Undo2Icon, |
| 141 | 145 | XIcon, |
| 142 | 146 | CopyIcon, |
| 143 | 147 | CheckIcon, |
| 144 | − ZapIcon, | |
| 145 | 148 | } from "lucide-react"; |
| 146 | 149 | import { Button } from "./ui/button"; |
| 147 | 150 | import { Input } from "./ui/input"; |
| @@ -201,9 +204,6 @@ import { readNativeApi } from "~/nativeApi"; · 53 unmodified lines | ||
| 201 | 204 | import { |
| 202 | 205 | getAppModelOptions, |
| 203 | 206 | resolveAppModelSelection, |
| 204 | − resolveAppServiceTier, | |
| 205 | − shouldShowFastTierIcon, | |
| 206 | − type AppServiceTier, | |
| 207 | 207 | useAppSettings, |
| 208 | 208 | } from "../appSettings"; |
| 209 | 209 | import { |
| @@ -291,21 +291,7 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { · 81 unmodified lines | ||
| 291 | 291 | return "text-muted-foreground/40"; |
| 292 | 292 | } |
| 293 | 293 | |
| 294 | −function normalizePlanMarkdownForExport(planMarkdown: string): string { | |
| 295 | − return `${planMarkdown.trimEnd()}\n`; | |
| 296 | −} | |
| 297 | 294 | |
| 298 | −function downloadTextFile(filename: string, contents: string): void { | |
| 299 | − const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" }); | |
| 300 | − const url = URL.createObjectURL(blob); | |
| 301 | − const anchor = document.createElement("a"); | |
| 302 | − anchor.href = url; | |
| 303 | − anchor.download = filename; | |
| 304 | − anchor.click(); | |
| 305 | − window.setTimeout(() => { | |
| 306 | − URL.revokeObjectURL(url); | |
| 307 | − }, 0); | |
| 308 | −} | |
| 309 | 295 | |
| 310 | 296 | interface ExpandedImageItem { |
| 311 | 297 | src: string; |
| @@ -420,7 +406,6 @@ type ComposerCommandItem = · 108 unmodified lines | ||
| 420 | 406 | model: ModelSlug; |
| 421 | 407 | label: string; |
| 422 | 408 | description: string; |
| 423 | − showFastBadge: boolean; | |
| 424 | 409 | }; |
| 425 | 410 | |
| 426 | 411 | type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; |
| @@ -531,9 +516,6 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { · 104 unmodified lines | ||
| 531 | 516 | </Badge> |
| 532 | 517 | ) : null} |
| 533 | 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 | 519 | <span className="truncate">{props.item.label}</span> |
| 538 | 520 | </span> |
| 539 | 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 | 638 | const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = |
| 657 | 639 | useState<Record<string, number>>({}); |
| 658 | 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 | 647 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 660 | 648 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 661 | 649 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| @@ -757,11 +745,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 95 unmodified lines | ||
| 757 | 745 | composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; |
| 758 | 746 | const isServerThread = serverThread !== undefined; |
| 759 | 747 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 760 | − const diffSearch = useMemo( | |
| 761 | − () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), | |
| 762 | − [rawSearch], | |
| 763 | − ); | |
| 764 | − const diffOpen = diffSearch.diff === "1"; | |
| 748 | + const diffOpen = rawSearch.diff === "1"; | |
| 765 | 749 | const activeThreadId = activeThread?.id ?? null; |
| 766 | 750 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 767 | 751 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| @@ -793,8 +777,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 25 unmodified lines | ||
| 793 | 777 | activeThread.messages.length > 0 || |
| 794 | 778 | activeThread.session !== null), |
| 795 | 779 | ); |
| 796 | − const selectedServiceTierSetting = settings.codexServiceTier; | |
| 797 | − const selectedServiceTier = resolveAppServiceTier(selectedServiceTierSetting); | |
| 798 | 780 | const lockedProvider: ProviderKind | null = hasThreadStarted |
| 799 | 781 | ? (sessionProvider ?? selectedProviderByThreadId ?? null) |
| 800 | 782 | : null; |
| @@ -1262,10 +1244,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 461 unmodified lines | ||
| 1262 | 1244 | model: slug, |
| 1263 | 1245 | label: name, |
| 1264 | 1246 | description: `${providerLabel} · ${slug}`, |
| 1265 | − showFastBadge: | |
| 1266 | − provider === "codex" && shouldShowFastTierIcon(slug, selectedServiceTierSetting), | |
| 1267 | 1247 | })); |
| 1268 | − }, [composerTrigger, searchableModelOptions, selectedServiceTierSetting, workspaceEntries]); | |
| 1248 | + }, [composerTrigger, searchableModelOptions, workspaceEntries]); | |
| 1269 | 1249 | const composerMenuOpen = Boolean(composerTrigger); |
| 1270 | 1250 | const activeComposerMenuItem = useMemo( |
| 1271 | 1251 | () => |
| @@ -1621,6 +1601,36 @@ export default function ChatView({ threadId }: ChatViewProps) { · 349 unmodified lines | ||
| 1621 | 1601 | }, |
| 1622 | 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 | 1635 | const handleRuntimeModeChange = useCallback( |
| 1626 | 1636 | (mode: RuntimeMode) => { |
| @@ -1763,6 +1773,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 136 unmodified lines | ||
| 1763 | 1773 | "button, summary, [role='button'], [data-scroll-anchor-target]", |
| 1764 | 1774 | ); |
| 1765 | 1775 | if (!trigger || !scrollContainer.contains(trigger)) return; |
| 1776 | + if (trigger.closest("[data-scroll-anchor-ignore]")) return; | |
| 1766 | 1777 | |
| 1767 | 1778 | pendingInteractionAnchorRef.current = { |
| 1768 | 1779 | element: trigger, |
| @@ -1911,8 +1922,17 @@ export default function ChatView({ threadId }: ChatViewProps) { · 142 unmodified lines | ||
| 1911 | 1922 | |
| 1912 | 1923 | useEffect(() => { |
| 1913 | 1924 | setExpandedWorkGroups({}); |
| 1925 | + if (planSidebarOpenOnNextThreadRef.current) { | |
| 1926 | + planSidebarOpenOnNextThreadRef.current = false; | |
| 1927 | + setPlanSidebarOpen(true); | |
| 1928 | + } else { | |
| 1929 | + setPlanSidebarOpen(false); | |
| 1930 | + } | |
| 1931 | + planSidebarDismissedForTurnRef.current = null; | |
| 1914 | 1932 | }, [activeThread?.id]); |
| 1915 | 1933 | |
| 1934 | + | |
| 1935 | + | |
| 1916 | 1936 | useEffect(() => { |
| 1917 | 1937 | if (!composerMenuOpen) { |
| 1918 | 1938 | setComposerHighlightedItemId(null); |
| @@ -2633,7 +2653,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 714 unmodified lines | ||
| 2633 | 2653 | attachments: turnAttachments, |
| 2634 | 2654 | }, |
| 2635 | 2655 | model: selectedModel || undefined, |
| 2636 | − serviceTier: selectedServiceTier, | |
| 2637 | 2656 | ...(selectedModelOptionsForDispatch |
| 2638 | 2657 | ? { modelOptions: selectedModelOptionsForDispatch } |
| 2639 | 2658 | : {}), |
| @@ -2925,6 +2944,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 285 unmodified lines | ||
| 2925 | 2944 | interactionMode: nextInteractionMode, |
| 2926 | 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 | 2954 | sendInFlightRef.current = false; |
| 2929 | 2955 | } catch (err) { |
| 2930 | 2956 | setOptimisticUserMessages((existing) => |
| @@ -3033,6 +3059,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 102 unmodified lines | ||
| 3033 | 3059 | .then(() => api.orchestration.getSnapshot()) |
| 3034 | 3060 | .then((snapshot) => { |
| 3035 | 3061 | syncServerReadModel(snapshot); |
| 3062 | + // Signal that the plan sidebar should open on the new thread. | |
| 3063 | + planSidebarOpenOnNextThreadRef.current = true; | |
| 3036 | 3064 | return navigate({ |
| 3037 | 3065 | to: "/$threadId", |
| 3038 | 3066 | params: { threadId: nextThreadId }, |
| @@ -3428,14 +3456,21 @@ export default function ChatView({ threadId }: ChatViewProps) { · 389 unmodified lines | ||
| 3428 | 3456 | }} |
| 3429 | 3457 | onAddProjectScript={saveProjectScript} |
| 3430 | 3458 | onUpdateProjectScript={updateProjectScript} |
| 3459 | + onDeleteProjectScript={deleteProjectScript} | |
| 3431 | 3460 | onToggleDiff={onToggleDiff} |
| 3432 | 3461 | /> |
| 3433 | 3462 | </header> |
| 3434 | 3463 | |
| 3435 | 3464 | {/* Error banner */} |
| 3436 | 3465 | <ProviderHealthBanner status={activeProviderStatus} /> |
| 3437 | − <ThreadErrorBanner error={activeThread.error} /> | |
| 3438 | − <PlanModePanel activePlan={activePlan} /> | |
| 3466 | + <ThreadErrorBanner | |
| 3467 | + error={activeThread.error} | |
| 3468 | + onDismiss={() => setThreadError(activeThread.id, null)} | |
| 3469 | + /> | |
| 3470 | + {/* Main content area with optional plan sidebar */} | |
| 3471 | + <div className="flex min-h-0 flex-1"> | |
| 3472 | + {/* Chat column */} | |
| 3473 | + <div className="flex min-h-0 min-w-0 flex-1 flex-col"> | |
| 3439 | 3474 | |
| 3440 | 3475 | {/* Messages */} |
| 3441 | 3476 | <div |
| @@ -3509,6 +3544,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 67 unmodified lines | ||
| 3509 | 3544 | answers={activePendingDraftAnswers} |
| 3510 | 3545 | questionIndex={activePendingQuestionIndex} |
| 3511 | 3546 | onSelectOption={onSelectActivePendingUserInputOption} |
| 3547 | + onAdvance={onAdvanceActivePendingUserInput} | |
| 3512 | 3548 | /> |
| 3513 | 3549 | </div> |
| 3514 | 3550 | ) : showPlanFollowUpPrompt && activeProposedPlan ? ( |
| @@ -3651,7 +3687,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 136 unmodified lines | ||
| 3651 | 3687 | model={selectedModelForPickerWithCustomFallback} |
| 3652 | 3688 | lockedProvider={lockedProvider} |
| 3653 | 3689 | modelOptionsByProvider={modelOptionsByProvider} |
| 3654 | − serviceTierSetting={selectedServiceTierSetting} | |
| 3655 | 3690 | onProviderModelChange={onProviderModelSelect} |
| 3656 | 3691 | /> |
| 3657 | 3692 | |
| @@ -3715,6 +3750,43 @@ export default function ChatView({ threadId }: ChatViewProps) { · 57 unmodified lines | ||
| 3715 | 3750 | {runtimeMode === "full-access" ? "Full access" : "Supervised"} |
| 3716 | 3751 | </span> |
| 3717 | 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 | 3790 | </div> |
| 3719 | 3791 | |
| 3720 | 3792 | {/* Right side: send / stop button */} |
| @@ -3881,6 +3953,27 @@ export default function ChatView({ threadId }: ChatViewProps) { · 160 unmodified lines | ||
| 3881 | 3953 | </form> |
| 3882 | 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 | 3977 | {isGitRepo && ( |
| 3885 | 3978 | <BranchToolbar |
| 3886 | 3979 | threadId={activeThread.id} |
| @@ -4005,6 +4098,7 @@ interface ChatHeaderProps { · 118 unmodified lines | ||
| 4005 | 4098 | onRunProjectScript: (script: ProjectScript) => void; |
| 4006 | 4099 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; |
| 4007 | 4100 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; |
| 4101 | + onDeleteProjectScript: (scriptId: string) => Promise<void>; | |
| 4008 | 4102 | onToggleDiff: () => void; |
| 4009 | 4103 | } |
| 4010 | 4104 | |
| @@ -4024,6 +4118,7 @@ const ChatHeader = memo(function ChatHeader({ · 13 unmodified lines | ||
| 4024 | 4118 | onRunProjectScript, |
| 4025 | 4119 | onAddProjectScript, |
| 4026 | 4120 | onUpdateProjectScript, |
| 4121 | + onDeleteProjectScript, | |
| 4027 | 4122 | onToggleDiff, |
| 4028 | 4123 | }: ChatHeaderProps) { |
| 4029 | 4124 | return ( |
| @@ -4056,6 +4151,7 @@ const ChatHeader = memo(function ChatHeader({ · 26 unmodified lines | ||
| 4056 | 4151 | onRunScript={onRunProjectScript} |
| 4057 | 4152 | onAddScript={onAddProjectScript} |
| 4058 | 4153 | onUpdateScript={onUpdateProjectScript} |
| 4154 | + onDeleteScript={onDeleteProjectScript} | |
| 4059 | 4155 | /> |
| 4060 | 4156 | )} |
| 4061 | 4157 | {activeProjectName && ( |
| @@ -4095,7 +4191,13 @@ const ChatHeader = memo(function ChatHeader({ · 33 unmodified lines | ||
| 4095 | 4191 | ); |
| 4096 | 4192 | }); |
| 4097 | 4193 | |
| 4098 | −const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: string | null }) { | |
| 4194 | +const ThreadErrorBanner = memo(function ThreadErrorBanner({ | |
| 4195 | + error, | |
| 4196 | + onDismiss, | |
| 4197 | +}: { | |
| 4198 | + error: string | null; | |
| 4199 | + onDismiss?: () => void; | |
| 4200 | +}) { | |
| 4099 | 4201 | if (!error) return null; |
| 4100 | 4202 | return ( |
| 4101 | 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 | 4206 | <AlertDescription className="line-clamp-3" title={error}> |
| 4105 | 4207 | {error} |
| 4106 | 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 | 4221 | </Alert> |
| 4108 | 4222 | </div> |
| 4109 | 4223 | ); |
| @@ -4219,61 +4333,13 @@ const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActi · 109 unmodified lines | ||
| 4219 | 4333 | ); |
| 4220 | 4334 | }); |
| 4221 | 4335 | |
| 4222 | −interface PlanModePanelProps { | |
| 4223 | − activePlan: ReturnType<typeof deriveActivePlanState>; | |
| 4224 | −} | |
| 4225 | − | |
| 4226 | −const PlanModePanel = memo(function PlanModePanel({ activePlan }: PlanModePanelProps) { | |
| 4227 | − if (!activePlan) return null; | |
| 4228 | − | |
| 4229 | − return ( | |
| 4230 | − <div className="pt-3 mx-auto max-w-3xl"> | |
| 4231 | − <div className="rounded-xl border border-border/70 bg-muted/30 p-4"> | |
| 4232 | − <div className="flex items-center gap-2"> | |
| 4233 | − <Badge variant="secondary">Plan</Badge> | |
| 4234 | − <span className="text-xs text-muted-foreground"> | |
| 4235 | − Updated {formatTimestamp(activePlan.createdAt)} | |
| 4236 | − </span> | |
| 4237 | − </div> | |
| 4238 | − {activePlan.explanation ? ( | |
| 4239 | − <p className="mt-2 text-sm text-muted-foreground">{activePlan.explanation}</p> | |
| 4240 | − ) : null} | |
| 4241 | − <div className="mt-3 space-y-2"> | |
| 4242 | − {activePlan.steps.map((step) => ( | |
| 4243 | − <div | |
| 4244 | − key={`${step.status}:${step.step}`} | |
| 4245 | − className="flex items-start gap-3 rounded-lg border border-border/60 bg-background/80 px-3 py-2" | |
| 4246 | − > | |
| 4247 | − <Badge | |
| 4248 | − variant={ | |
| 4249 | − step.status === "completed" | |
| 4250 | − ? "default" | |
| 4251 | − : step.status === "inProgress" | |
| 4252 | − ? "secondary" | |
| 4253 | − : "outline" | |
| 4254 | − } | |
| 4255 | − > | |
| 4256 | − {step.status === "inProgress" | |
| 4257 | − ? "In progress" | |
| 4258 | − : step.status === "completed" | |
| 4259 | − ? "Done" | |
| 4260 | − : "Pending"} | |
| 4261 | − </Badge> | |
| 4262 | − <div className="min-w-0 flex-1 text-sm">{step.step}</div> | |
| 4263 | − </div> | |
| 4264 | − ))} | |
| 4265 | − </div> | |
| 4266 | − </div> | |
| 4267 | − </div> | |
| 4268 | − ); | |
| 4269 | −}); | |
| 4270 | − | |
| 4271 | 4336 | interface PendingUserInputPanelProps { |
| 4272 | 4337 | pendingUserInputs: PendingUserInput[]; |
| 4273 | 4338 | respondingRequestIds: ApprovalRequestId[]; |
| 4274 | 4339 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 4275 | 4340 | questionIndex: number; |
| 4276 | 4341 | onSelectOption: (questionId: string, optionLabel: string) => void; |
| 4342 | + onAdvance: () => void; | |
| 4277 | 4343 | } |
| 4278 | 4344 | |
| 4279 | 4345 | const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ |
| @@ -4282,6 +4348,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane · 2 unmodified lines | ||
| 4282 | 4348 | answers, |
| 4283 | 4349 | questionIndex, |
| 4284 | 4350 | onSelectOption, |
| 4351 | + onAdvance, | |
| 4285 | 4352 | }: PendingUserInputPanelProps) { |
| 4286 | 4353 | if (pendingUserInputs.length === 0) return null; |
| 4287 | 4354 | const activePrompt = pendingUserInputs[0]; |
| @@ -4295,6 +4362,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane · 7 unmodified lines | ||
| 4295 | 4362 | answers={answers} |
| 4296 | 4363 | questionIndex={questionIndex} |
| 4297 | 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 | 4373 | answers, |
| 4306 | 4374 | questionIndex, |
| 4307 | 4375 | onSelectOption, |
| 4376 | + onAdvance, | |
| 4308 | 4377 | }: { |
| 4309 | 4378 | prompt: PendingUserInput; |
| 4310 | 4379 | isResponding: boolean; |
| 4311 | 4380 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 4312 | 4381 | questionIndex: number; |
| 4313 | 4382 | onSelectOption: (questionId: string, optionLabel: string) => void; |
| 4383 | + onAdvance: () => void; | |
| 4314 | 4384 | }) { |
| 4315 | 4385 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); |
| 4316 | 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 | 4446 | if (!activeQuestion) { |
| 4319 | 4447 | return null; |
| 4320 | 4448 | } |
| 4321 | 4449 | |
| 4322 | 4450 | return ( |
| 4323 | − <div className="px-4 py-4 sm:px-5"> | |
| 4324 | − <div className="flex gap-2"> | |
| 4325 | − <span className="uppercase text-sm tracking-[0.2em]"> | |
| 4326 | − {questionIndex + 1}/{prompt.questions.length} {activeQuestion.header} | |
| 4327 | − </span> | |
| 4328 | − <div className="text-sm font-medium">{activeQuestion.question}</div> | |
| 4451 | + <div className="px-4 py-3 sm:px-5"> | |
| 4452 | + <div className="flex items-center gap-3"> | |
| 4453 | + <div className="flex items-center gap-2"> | |
| 4454 | + {prompt.questions.length > 1 ? ( | |
| 4455 | + <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-[10px] font-medium tabular-nums text-muted-foreground/60"> | |
| 4456 | + {questionIndex + 1}/{prompt.questions.length} | |
| 4457 | + </span> | |
| 4458 | + ) : null} | |
| 4459 | + <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/50 uppercase"> | |
| 4460 | + {activeQuestion.header} | |
| 4461 | + </span> | |
| 4462 | + </div> | |
| 4329 | 4463 | </div> |
| 4330 | − <div className="mt-3 flex flex-wrap gap-2"> | |
| 4331 | − {activeQuestion.options.map((option) => { | |
| 4464 | + <p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p> | |
| 4465 | + <div className="mt-3 space-y-1"> | |
| 4466 | + {activeQuestion.options.map((option, index) => { | |
| 4332 | 4467 | const isSelected = progress.selectedOptionLabel === option.label; |
| 4468 | + const shortcutKey = index < 9 ? index + 1 : null; | |
| 4333 | 4469 | return ( |
| 4334 | − <Button | |
| 4470 | + <button | |
| 4335 | 4471 | key={`${activeQuestion.id}:${option.label}`} |
| 4336 | − size="sm" | |
| 4337 | − variant={isSelected ? "default" : "outline"} | |
| 4472 | + type="button" | |
| 4338 | 4473 | disabled={isResponding} |
| 4339 | − onClick={() => onSelectOption(activeQuestion.id, option.label)} | |
| 4340 | − title={option.description} | |
| 4474 | + onClick={() => selectOptionAndAutoAdvance(activeQuestion.id, option.label)} | |
| 4475 | + className={cn( | |
| 4476 | + "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150", | |
| 4477 | + isSelected | |
| 4478 | + ? "border-blue-500/40 bg-blue-500/8 text-foreground" | |
| 4479 | + : "border-transparent bg-muted/20 text-foreground/80 hover:bg-muted/40 hover:border-border/40", | |
| 4480 | + isResponding && "opacity-50 cursor-not-allowed", | |
| 4481 | + )} | |
| 4341 | 4482 | > |
| 4342 | − {option.label} | |
| 4343 | − </Button> | |
| 4483 | + {shortcutKey !== null ? ( | |
| 4484 | + <kbd | |
| 4485 | + className={cn( | |
| 4486 | + "flex size-5 shrink-0 items-center justify-center rounded text-[11px] font-medium tabular-nums transition-colors duration-150", | |
| 4487 | + isSelected | |
| 4488 | + ? "bg-blue-500/20 text-blue-400" | |
| 4489 | + : "bg-muted/40 text-muted-foreground/50 group-hover:bg-muted/60 group-hover:text-muted-foreground/70", | |
| 4490 | + )} | |
| 4491 | + > | |
| 4492 | + {shortcutKey} | |
| 4493 | + </kbd> | |
| 4494 | + ) : null} | |
| 4495 | + <div className="min-w-0 flex-1"> | |
| 4496 | + <span className="text-sm font-medium">{option.label}</span> | |
| 4497 | + {option.description && option.description !== option.label ? ( | |
| 4498 | + <span className="ml-2 text-xs text-muted-foreground/50">{option.description}</span> | |
| 4499 | + ) : null} | |
| 4500 | + </div> | |
| 4501 | + {isSelected ? ( | |
| 4502 | + <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> | |
| 4503 | + ) : null} | |
| 4504 | + </button> | |
| 4344 | 4505 | ); |
| 4345 | 4506 | })} |
| 4346 | 4507 | </div> |
| @@ -4554,7 +4715,7 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ · 207 unmodified lines | ||
| 4554 | 4715 | const saveContents = normalizePlanMarkdownForExport(planMarkdown); |
| 4555 | 4716 | |
| 4556 | 4717 | const handleDownload = () => { |
| 4557 | − downloadTextFile(downloadFilename, saveContents); | |
| 4718 | + downloadPlanAsTextFile(downloadFilename, saveContents); | |
| 4558 | 4719 | }; |
| 4559 | 4720 | |
| 4560 | 4721 | const openSaveDialog = () => { |
| @@ -4646,7 +4807,12 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ · 85 unmodified lines | ||
| 4646 | 4807 | </div> |
| 4647 | 4808 | {canCollapse ? ( |
| 4648 | 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 | 4816 | {expanded ? "Collapse plan" : "Expand plan"} |
| 4651 | 4817 | </Button> |
| 4652 | 4818 | </div> |
| @@ -5368,7 +5534,6 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 715 unmodified lines | ||
| 5368 | 5534 | model: ModelSlug; |
| 5369 | 5535 | lockedProvider: ProviderKind | null; |
| 5370 | 5536 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>; |
| 5371 | − serviceTierSetting: AppServiceTier; | |
| 5372 | 5537 | disabled?: boolean; |
| 5373 | 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 | 5567 | <span className="flex min-w-0 items-center gap-2"> |
| 5403 | 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 | 5569 | <span className="truncate">{selectedModelLabel}</span> |
| 5408 | 5570 | <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" /> |
| 5409 | 5571 | </span> |
| @@ -5446,10 +5608,6 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 36 unmodified lines | ||
| 5446 | 5608 | value={modelOption.slug} |
| 5447 | 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 | 5611 | {modelOption.name} |
| 5454 | 5612 | </MenuRadioItem> |
| 5455 | 5613 | ))} |
| @@ -0,0 +1,267 @@ | ||
| 1 | +import { memo, useState, useCallback } from "react"; | |
| 2 | +import { Badge } from "./ui/badge"; | |
| 3 | +import { Button } from "./ui/button"; | |
| 4 | +import { ScrollArea } from "./ui/scroll-area"; | |
| 5 | +import ChatMarkdown from "./ChatMarkdown"; | |
| 6 | +import { | |
| 7 | + CheckIcon, | |
| 8 | + ChevronDownIcon, | |
| 9 | + ChevronRightIcon, | |
| 10 | + EllipsisIcon, | |
| 11 | + LoaderIcon, | |
| 12 | + PanelRightCloseIcon, | |
| 13 | +} from "lucide-react"; | |
| 14 | +import { cn } from "~/lib/utils"; | |
| 15 | +import { formatTimestamp } from "../session-logic"; | |
| 16 | +import type { ActivePlanState } from "../session-logic"; | |
| 17 | +import type { LatestProposedPlanState } from "../session-logic"; | |
| 18 | +import { | |
| 19 | + proposedPlanTitle, | |
| 20 | + buildProposedPlanMarkdownFilename, | |
| 21 | + normalizePlanMarkdownForExport, | |
| 22 | + downloadPlanAsTextFile, | |
| 23 | +} from "../proposedPlan"; | |
| 24 | +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; | |
| 25 | +import { readNativeApi } from "~/nativeApi"; | |
| 26 | +import { toastManager } from "./ui/toast"; | |
| 27 | + | |
| 28 | +function stepStatusIcon(status: string): React.ReactNode { | |
| 29 | + if (status === "completed") { | |
| 30 | + return ( | |
| 31 | + <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-500"> | |
| 32 | + <CheckIcon className="size-3" /> | |
| 33 | + </span> | |
| 34 | + ); | |
| 35 | + } | |
| 36 | + if (status === "inProgress") { | |
| 37 | + return ( | |
| 38 | + <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-blue-500/15 text-blue-400"> | |
| 39 | + <LoaderIcon className="size-3 animate-spin" /> | |
| 40 | + </span> | |
| 41 | + ); | |
| 42 | + } | |
| 43 | + return ( | |
| 44 | + <span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30"> | |
| 45 | + <span className="size-1.5 rounded-full bg-muted-foreground/30" /> | |
| 46 | + </span> | |
| 47 | + ); | |
| 48 | +} | |
| 49 | + | |
| 50 | +interface PlanSidebarProps { | |
| 51 | + activePlan: ActivePlanState | null; | |
| 52 | + activeProposedPlan: LatestProposedPlanState | null; | |
| 53 | + markdownCwd: string | undefined; | |
| 54 | + workspaceRoot: string | undefined; | |
| 55 | + onClose: () => void; | |
| 56 | +} | |
| 57 | + | |
| 58 | +const PlanSidebar = memo(function PlanSidebar({ | |
| 59 | + activePlan, | |
| 60 | + activeProposedPlan, | |
| 61 | + markdownCwd, | |
| 62 | + workspaceRoot, | |
| 63 | + onClose, | |
| 64 | +}: PlanSidebarProps) { | |
| 65 | + const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); | |
| 66 | + const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); | |
| 67 | + const [copied, setCopied] = useState(false); | |
| 68 | + | |
| 69 | + const planMarkdown = activeProposedPlan?.planMarkdown ?? null; | |
| 70 | + const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; | |
| 71 | + | |
| 72 | + const handleCopyPlan = useCallback(() => { | |
| 73 | + if (!planMarkdown) return; | |
| 74 | + void navigator.clipboard.writeText(planMarkdown); | |
| 75 | + setCopied(true); | |
| 76 | + setTimeout(() => setCopied(false), 2000); | |
| 77 | + }, [planMarkdown]); | |
| 78 | + | |
| 79 | + const handleDownload = useCallback(() => { | |
| 80 | + if (!planMarkdown) return; | |
| 81 | + const filename = buildProposedPlanMarkdownFilename(planMarkdown); | |
| 82 | + downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown)); | |
| 83 | + }, [planMarkdown]); | |
| 84 | + | |
| 85 | + const handleSaveToWorkspace = useCallback(() => { | |
| 86 | + const api = readNativeApi(); | |
| 87 | + if (!api || !workspaceRoot || !planMarkdown) return; | |
| 88 | + const filename = buildProposedPlanMarkdownFilename(planMarkdown); | |
| 89 | + setIsSavingToWorkspace(true); | |
| 90 | + void api.projects | |
| 91 | + .writeFile({ | |
| 92 | + cwd: workspaceRoot, | |
| 93 | + relativePath: filename, | |
| 94 | + contents: normalizePlanMarkdownForExport(planMarkdown), | |
| 95 | + }) | |
| 96 | + .then((result) => { | |
| 97 | + toastManager.add({ | |
| 98 | + type: "success", | |
| 99 | + title: "Plan saved", | |
| 100 | + description: result.relativePath, | |
| 101 | + }); | |
| 102 | + }) | |
| 103 | + .catch((error) => { | |
| 104 | + toastManager.add({ | |
| 105 | + type: "error", | |
| 106 | + title: "Could not save plan", | |
| 107 | + description: | |
| 108 | + error instanceof Error ? error.message : "An error occurred.", | |
| 109 | + }); | |
| 110 | + }) | |
| 111 | + .then( | |
| 112 | + () => setIsSavingToWorkspace(false), | |
| 113 | + () => setIsSavingToWorkspace(false), | |
| 114 | + ); | |
| 115 | + }, [planMarkdown, workspaceRoot]); | |
| 116 | + | |
| 117 | + return ( | |
| 118 | + <div className="flex h-full w-[340px] shrink-0 flex-col border-l border-border/70 bg-card/50"> | |
| 119 | + {/* Header */} | |
| 120 | + <div className="flex h-12 shrink-0 items-center justify-between border-b border-border/60 px-3"> | |
| 121 | + <div className="flex items-center gap-2"> | |
| 122 | + <Badge | |
| 123 | + variant="secondary" | |
| 124 | + className="rounded-md bg-blue-500/10 px-1.5 py-0 text-[10px] font-semibold tracking-wide text-blue-400 uppercase" | |
| 125 | + > | |
| 126 | + Plan | |
| 127 | + </Badge> | |
| 128 | + {activePlan ? ( | |
| 129 | + <span className="text-[11px] text-muted-foreground/60"> | |
| 130 | + {formatTimestamp(activePlan.createdAt)} | |
| 131 | + </span> | |
| 132 | + ) : null} | |
| 133 | + </div> | |
| 134 | + <div className="flex items-center gap-1"> | |
| 135 | + {planMarkdown ? ( | |
| 136 | + <Menu> | |
| 137 | + <MenuTrigger | |
| 138 | + render={ | |
| 139 | + <Button | |
| 140 | + size="icon-xs" | |
| 141 | + variant="ghost" | |
| 142 | + className="text-muted-foreground/50 hover:text-foreground/70" | |
| 143 | + aria-label="Plan actions" | |
| 144 | + /> | |
| 145 | + } | |
| 146 | + > | |
| 147 | + <EllipsisIcon className="size-3.5" /> | |
| 148 | + </MenuTrigger> | |
| 149 | + <MenuPopup align="end"> | |
| 150 | + <MenuItem onClick={handleCopyPlan}> | |
| 151 | + {copied ? "Copied!" : "Copy to clipboard"} | |
| 152 | + </MenuItem> | |
| 153 | + <MenuItem onClick={handleDownload}>Download as markdown</MenuItem> | |
| 154 | + <MenuItem | |
| 155 | + onClick={handleSaveToWorkspace} | |
| 156 | + disabled={!workspaceRoot || isSavingToWorkspace} | |
| 157 | + > | |
| 158 | + Save to workspace | |
| 159 | + </MenuItem> | |
| 160 | + </MenuPopup> | |
| 161 | + </Menu> | |
| 162 | + ) : null} | |
| 163 | + <Button | |
| 164 | + size="icon-xs" | |
| 165 | + variant="ghost" | |
| 166 | + onClick={onClose} | |
| 167 | + aria-label="Close plan sidebar" | |
| 168 | + className="text-muted-foreground/50 hover:text-foreground/70" | |
| 169 | + > | |
| 170 | + <PanelRightCloseIcon className="size-3.5" /> | |
| 171 | + </Button> | |
| 172 | + </div> | |
| 173 | + </div> | |
| 174 | + | |
| 175 | + {/* Content */} | |
| 176 | + <ScrollArea className="min-h-0 flex-1"> | |
| 177 | + <div className="p-3 space-y-4"> | |
| 178 | + {/* Explanation */} | |
| 179 | + {activePlan?.explanation ? ( | |
| 180 | + <p className="text-[13px] leading-relaxed text-muted-foreground/80"> | |
| 181 | + {activePlan.explanation} | |
| 182 | + </p> | |
| 183 | + ) : null} | |
| 184 | + | |
| 185 | + {/* Plan Steps */} | |
| 186 | + {activePlan && activePlan.steps.length > 0 ? ( | |
| 187 | + <div className="space-y-1"> | |
| 188 | + <p className="mb-2 text-[10px] font-semibold tracking-widest text-muted-foreground/40 uppercase"> | |
| 189 | + Steps | |
| 190 | + </p> | |
| 191 | + {activePlan.steps.map((step) => ( | |
| 192 | + <div | |
| 193 | + key={`${step.status}:${step.step}`} | |
| 194 | + className={cn( | |
| 195 | + "flex items-start gap-2.5 rounded-lg px-2.5 py-2 transition-colors duration-200", | |
| 196 | + step.status === "inProgress" && "bg-blue-500/5", | |
| 197 | + step.status === "completed" && "bg-emerald-500/5", | |
| 198 | + )} | |
| 199 | + > | |
| 200 | + <div className="mt-0.5"> | |
| 201 | + {stepStatusIcon(step.status)} | |
| 202 | + </div> | |
| 203 | + <p | |
| 204 | + className={cn( | |
| 205 | + "text-[13px] leading-snug", | |
| 206 | + step.status === "completed" | |
| 207 | + ? "text-muted-foreground/50 line-through decoration-muted-foreground/20" | |
| 208 | + : step.status === "inProgress" | |
| 209 | + ? "text-foreground/90" | |
| 210 | + : "text-muted-foreground/70", | |
| 211 | + )} | |
| 212 | + > | |
| 213 | + {step.step} | |
| 214 | + </p> | |
| 215 | + </div> | |
| 216 | + ))} | |
| 217 | + </div> | |
| 218 | + ) : null} | |
| 219 | + | |
| 220 | + {/* Proposed Plan Markdown */} | |
| 221 | + {planMarkdown ? ( | |
| 222 | + <div className="space-y-2"> | |
| 223 | + <button | |
| 224 | + type="button" | |
| 225 | + className="group flex w-full items-center gap-1.5 text-left" | |
| 226 | + onClick={() => setProposedPlanExpanded((v) => !v)} | |
| 227 | + > | |
| 228 | + {proposedPlanExpanded ? ( | |
| 229 | + <ChevronDownIcon className="size-3 shrink-0 text-muted-foreground/40 transition-transform" /> | |
| 230 | + ) : ( | |
| 231 | + <ChevronRightIcon className="size-3 shrink-0 text-muted-foreground/40 transition-transform" /> | |
| 232 | + )} | |
| 233 | + <span className="text-[10px] font-semibold tracking-widest text-muted-foreground/40 uppercase group-hover:text-muted-foreground/60"> | |
| 234 | + {planTitle ?? "Full Plan"} | |
| 235 | + </span> | |
| 236 | + </button> | |
| 237 | + {proposedPlanExpanded ? ( | |
| 238 | + <div className="rounded-lg border border-border/50 bg-background/50 p-3"> | |
| 239 | + <ChatMarkdown | |
| 240 | + text={planMarkdown} | |
| 241 | + cwd={markdownCwd} | |
| 242 | + isStreaming={false} | |
| 243 | + /> | |
| 244 | + </div> | |
| 245 | + ) : null} | |
| 246 | + </div> | |
| 247 | + ) : null} | |
| 248 | + | |
| 249 | + {/* Empty state */} | |
| 250 | + {!activePlan && !planMarkdown ? ( | |
| 251 | + <div className="flex flex-col items-center justify-center py-12 text-center"> | |
| 252 | + <p className="text-[13px] text-muted-foreground/40"> | |
| 253 | + No active plan yet. | |
| 254 | + </p> | |
| 255 | + <p className="mt-1 text-[11px] text-muted-foreground/30"> | |
| 256 | + Plans will appear here when generated. | |
| 257 | + </p> | |
| 258 | + </div> | |
| 259 | + ) : null} | |
| 260 | + </div> | |
| 261 | + </ScrollArea> | |
| 262 | + </div> | |
| 263 | + ); | |
| 264 | +}); | |
| 265 | + | |
| 266 | +export default PlanSidebar; | |
| 267 | +export type { PlanSidebarProps }; | |
| @@ -14,7 +14,7 @@ import { · 13 unmodified lines | ||
| 14 | 14 | SettingsIcon, |
| 15 | 15 | WrenchIcon, |
| 16 | 16 | } from "lucide-react"; |
| 17 | −import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; | |
| 17 | +import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react"; | |
| 18 | 18 | |
| 19 | 19 | import { |
| 20 | 20 | keybindingValueForCommand, |
| @@ -27,6 +27,15 @@ import { · 6 unmodified lines | ||
| 27 | 27 | } from "~/projectScripts"; |
| 28 | 28 | import { shortcutLabelForCommand } from "~/keybindings"; |
| 29 | 29 | import { isMacPlatform } from "~/lib/utils"; |
| 30 | +import { | |
| 31 | + AlertDialog, | |
| 32 | + AlertDialogClose, | |
| 33 | + AlertDialogDescription, | |
| 34 | + AlertDialogFooter, | |
| 35 | + AlertDialogHeader, | |
| 36 | + AlertDialogPopup, | |
| 37 | + AlertDialogTitle, | |
| 38 | +} from "./ui/alert-dialog"; | |
| 30 | 39 | import { Button } from "./ui/button"; |
| 31 | 40 | import { |
| 32 | 41 | Dialog, |
| @@ -84,6 +93,7 @@ interface ProjectScriptsControlProps { · 51 unmodified lines | ||
| 84 | 93 | onRunScript: (script: ProjectScript) => void; |
| 85 | 94 | onAddScript: (input: NewProjectScriptInput) => Promise<void> | void; |
| 86 | 95 | onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void; |
| 96 | + onDeleteScript: (scriptId: string) => Promise<void> | void; | |
| 87 | 97 | } |
| 88 | 98 | |
| 89 | 99 | function normalizeShortcutKeyToken(key: string): string | null { |
| @@ -144,6 +154,7 @@ export default function ProjectScriptsControl({ · 54 unmodified lines | ||
| 144 | 154 | onRunScript, |
| 145 | 155 | onAddScript, |
| 146 | 156 | onUpdateScript, |
| 157 | + onDeleteScript, | |
| 147 | 158 | }: ProjectScriptsControlProps) { |
| 148 | 159 | const addScriptFormId = React.useId(); |
| 149 | 160 | const [editingScriptId, setEditingScriptId] = useState<string | null>(null); |
| @@ -155,6 +166,7 @@ export default function ProjectScriptsControl({ · 5 unmodified lines | ||
| 155 | 166 | const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); |
| 156 | 167 | const [keybinding, setKeybinding] = useState(""); |
| 157 | 168 | const [validationError, setValidationError] = useState<string | null>(null); |
| 169 | + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); | |
| 158 | 170 | |
| 159 | 171 | const primaryScript = useMemo(() => { |
| 160 | 172 | if (preferredScriptId) { |
| @@ -247,6 +259,13 @@ export default function ProjectScriptsControl({ · 86 unmodified lines | ||
| 247 | 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 | 269 | return ( |
| 251 | 270 | <> |
| 252 | 271 | {primaryScript ? ( |
| @@ -440,6 +459,16 @@ export default function ProjectScriptsControl({ · 187 unmodified lines | ||
| 440 | 459 | </form> |
| 441 | 460 | </DialogPanel> |
| 442 | 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 | 472 | <Button |
| 444 | 473 | type="button" |
| 445 | 474 | variant="outline" |
| @@ -455,6 +484,21 @@ export default function ProjectScriptsControl({ · 9 unmodified lines | ||
| 455 | 484 | </DialogFooter> |
| 456 | 485 | </DialogPopup> |
| 457 | 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 | } |
| @@ -0,0 +1,124 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | + | |
| 3 | +import { hasUnseenCompletion, resolveThreadStatusPill } from "./Sidebar.logic"; | |
| 4 | + | |
| 5 | +function makeLatestTurn(overrides?: { | |
| 6 | + completedAt?: string | null; | |
| 7 | + startedAt?: string | null; | |
| 8 | +}): Parameters<typeof hasUnseenCompletion>[0]["latestTurn"] { | |
| 9 | + return { | |
| 10 | + turnId: "turn-1" as never, | |
| 11 | + state: "completed", | |
| 12 | + assistantMessageId: null, | |
| 13 | + requestedAt: "2026-03-09T10:00:00.000Z", | |
| 14 | + startedAt: overrides?.startedAt ?? "2026-03-09T10:00:00.000Z", | |
| 15 | + completedAt: overrides?.completedAt ?? "2026-03-09T10:05:00.000Z", | |
| 16 | + }; | |
| 17 | +} | |
| 18 | + | |
| 19 | +describe("hasUnseenCompletion", () => { | |
| 20 | + it("returns true when a thread completed after its last visit", () => { | |
| 21 | + expect( | |
| 22 | + hasUnseenCompletion({ | |
| 23 | + interactionMode: "default", | |
| 24 | + latestTurn: makeLatestTurn(), | |
| 25 | + lastVisitedAt: "2026-03-09T10:04:00.000Z", | |
| 26 | + proposedPlans: [], | |
| 27 | + session: null, | |
| 28 | + }), | |
| 29 | + ).toBe(true); | |
| 30 | + }); | |
| 31 | +}); | |
| 32 | + | |
| 33 | +describe("resolveThreadStatusPill", () => { | |
| 34 | + const baseThread = { | |
| 35 | + interactionMode: "plan" as const, | |
| 36 | + latestTurn: null, | |
| 37 | + lastVisitedAt: undefined, | |
| 38 | + proposedPlans: [], | |
| 39 | + session: { | |
| 40 | + provider: "codex" as const, | |
| 41 | + status: "running" as const, | |
| 42 | + createdAt: "2026-03-09T10:00:00.000Z", | |
| 43 | + updatedAt: "2026-03-09T10:00:00.000Z", | |
| 44 | + orchestrationStatus: "running" as const, | |
| 45 | + }, | |
| 46 | + }; | |
| 47 | + | |
| 48 | + it("shows pending approval before all other statuses", () => { | |
| 49 | + expect( | |
| 50 | + resolveThreadStatusPill({ | |
| 51 | + thread: baseThread, | |
| 52 | + hasPendingApprovals: true, | |
| 53 | + hasPendingUserInput: true, | |
| 54 | + }), | |
| 55 | + ).toMatchObject({ label: "Pending Approval", pulse: false }); | |
| 56 | + }); | |
| 57 | + | |
| 58 | + it("shows awaiting input when plan mode is blocked on user answers", () => { | |
| 59 | + expect( | |
| 60 | + resolveThreadStatusPill({ | |
| 61 | + thread: baseThread, | |
| 62 | + hasPendingApprovals: false, | |
| 63 | + hasPendingUserInput: true, | |
| 64 | + }), | |
| 65 | + ).toMatchObject({ label: "Awaiting Input", pulse: false }); | |
| 66 | + }); | |
| 67 | + | |
| 68 | + it("falls back to working when the thread is actively running without blockers", () => { | |
| 69 | + expect( | |
| 70 | + resolveThreadStatusPill({ | |
| 71 | + thread: baseThread, | |
| 72 | + hasPendingApprovals: false, | |
| 73 | + hasPendingUserInput: false, | |
| 74 | + }), | |
| 75 | + ).toMatchObject({ label: "Working", pulse: true }); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => { | |
| 79 | + expect( | |
| 80 | + resolveThreadStatusPill({ | |
| 81 | + thread: { | |
| 82 | + ...baseThread, | |
| 83 | + latestTurn: makeLatestTurn(), | |
| 84 | + proposedPlans: [ | |
| 85 | + { | |
| 86 | + id: "plan-1" as never, | |
| 87 | + turnId: "turn-1" as never, | |
| 88 | + createdAt: "2026-03-09T10:00:00.000Z", | |
| 89 | + updatedAt: "2026-03-09T10:05:00.000Z", | |
| 90 | + planMarkdown: "# Plan", | |
| 91 | + }, | |
| 92 | + ], | |
| 93 | + session: { | |
| 94 | + ...baseThread.session, | |
| 95 | + status: "ready", | |
| 96 | + orchestrationStatus: "ready", | |
| 97 | + }, | |
| 98 | + }, | |
| 99 | + hasPendingApprovals: false, | |
| 100 | + hasPendingUserInput: false, | |
| 101 | + }), | |
| 102 | + ).toMatchObject({ label: "Plan Ready", pulse: false }); | |
| 103 | + }); | |
| 104 | + | |
| 105 | + it("shows completed when there is an unseen completion and no active blocker", () => { | |
| 106 | + expect( | |
| 107 | + resolveThreadStatusPill({ | |
| 108 | + thread: { | |
| 109 | + ...baseThread, | |
| 110 | + interactionMode: "default", | |
| 111 | + latestTurn: makeLatestTurn(), | |
| 112 | + lastVisitedAt: "2026-03-09T10:04:00.000Z", | |
| 113 | + session: { | |
| 114 | + ...baseThread.session, | |
| 115 | + status: "ready", | |
| 116 | + orchestrationStatus: "ready", | |
| 117 | + }, | |
| 118 | + }, | |
| 119 | + hasPendingApprovals: false, | |
| 120 | + hasPendingUserInput: false, | |
| 121 | + }), | |
| 122 | + ).toMatchObject({ label: "Completed", pulse: false }); | |
| 123 | + }); | |
| 124 | +}); | |
| @@ -0,0 +1,100 @@ | ||
| 1 | +import type { Thread } from "../types"; | |
| 2 | +import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; | |
| 3 | + | |
| 4 | +export interface ThreadStatusPill { | |
| 5 | + label: | |
| 6 | + | "Working" | |
| 7 | + | "Connecting" | |
| 8 | + | "Completed" | |
| 9 | + | "Pending Approval" | |
| 10 | + | "Awaiting Input" | |
| 11 | + | "Plan Ready"; | |
| 12 | + colorClass: string; | |
| 13 | + dotClass: string; | |
| 14 | + pulse: boolean; | |
| 15 | +} | |
| 16 | + | |
| 17 | +type ThreadStatusInput = Pick< | |
| 18 | + Thread, | |
| 19 | + "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" | |
| 20 | +>; | |
| 21 | + | |
| 22 | +export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { | |
| 23 | + if (!thread.latestTurn?.completedAt) return false; | |
| 24 | + const completedAt = Date.parse(thread.latestTurn.completedAt); | |
| 25 | + if (Number.isNaN(completedAt)) return false; | |
| 26 | + if (!thread.lastVisitedAt) return true; | |
| 27 | + | |
| 28 | + const lastVisitedAt = Date.parse(thread.lastVisitedAt); | |
| 29 | + if (Number.isNaN(lastVisitedAt)) return true; | |
| 30 | + return completedAt > lastVisitedAt; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export function resolveThreadStatusPill(input: { | |
| 34 | + thread: ThreadStatusInput; | |
| 35 | + hasPendingApprovals: boolean; | |
| 36 | + hasPendingUserInput: boolean; | |
| 37 | +}): ThreadStatusPill | null { | |
| 38 | + const { hasPendingApprovals, hasPendingUserInput, thread } = input; | |
| 39 | + | |
| 40 | + if (hasPendingApprovals) { | |
| 41 | + return { | |
| 42 | + label: "Pending Approval", | |
| 43 | + colorClass: "text-amber-600 dark:text-amber-300/90", | |
| 44 | + dotClass: "bg-amber-500 dark:bg-amber-300/90", | |
| 45 | + pulse: false, | |
| 46 | + }; | |
| 47 | + } | |
| 48 | + | |
| 49 | + if (hasPendingUserInput) { | |
| 50 | + return { | |
| 51 | + label: "Awaiting Input", | |
| 52 | + colorClass: "text-indigo-600 dark:text-indigo-300/90", | |
| 53 | + dotClass: "bg-indigo-500 dark:bg-indigo-300/90", | |
| 54 | + pulse: false, | |
| 55 | + }; | |
| 56 | + } | |
| 57 | + | |
| 58 | + if (thread.session?.status === "running") { | |
| 59 | + return { | |
| 60 | + label: "Working", | |
| 61 | + colorClass: "text-sky-600 dark:text-sky-300/80", | |
| 62 | + dotClass: "bg-sky-500 dark:bg-sky-300/80", | |
| 63 | + pulse: true, | |
| 64 | + }; | |
| 65 | + } | |
| 66 | + | |
| 67 | + if (thread.session?.status === "connecting") { | |
| 68 | + return { | |
| 69 | + label: "Connecting", | |
| 70 | + colorClass: "text-sky-600 dark:text-sky-300/80", | |
| 71 | + dotClass: "bg-sky-500 dark:bg-sky-300/80", | |
| 72 | + pulse: true, | |
| 73 | + }; | |
| 74 | + } | |
| 75 | + | |
| 76 | + const hasPlanReadyPrompt = | |
| 77 | + !hasPendingUserInput && | |
| 78 | + thread.interactionMode === "plan" && | |
| 79 | + isLatestTurnSettled(thread.latestTurn, thread.session) && | |
| 80 | + findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null) !== null; | |
| 81 | + if (hasPlanReadyPrompt) { | |
| 82 | + return { | |
| 83 | + label: "Plan Ready", | |
| 84 | + colorClass: "text-violet-600 dark:text-violet-300/90", | |
| 85 | + dotClass: "bg-violet-500 dark:bg-violet-300/90", | |
| 86 | + pulse: false, | |
| 87 | + }; | |
| 88 | + } | |
| 89 | + | |
| 90 | + if (hasUnseenCompletion(thread)) { | |
| 91 | + return { | |
| 92 | + label: "Completed", | |
| 93 | + colorClass: "text-emerald-600 dark:text-emerald-300/90", | |
| 94 | + dotClass: "bg-emerald-500 dark:bg-emerald-300/90", | |
| 95 | + pulse: false, | |
| 96 | + }; | |
| 97 | + } | |
| 98 | + | |
| 99 | + return null; | |
| 100 | +} | |
| @@ -1,10 +1,14 @@ | ||
| 1 | 1 | import { |
| 2 | + ArrowLeftIcon, | |
| 2 | 3 | ChevronRightIcon, |
| 3 | 4 | FolderIcon, |
| 4 | 5 | GitPullRequestIcon, |
| 6 | + PlusIcon, | |
| 5 | 7 | RocketIcon, |
| 8 | + SettingsIcon, | |
| 6 | 9 | SquarePenIcon, |
| 7 | 10 | TerminalIcon, |
| 11 | + TriangleAlertIcon, | |
| 8 | 12 | } from "lucide-react"; |
| 9 | 13 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 10 | 14 | import { |
| @@ -17,15 +21,14 @@ import { · 6 unmodified lines | ||
| 17 | 21 | type ResolvedKeybindingsConfig, |
| 18 | 22 | } from "@t3tools/contracts"; |
| 19 | 23 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 20 | −import { useNavigate, useParams } from "@tanstack/react-router"; | |
| 24 | +import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; | |
| 21 | 25 | import { useAppSettings } from "../appSettings"; |
| 22 | 26 | import { isElectron } from "../env"; |
| 23 | 27 | import { APP_STAGE_LABEL } from "../branding"; |
| 24 | 28 | import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; |
| 25 | 29 | import { useStore } from "../store"; |
| 26 | 30 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; |
| 27 | −import { type Thread } from "../types"; | |
| 28 | −import { derivePendingApprovals } from "../session-logic"; | |
| 31 | +import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; | |
| 29 | 32 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 30 | 33 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 31 | 34 | import { readNativeApi } from "../nativeApi"; |
| @@ -33,14 +36,18 @@ import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraft · 1 unmodified line | ||
| 33 | 36 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 34 | 37 | import { toastManager } from "./ui/toast"; |
| 35 | 38 | import { |
| 39 | + getArm64IntelBuildWarningDescription, | |
| 36 | 40 | getDesktopUpdateActionError, |
| 37 | 41 | getDesktopUpdateButtonTooltip, |
| 38 | 42 | isDesktopUpdateButtonDisabled, |
| 39 | 43 | resolveDesktopUpdateButtonAction, |
| 44 | + shouldShowArm64IntelBuildWarning, | |
| 40 | 45 | shouldHighlightDesktopUpdateError, |
| 41 | 46 | shouldShowDesktopUpdateButton, |
| 42 | 47 | shouldToastDesktopUpdateActionResult, |
| 43 | 48 | } from "./desktopUpdate.logic"; |
| 49 | +import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; | |
| 50 | +import { Button } from "./ui/button"; | |
| 44 | 51 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 45 | 52 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 46 | 53 | import { |
| @@ -60,6 +67,7 @@ import { · 13 unmodified lines | ||
| 60 | 67 | } from "./ui/sidebar"; |
| 61 | 68 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 62 | 69 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 70 | +import { resolveThreadStatusPill } from "./Sidebar.logic"; | |
| 63 | 71 | |
| 64 | 72 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 65 | 73 | const THREAD_PREVIEW_LIMIT = 6; |
| @@ -81,13 +89,6 @@ function formatRelativeTime(iso: string): string { · 15 unmodified lines | ||
| 81 | 89 | return `${Math.floor(hours / 24)}d ago`; |
| 82 | 90 | } |
| 83 | 91 | |
| 84 | −interface ThreadStatusPill { | |
| 85 | − label: "Working" | "Connecting" | "Completed" | "Pending Approval"; | |
| 86 | − colorClass: string; | |
| 87 | − dotClass: string; | |
| 88 | − pulse: boolean; | |
| 89 | −} | |
| 90 | − | |
| 91 | 92 | interface TerminalStatusIndicator { |
| 92 | 93 | label: "Terminal process running"; |
| 93 | 94 | colorClass: string; |
| @@ -103,57 +104,6 @@ interface PrStatusIndicator { · 9 unmodified lines | ||
| 103 | 104 | |
| 104 | 105 | type ThreadPr = GitStatusResult["pr"]; |
| 105 | 106 | |
| 106 | −function hasUnseenCompletion(thread: Thread): boolean { | |
| 107 | − if (!thread.latestTurn?.completedAt) return false; | |
| 108 | − const completedAt = Date.parse(thread.latestTurn.completedAt); | |
| 109 | − if (Number.isNaN(completedAt)) return false; | |
| 110 | − if (!thread.lastVisitedAt) return true; | |
| 111 | − | |
| 112 | − const lastVisitedAt = Date.parse(thread.lastVisitedAt); | |
| 113 | − if (Number.isNaN(lastVisitedAt)) return true; | |
| 114 | − return completedAt > lastVisitedAt; | |
| 115 | −} | |
| 116 | − | |
| 117 | −function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null { | |
| 118 | − if (hasPendingApprovals) { | |
| 119 | − return { | |
| 120 | − label: "Pending Approval", | |
| 121 | − colorClass: "text-amber-600 dark:text-amber-300/90", | |
| 122 | − dotClass: "bg-amber-500 dark:bg-amber-300/90", | |
| 123 | − pulse: false, | |
| 124 | − }; | |
| 125 | − } | |
| 126 | − | |
| 127 | − if (thread.session?.status === "running") { | |
| 128 | − return { | |
| 129 | − label: "Working", | |
| 130 | − colorClass: "text-sky-600 dark:text-sky-300/80", | |
| 131 | − dotClass: "bg-sky-500 dark:bg-sky-300/80", | |
| 132 | − pulse: true, | |
| 133 | − }; | |
| 134 | − } | |
| 135 | − | |
| 136 | − if (thread.session?.status === "connecting") { | |
| 137 | − return { | |
| 138 | − label: "Connecting", | |
| 139 | − colorClass: "text-sky-600 dark:text-sky-300/80", | |
| 140 | − dotClass: "bg-sky-500 dark:bg-sky-300/80", | |
| 141 | − pulse: true, | |
| 142 | − }; | |
| 143 | − } | |
| 144 | − | |
| 145 | − if (hasUnseenCompletion(thread)) { | |
| 146 | − return { | |
| 147 | − label: "Completed", | |
| 148 | − colorClass: "text-emerald-600 dark:text-emerald-300/90", | |
| 149 | − dotClass: "bg-emerald-500 dark:bg-emerald-300/90", | |
| 150 | − pulse: false, | |
| 151 | − }; | |
| 152 | − } | |
| 153 | − | |
| 154 | − return null; | |
| 155 | −} | |
| 156 | − | |
| 157 | 107 | function terminalStatusFromRunningIds( |
| 158 | 108 | runningTerminalIds: string[], |
| 159 | 109 | ): TerminalStatusIndicator | null { |
| @@ -278,6 +228,7 @@ export default function Sidebar() { · 118 unmodified lines | ||
| 278 | 228 | (store) => store.clearProjectDraftThreadById, |
| 279 | 229 | ); |
| 280 | 230 | const navigate = useNavigate(); |
| 231 | + const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); | |
| 281 | 232 | const { settings: appSettings } = useAppSettings(); |
| 282 | 233 | const routeThreadId = useParams({ |
| 283 | 234 | strict: false, |
| @@ -293,6 +244,8 @@ export default function Sidebar() { · 9 unmodified lines | ||
| 293 | 244 | const [newCwd, setNewCwd] = useState(""); |
| 294 | 245 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| 295 | 246 | const [isAddingProject, setIsAddingProject] = useState(false); |
| 247 | + const [addProjectError, setAddProjectError] = useState<string | null>(null); | |
| 248 | + const addProjectInputRef = useRef<HTMLInputElement | null>(null); | |
| 296 | 249 | const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); |
| 297 | 250 | const [renamingTitle, setRenamingTitle] = useState(""); |
| 298 | 251 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< |
| @@ -301,6 +254,8 @@ export default function Sidebar() { · 2 unmodified lines | ||
| 301 | 254 | const renamingCommittedRef = useRef(false); |
| 302 | 255 | const renamingInputRef = useRef<HTMLInputElement | null>(null); |
| 303 | 256 | const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null); |
| 257 | + const shouldBrowseForProjectImmediately = isElectron; | |
| 258 | + const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; | |
| 304 | 259 | const pendingApprovalByThreadId = useMemo(() => { |
| 305 | 260 | const map = new Map<ThreadId, boolean>(); |
| 306 | 261 | for (const thread of threads) { |
| @@ -308,6 +263,13 @@ export default function Sidebar() { · 1 unmodified line | ||
| 308 | 263 | } |
| 309 | 264 | return map; |
| 310 | 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 | 273 | const projectCwdById = useMemo( |
| 312 | 274 | () => new Map(projects.map((project) => [project.id, project.cwd] as const)), |
| 313 | 275 | [projects], |
| @@ -486,6 +448,7 @@ export default function Sidebar() { · 172 unmodified lines | ||
| 486 | 448 | const finishAddingProject = () => { |
| 487 | 449 | setIsAddingProject(false); |
| 488 | 450 | setNewCwd(""); |
| 451 | + setAddProjectError(null); | |
| 489 | 452 | setAddingProject(false); |
| 490 | 453 | }; |
| 491 | 454 | |
| @@ -511,18 +474,29 @@ export default function Sidebar() { · 19 unmodified lines | ||
| 511 | 474 | }); |
| 512 | 475 | await handleNewThread(projectId).catch(() => undefined); |
| 513 | 476 | } catch (error) { |
| 477 | + const description = | |
| 478 | + error instanceof Error ? error.message : "An error occurred while adding the project."; | |
| 514 | 479 | setIsAddingProject(false); |
| 515 | − toastManager.add({ | |
| 516 | − type: "error", | |
| 517 | − title: "Unable to add project", | |
| 518 | − description: | |
| 519 | − error instanceof Error ? error.message : "An error occurred while adding the project.", | |
| 520 | − }); | |
| 480 | + if (shouldBrowseForProjectImmediately) { | |
| 481 | + toastManager.add({ | |
| 482 | + type: "error", | |
| 483 | + title: "Failed to add project", | |
| 484 | + description, | |
| 485 | + }); | |
| 486 | + } else { | |
| 487 | + setAddProjectError(description); | |
| 488 | + } | |
| 521 | 489 | return; |
| 522 | 490 | } |
| 523 | 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 | 502 | const handleAddProject = () => { |
| @@ -541,10 +515,21 @@ export default function Sidebar() { · 12 unmodified lines | ||
| 541 | 515 | } |
| 542 | 516 | if (pickedPath) { |
| 543 | 517 | await addProjectFromPath(pickedPath); |
| 518 | + } else if (!shouldBrowseForProjectImmediately) { | |
| 519 | + addProjectInputRef.current?.focus(); | |
| 544 | 520 | } |
| 545 | 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 | 533 | const cancelRename = useCallback(() => { |
| 549 | 534 | setRenamingThreadId(null); |
| 550 | 535 | renamingInputRef.current = null; |
| @@ -881,6 +866,12 @@ export default function Sidebar() { · 330 unmodified lines | ||
| 881 | 866 | const desktopUpdateButtonAction = desktopUpdateState |
| 882 | 867 | ? resolveDesktopUpdateButtonAction(desktopUpdateState) |
| 883 | 868 | : "none"; |
| 869 | + const showArm64IntelBuildWarning = | |
| 870 | + isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); | |
| 871 | + const arm64IntelBuildWarningDescription = | |
| 872 | + desktopUpdateState && showArm64IntelBuildWarning | |
| 873 | + ? getArm64IntelBuildWarningDescription(desktopUpdateState) | |
| 874 | + : null; | |
| 884 | 875 | const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled |
| 885 | 876 | ? "cursor-not-allowed opacity-60" |
| 886 | 877 | : "hover:bg-accent hover:text-foreground"; |
| @@ -978,7 +969,7 @@ export default function Sidebar() { · 91 unmodified lines | ||
| 978 | 969 | const wordmark = ( |
| 979 | 970 | <div className="flex items-center gap-2"> |
| 980 | 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 | 973 | <T3Wordmark /> |
| 983 | 974 | <span className="truncate text-sm font-medium tracking-tight text-muted-foreground"> |
| 984 | 975 | Code |
| @@ -994,7 +985,7 @@ export default function Sidebar() { · 9 unmodified lines | ||
| 994 | 985 | <> |
| 995 | 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 | 989 | {wordmark} |
| 999 | 990 | {showDesktopUpdateButton && ( |
| 1000 | 991 | <Tooltip> |
| @@ -1005,7 +996,7 @@ export default function Sidebar() { · 4 unmodified lines | ||
| 1005 | 996 | aria-label={desktopUpdateTooltip} |
| 1006 | 997 | aria-disabled={desktopUpdateButtonDisabled || undefined} |
| 1007 | 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 | 1000 | onClick={handleDesktopUpdateButtonClick} |
| 1010 | 1001 | > |
| 1011 | 1002 | <RocketIcon className="size-3.5" /> |
| @@ -1024,7 +1015,121 @@ export default function Sidebar() { · 12 unmodified lines | ||
| 1024 | 1015 | )} |
| 1025 | 1016 | |
| 1026 | 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 | 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 | 1133 | <SidebarMenu> |
| 1029 | 1134 | {projects.map((project) => { |
| 1030 | 1135 | const projectThreads = threads |
| @@ -1112,10 +1217,11 @@ export default function Sidebar() { · 81 unmodified lines | ||
| 1112 | 1217 | <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0"> |
| 1113 | 1218 | {visibleThreads.map((thread) => { |
| 1114 | 1219 | const isActive = routeThreadId === thread.id; |
| 1115 | − const threadStatus = threadStatusPill( | |
| 1220 | + const threadStatus = resolveThreadStatusPill({ | |
| 1116 | 1221 | thread, |
| 1117 | − pendingApprovalByThreadId.get(thread.id) === true, | |
| 1118 | − ); | |
| 1222 | + hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true, | |
| 1223 | + hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true, | |
| 1224 | + }); | |
| 1119 | 1225 | const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); |
| 1120 | 1226 | const terminalStatus = terminalStatusFromRunningIds( |
| 1121 | 1227 | selectThreadTerminalState(terminalStateByThreadId, thread.id) |
| @@ -1286,70 +1392,39 @@ export default function Sidebar() { · 164 unmodified lines | ||
| 1286 | 1392 | })} |
| 1287 | 1393 | </SidebarMenu> |
| 1288 | 1394 | |
| 1289 | − {projects.length === 0 && !addingProject && ( | |
| 1395 | + {projects.length === 0 && !shouldShowProjectPathEntry && ( | |
| 1290 | 1396 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> |
| 1291 | − No projects yet. | |
| 1292 | − <br /> | |
| 1293 | − Add one to get started. | |
| 1397 | + No projects yet | |
| 1294 | 1398 | </div> |
| 1295 | 1399 | )} |
| 1296 | 1400 | </SidebarGroup> |
| 1297 | 1401 | </SidebarContent> |
| 1298 | 1402 | |
| 1299 | 1403 | <SidebarSeparator /> |
| 1300 | − <SidebarFooter className="gap-0 p-3"> | |
| 1301 | − {addingProject ? ( | |
| 1302 | − <> | |
| 1303 | − <p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70"> | |
| 1304 | − Add project | |
| 1305 | − </p> | |
| 1306 | − <input | |
| 1307 | − className="mb-2 w-full rounded-md border border-border bg-secondary px-2 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-ring focus:outline-none" | |
| 1308 | − placeholder="/path/to/project" | |
| 1309 | − value={newCwd} | |
| 1310 | − onChange={(event) => setNewCwd(event.target.value)} | |
| 1311 | − onKeyDown={(event) => { | |
| 1312 | − if (event.key === "Enter") handleAddProject(); | |
| 1313 | − if (event.key === "Escape") setAddingProject(false); | |
| 1314 | − }} | |
| 1315 | − /> | |
| 1316 | − {isElectron && ( | |
| 1317 | − <button | |
| 1318 | − type="button" | |
| 1319 | − className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" | |
| 1320 | − onClick={() => void handlePickFolder()} | |
| 1321 | − disabled={isPickingFolder || isAddingProject} | |
| 1404 | + <SidebarFooter className="p-2"> | |
| 1405 | + <SidebarMenu> | |
| 1406 | + <SidebarMenuItem> | |
| 1407 | + {isOnSettings ? ( | |
| 1408 | + <SidebarMenuButton | |
| 1409 | + size="sm" | |
| 1410 | + className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground" | |
| 1411 | + onClick={() => window.history.back()} | |
| 1322 | 1412 | > |
| 1323 | − {isPickingFolder ? "Picking folder..." : "Browse for folder"} | |
| 1324 | − </button> | |
| 1325 | − )} | |
| 1326 | − <div className="flex gap-2"> | |
| 1327 | − <button | |
| 1328 | − type="button" | |
| 1329 | − className="flex-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90" | |
| 1330 | − onClick={handleAddProject} | |
| 1331 | − disabled={isAddingProject} | |
| 1332 | − > | |
| 1333 | − {isAddingProject ? "Adding..." : "Add"} | |
| 1334 | − </button> | |
| 1335 | − <button | |
| 1336 | − type="button" | |
| 1337 | − className="flex-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground/80 transition-colors duration-150 hover:bg-secondary" | |
| 1338 | − onClick={() => setAddingProject(false)} | |
| 1413 | + <ArrowLeftIcon className="size-3.5" /> | |
| 1414 | + <span className="text-xs">Back</span> | |
| 1415 | + </SidebarMenuButton> | |
| 1416 | + ) : ( | |
| 1417 | + <SidebarMenuButton | |
| 1418 | + size="sm" | |
| 1419 | + className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground" | |
| 1420 | + onClick={() => void navigate({ to: "/settings" })} | |
| 1339 | 1421 | > |
| 1340 | − Cancel | |
| 1341 | − </button> | |
| 1342 | − </div> | |
| 1343 | − </> | |
| 1344 | − ) : ( | |
| 1345 | − <button | |
| 1346 | − type="button" | |
| 1347 | − className="flex w-full items-center justify-center gap-1 rounded-md border border-dashed border-border py-2 text-xs text-muted-foreground/70 transition-colors duration-150 hover:border-ring hover:text-muted-foreground" | |
| 1348 | − onClick={() => setAddingProject(true)} | |
| 1349 | − > | |
| 1350 | − + Add project | |
| 1351 | − </button> | |
| 1352 | − )} | |
| 1422 | + <SettingsIcon className="size-3.5" /> | |
| 1423 | + <span className="text-xs">Settings</span> | |
| 1424 | + </SidebarMenuButton> | |
| 1425 | + )} | |
| 1426 | + </SidebarMenuItem> | |
| 1427 | + </SidebarMenu> | |
| 1353 | 1428 | </SidebarFooter> |
| 1354 | 1429 | </> |
| 1355 | 1430 | ); |
| @@ -2,11 +2,13 @@ import { describe, expect, it } from "vitest"; · 1 unmodified line | ||
| 2 | 2 | import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | + getArm64IntelBuildWarningDescription, | |
| 5 | 6 | getDesktopUpdateActionError, |
| 6 | 7 | getDesktopUpdateButtonTooltip, |
| 7 | 8 | isDesktopUpdateButtonDisabled, |
| 8 | 9 | resolveDesktopUpdateButtonAction, |
| 9 | 10 | shouldHighlightDesktopUpdateError, |
| 11 | + shouldShowArm64IntelBuildWarning, | |
| 10 | 12 | shouldShowDesktopUpdateButton, |
| 11 | 13 | shouldToastDesktopUpdateActionResult, |
| 12 | 14 | } from "./desktopUpdate.logic"; |
| @@ -15,6 +17,9 @@ const baseState: DesktopUpdateState = { · 2 unmodified lines | ||
| 15 | 17 | enabled: true, |
| 16 | 18 | status: "idle", |
| 17 | 19 | currentVersion: "1.0.0", |
| 20 | + hostArch: "x64", | |
| 21 | + appArch: "x64", | |
| 22 | + runningUnderArm64Translation: false, | |
| 18 | 23 | availableVersion: null, |
| 19 | 24 | downloadedVersion: null, |
| 20 | 25 | downloadPercent: null, |
| @@ -171,4 +176,30 @@ describe("desktop update UI helpers", () => { · 150 unmodified lines | ||
| 171 | 176 | }), |
| 172 | 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 | }); |
| @@ -32,10 +32,29 @@ export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): · 31 unmodified lines | ||
| 32 | 32 | return resolveDesktopUpdateButtonAction(state) !== "none"; |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | +export function shouldShowArm64IntelBuildWarning(state: DesktopUpdateState | null): boolean { | |
| 36 | + return state?.hostArch === "arm64" && state.appArch === "x64"; | |
| 37 | +} | |
| 38 | + | |
| 35 | 39 | export function isDesktopUpdateButtonDisabled(state: DesktopUpdateState | null): boolean { |
| 36 | 40 | return state?.status === "downloading"; |
| 37 | 41 | } |
| 38 | 42 | |
| 43 | +export function getArm64IntelBuildWarningDescription(state: DesktopUpdateState): string { | |
| 44 | + if (!shouldShowArm64IntelBuildWarning(state)) { | |
| 45 | + return "This install is using the correct architecture."; | |
| 46 | + } | |
| 47 | + | |
| 48 | + const action = resolveDesktopUpdateButtonAction(state); | |
| 49 | + if (action === "download") { | |
| 50 | + return "This Mac has Apple Silicon, but T3 Code is still running the Intel build under Rosetta. Download the available update to switch to the native Apple Silicon build."; | |
| 51 | + } | |
| 52 | + if (action === "install") { | |
| 53 | + return "This Mac has Apple Silicon, but T3 Code is still running the Intel build under Rosetta. Restart to install the downloaded Apple Silicon build."; | |
| 54 | + } | |
| 55 | + return "This Mac has Apple Silicon, but T3 Code is still running the Intel build under Rosetta. The next app update will replace it with the native Apple Silicon build."; | |
| 56 | +} | |
| 57 | + | |
| 39 | 58 | export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string { |
| 40 | 59 | if (state.status === "available") { |
| 41 | 60 | return `Update ${state.availableVersion ?? "available"} ready to download`; |
| @@ -566,7 +566,8 @@ function SidebarRail({ · 565 unmodified lines | ||
| 566 | 566 | <button |
| 567 | 567 | aria-label={railLabel} |
| 568 | 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 | 571 | "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", |
| 571 | 572 | "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", |
| 572 | 573 | "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full", |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { assert, describe, it } from "vitest"; |
| 2 | −import { shouldHideCollapsedToastContent } from "./toast.logic"; | |
| 2 | +import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; | |
| 3 | 3 | |
| 4 | 4 | describe("shouldHideCollapsedToastContent", () => { |
| 5 | 5 | it("keeps a single visible toast readable", () => { |
| @@ -14,3 +14,42 @@ describe("shouldHideCollapsedToastContent", () => { · 8 unmodified lines | ||
| 14 | 14 | assert.equal(shouldHideCollapsedToastContent(1, 3), true); |
| 15 | 15 | }); |
| 16 | 16 | }); |
| 17 | + | |
| 18 | +describe("buildVisibleToastLayout", () => { | |
| 19 | + it("computes indices and offsets from the visible subset", () => { | |
| 20 | + const visibleToasts = [{ id: "a", height: 48 }, { id: "b", height: 72 }, { id: "c", height: 24 }]; | |
| 21 | + | |
| 22 | + const layout = buildVisibleToastLayout(visibleToasts); | |
| 23 | + | |
| 24 | + assert.equal(layout.frontmostHeight, 48); | |
| 25 | + assert.deepEqual( | |
| 26 | + layout.items.map(({ toast, visibleIndex, offsetY }) => ({ | |
| 27 | + id: toast.id, | |
| 28 | + visibleIndex, | |
| 29 | + offsetY, | |
| 30 | + })), | |
| 31 | + [ | |
| 32 | + { id: "a", visibleIndex: 0, offsetY: 0 }, | |
| 33 | + { id: "b", visibleIndex: 1, offsetY: 48 }, | |
| 34 | + { id: "c", visibleIndex: 2, offsetY: 120 }, | |
| 35 | + ], | |
| 36 | + ); | |
| 37 | + }); | |
| 38 | + | |
| 39 | + it("treats missing heights as zero", () => { | |
| 40 | + const layout = buildVisibleToastLayout([{ id: "a" }, { id: "b", height: undefined }, { id: "c", height: 30 }]); | |
| 41 | + | |
| 42 | + assert.equal(layout.frontmostHeight, 0); | |
| 43 | + assert.deepEqual( | |
| 44 | + layout.items.map(({ toast, offsetY }) => ({ | |
| 45 | + id: toast.id, | |
| 46 | + offsetY, | |
| 47 | + })), | |
| 48 | + [ | |
| 49 | + { id: "a", offsetY: 0 }, | |
| 50 | + { id: "b", offsetY: 0 }, | |
| 51 | + { id: "c", offsetY: 0 }, | |
| 52 | + ], | |
| 53 | + ); | |
| 54 | + }); | |
| 55 | +}); | |
| @@ -7,3 +7,40 @@ export function shouldHideCollapsedToastContent( · 6 unmodified lines | ||
| 7 | 7 | if (visibleToastCount <= 1) return false; |
| 8 | 8 | return visibleToastIndex > 0; |
| 9 | 9 | } |
| 10 | + | |
| 11 | +type ToastWithHeight = { | |
| 12 | + height?: number | null | undefined; | |
| 13 | +}; | |
| 14 | + | |
| 15 | +type VisibleToastLayoutItem<TToast extends object> = { | |
| 16 | + toast: TToast; | |
| 17 | + visibleIndex: number; | |
| 18 | + offsetY: number; | |
| 19 | +}; | |
| 20 | + | |
| 21 | +export function buildVisibleToastLayout<TToast extends object>( | |
| 22 | + visibleToasts: readonly (TToast & ToastWithHeight)[], | |
| 23 | +): { | |
| 24 | + frontmostHeight: number; | |
| 25 | + items: VisibleToastLayoutItem<TToast & ToastWithHeight>[]; | |
| 26 | +} { | |
| 27 | + let offsetY = 0; | |
| 28 | + | |
| 29 | + return { | |
| 30 | + frontmostHeight: normalizeToastHeight(visibleToasts[0]?.height), | |
| 31 | + items: visibleToasts.map((toast, visibleIndex) => { | |
| 32 | + const item = { | |
| 33 | + toast, | |
| 34 | + visibleIndex, | |
| 35 | + offsetY, | |
| 36 | + }; | |
| 37 | + | |
| 38 | + offsetY += normalizeToastHeight(toast.height); | |
| 39 | + return item; | |
| 40 | + }), | |
| 41 | + }; | |
| 42 | +} | |
| 43 | + | |
| 44 | +function normalizeToastHeight(height: number | null | undefined): number { | |
| 45 | + return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : 0; | |
| 46 | +} | |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | −import { useEffect } from "react"; | |
| 4 | +import { useEffect, type CSSProperties } from "react"; | |
| 5 | 5 | import { useParams } from "@tanstack/react-router"; |
| 6 | 6 | import { ThreadId } from "@t3tools/contracts"; |
| 7 | 7 | import { |
| @@ -14,7 +14,7 @@ import { · 6 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | import { cn } from "~/lib/utils"; |
| 16 | 16 | import { buttonVariants } from "~/components/ui/button"; |
| 17 | −import { shouldHideCollapsedToastContent } from "./toast.logic"; | |
| 17 | +import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; | |
| 18 | 18 | |
| 19 | 19 | type ThreadToastData = { |
| 20 | 20 | threadId?: ThreadId | null; |
| @@ -158,6 +158,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 137 unmodified lines | ||
| 158 | 158 | const visibleToasts = toasts.filter((toast) => |
| 159 | 159 | shouldRenderForActiveThread(toast.data, activeThreadId), |
| 160 | 160 | ); |
| 161 | + const visibleToastLayout = buildVisibleToastLayout(visibleToasts); | |
| 161 | 162 | |
| 162 | 163 | useEffect(() => { |
| 163 | 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 | 185 | data-position={position} |
| 185 | 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 | 194 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; |
| 189 | 195 | const hideCollapsedContent = shouldHideCollapsedToastContent( |
| 190 | 196 | visibleIndex, |
| 191 | − visibleToasts.length, | |
| 197 | + visibleToastLayout.items.length, | |
| 192 | 198 | ); |
| 193 | 199 | |
| 194 | 200 | return ( |
| @@ -241,6 +247,12 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 46 unmodified lines | ||
| 241 | 247 | )} |
| 242 | 248 | data-position={position} |
| 243 | 249 | key={toast.id} |
| 250 | + style={ | |
| 251 | + { | |
| 252 | + "--toast-index": visibleIndex, | |
| 253 | + "--toast-offset-y": `${offsetY}px`, | |
| 254 | + } as CSSProperties | |
| 255 | + } | |
| 244 | 256 | swipeDirection={ |
| 245 | 257 | position.includes("center") |
| 246 | 258 | ? [isTop ? "up" : "down"] |
| @@ -49,3 +49,19 @@ export function buildProposedPlanMarkdownFilename(planMarkdown: string): string · 48 unmodified lines | ||
| 49 | 49 | const title = proposedPlanTitle(planMarkdown); |
| 50 | 50 | return `${sanitizePlanFileSegment(title ?? "plan")}.md`; |
| 51 | 51 | } |
| 52 | + | |
| 53 | +export function normalizePlanMarkdownForExport(planMarkdown: string): string { | |
| 54 | + return `${planMarkdown.trimEnd()}\n`; | |
| 55 | +} | |
| 56 | + | |
| 57 | +export function downloadPlanAsTextFile(filename: string, contents: string): void { | |
| 58 | + const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" }); | |
| 59 | + const url = URL.createObjectURL(blob); | |
| 60 | + const anchor = document.createElement("a"); | |
| 61 | + anchor.href = url; | |
| 62 | + anchor.download = filename; | |
| 63 | + anchor.click(); | |
| 64 | + window.setTimeout(() => { | |
| 65 | + URL.revokeObjectURL(url); | |
| 66 | + }, 0); | |
| 67 | +} | |
| @@ -3,14 +3,8 @@ import { useQuery } from "@tanstack/react-query"; · 2 unmodified lines | ||
| 3 | 3 | import { useCallback, useState } from "react"; |
| 4 | 4 | import { type ProviderKind } from "@t3tools/contracts"; |
| 5 | 5 | import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; |
| 6 | −import { ZapIcon } from "lucide-react"; | |
| 7 | − | |
| 8 | −import { | |
| 9 | − APP_SERVICE_TIER_OPTIONS, | |
| 10 | − MAX_CUSTOM_MODEL_LENGTH, | |
| 11 | − shouldShowFastTierIcon, | |
| 12 | − useAppSettings, | |
| 13 | −} from "../appSettings"; | |
| 6 | + | |
| 7 | +import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; | |
| 14 | 8 | import { isElectron } from "../env"; |
| 15 | 9 | import { useTheme } from "../hooks/useTheme"; |
| 16 | 10 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| @@ -18,7 +12,6 @@ import { ensureNativeApi } from "../nativeApi"; · 1 unmodified line | ||
| 18 | 12 | import { preferredTerminalEditor } from "../terminal-links"; |
| 19 | 13 | import { Button } from "../components/ui/button"; |
| 20 | 14 | import { Input } from "../components/ui/input"; |
| 21 | −import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../components/ui/select"; | |
| 22 | 15 | import { Switch } from "../components/ui/switch"; |
| 23 | 16 | import { SidebarInset } from "~/components/ui/sidebar"; |
| 24 | 17 | |
| @@ -103,7 +96,6 @@ function SettingsRouteView() { · 78 unmodified lines | ||
| 103 | 96 | |
| 104 | 97 | const codexBinaryPath = settings.codexBinaryPath; |
| 105 | 98 | const codexHomePath = settings.codexHomePath; |
| 106 | − const codexServiceTier = settings.codexServiceTier; | |
| 107 | 99 | const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; |
| 108 | 100 | |
| 109 | 101 | const openKeybindingsFile = useCallback(() => { |
| @@ -310,43 +302,6 @@ function SettingsRouteView() { · 200 unmodified lines | ||
| 310 | 302 | </div> |
| 311 | 303 | |
| 312 | 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 | 305 | {MODEL_PROVIDER_SETTINGS.map((providerSettings) => { |
| 351 | 306 | const provider = providerSettings.provider; |
| 352 | 307 | const customModels = getCustomModelsForProvider(settings, provider); |
| @@ -445,14 +400,9 @@ function SettingsRouteView() { · 92 unmodified lines | ||
| 445 | 400 | key={`${provider}:${slug}`} |
| 446 | 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"> | |
| 449 | − {provider === "codex" && shouldShowFastTierIcon(slug, codexServiceTier) ? ( | |
| 450 | − <ZapIcon className="size-3.5 shrink-0 text-amber-500" /> | |
| 451 | − ) : null} | |
| 452 | − <code className="min-w-0 flex-1 truncate text-xs text-foreground"> | |
| 453 | − {slug} | |
| 454 | − </code> | |
| 455 | − </div> | |
| 403 | + <code className="min-w-0 flex-1 truncate text-xs text-foreground"> | |
| 404 | + {slug} | |
| 405 | + </code> | |
| 456 | 406 | <Button |
| 457 | 407 | size="xs" |
| 458 | 408 | variant="ghost" |
| @@ -25,6 +25,7 @@ export interface AppState { · 24 unmodified lines | ||
| 25 | 25 | |
| 26 | 26 | const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; |
| 27 | 27 | const LEGACY_PERSISTED_STATE_KEYS = [ |
| 28 | + "t3code:renderer-state:v7", | |
| 28 | 29 | "t3code:renderer-state:v6", |
| 29 | 30 | "t3code:renderer-state:v5", |
| 30 | 31 | "t3code:renderer-state:v4", |
| @@ -4,6 +4,14 @@ import { tanstackRouter } from "@tanstack/router-plugin/vite"; · 3 unmodified lines | ||
| 4 | 4 | import { defineConfig } from "vite"; |
| 5 | 5 | |
| 6 | 6 | const port = Number(process.env.PORT ?? 5733); |
| 7 | +const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); | |
| 8 | + | |
| 9 | +const buildSourcemap = | |
| 10 | + sourcemapEnv === "0" || sourcemapEnv === "false" | |
| 11 | + ? false | |
| 12 | + : sourcemapEnv === "hidden" | |
| 13 | + ? "hidden" | |
| 14 | + : true; | |
| 7 | 15 | |
| 8 | 16 | export default defineConfig({ |
| 9 | 17 | plugins: [ |
| @@ -42,5 +50,6 @@ export default defineConfig({ · 32 unmodified lines | ||
| 42 | 50 | build: { |
| 43 | 51 | outDir: "dist", |
| 44 | 52 | emptyOutDir: true, |
| 53 | + sourcemap: buildSourcemap, | |
| 45 | 54 | }, |
| 46 | 55 | }); |
| @@ -14,7 +14,7 @@ · 13 unmodified lines | ||
| 14 | 14 | }, |
| 15 | 15 | "apps/desktop": { |
| 16 | 16 | "name": "@t3tools/desktop", |
| 17 | − "version": "0.0.5", | |
| 17 | + "version": "0.0.9", | |
| 18 | 18 | "dependencies": { |
| 19 | 19 | "effect": "catalog:", |
| 20 | 20 | "electron": "40.6.0", |
| @@ -43,7 +43,7 @@ · 22 unmodified lines | ||
| 43 | 43 | }, |
| 44 | 44 | "apps/server": { |
| 45 | 45 | "name": "t3", |
| 46 | − "version": "0.0.5", | |
| 46 | + "version": "0.0.9", | |
| 47 | 47 | "bin": { |
| 48 | 48 | "t3": "./dist/index.mjs", |
| 49 | 49 | }, |
| @@ -72,7 +72,7 @@ · 22 unmodified lines | ||
| 72 | 72 | }, |
| 73 | 73 | "apps/web": { |
| 74 | 74 | "name": "@t3tools/web", |
| 75 | − "version": "0.0.5", | |
| 75 | + "version": "0.0.9", | |
| 76 | 76 | "dependencies": { |
| 77 | 77 | "@base-ui/react": "^1.2.0", |
| 78 | 78 | "@lexical/react": "^0.41.0", |
| @@ -116,7 +116,7 @@ · 37 unmodified lines | ||
| 116 | 116 | }, |
| 117 | 117 | "packages/contracts": { |
| 118 | 118 | "name": "@t3tools/contracts", |
| 119 | − "version": "0.0.5", | |
| 119 | + "version": "0.0.9", | |
| 120 | 120 | "dependencies": { |
| 121 | 121 | "effect": "catalog:", |
| 122 | 122 | }, |
| @@ -45,6 +45,7 @@ · 44 unmodified lines | ||
| 45 | 45 | "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", |
| 46 | 46 | "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", |
| 47 | 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 | 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 | 50 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" |
| 50 | 51 | }, |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@t3tools/contracts", |
| 3 | − "version": "0.0.5", | |
| 3 | + "version": "0.0.9", | |
| 4 | 4 | "private": true, |
| 5 | 5 | "files": [ |
| 6 | 6 | "dist" |
| @@ -58,10 +58,21 @@ export type DesktopUpdateStatus = · 57 unmodified lines | ||
| 58 | 58 | | "downloaded" |
| 59 | 59 | | "error"; |
| 60 | 60 | |
| 61 | +export type DesktopRuntimeArch = "arm64" | "x64" | "other"; | |
| 62 | + | |
| 63 | +export interface DesktopRuntimeInfo { | |
| 64 | + hostArch: DesktopRuntimeArch; | |
| 65 | + appArch: DesktopRuntimeArch; | |
| 66 | + runningUnderArm64Translation: boolean; | |
| 67 | +} | |
| 68 | + | |
| 61 | 69 | export interface DesktopUpdateState { |
| 62 | 70 | enabled: boolean; |
| 63 | 71 | status: DesktopUpdateStatus; |
| 64 | 72 | currentVersion: string; |
| 73 | + hostArch: DesktopRuntimeArch; | |
| 74 | + appArch: DesktopRuntimeArch; | |
| 75 | + runningUnderArm64Translation: boolean; | |
| 65 | 76 | availableVersion: string | null; |
| 66 | 77 | downloadedVersion: string | null; |
| 67 | 78 | downloadPercent: number | null; |
| @@ -42,8 +42,6 @@ export const ProviderSandboxMode = Schema.Literals([ · 41 unmodified lines | ||
| 42 | 42 | "danger-full-access", |
| 43 | 43 | ]); |
| 44 | 44 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; |
| 45 | −export const ProviderServiceTier = Schema.Literals(["fast", "flex"]); | |
| 46 | −export type ProviderServiceTier = typeof ProviderServiceTier.Type; | |
| 47 | 45 | export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; |
| 48 | 46 | const CodexProviderStartOptions = Schema.Struct({ |
| 49 | 47 | binaryPath: Schema.optional(TrimmedNonEmptyString), |
| @@ -371,7 +369,6 @@ export const ThreadTurnStartCommand = Schema.Struct({ · 321 unmodified lines | ||
| 371 | 369 | }), |
| 372 | 370 | provider: Schema.optional(ProviderKind), |
| 373 | 371 | model: Schema.optional(TrimmedNonEmptyString), |
| 374 | − serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), | |
| 375 | 372 | modelOptions: Schema.optional(ProviderModelOptions), |
| 376 | 373 | providerOptions: Schema.optional(ProviderStartOptions), |
| 377 | 374 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| @@ -394,7 +391,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({ · 16 unmodified lines | ||
| 394 | 391 | }), |
| 395 | 392 | provider: Schema.optional(ProviderKind), |
| 396 | 393 | model: Schema.optional(TrimmedNonEmptyString), |
| 397 | − serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), | |
| 398 | 394 | modelOptions: Schema.optional(ProviderModelOptions), |
| 399 | 395 | providerOptions: Schema.optional(ProviderStartOptions), |
| 400 | 396 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| @@ -675,7 +671,6 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ · 274 unmodified lines | ||
| 675 | 671 | messageId: MessageId, |
| 676 | 672 | provider: Schema.optional(ProviderKind), |
| 677 | 673 | model: Schema.optional(TrimmedNonEmptyString), |
| 678 | − serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), | |
| 679 | 674 | modelOptions: Schema.optional(ProviderModelOptions), |
| 680 | 675 | providerOptions: Schema.optional(ProviderStartOptions), |
| 681 | 676 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| @@ -769,20 +764,6 @@ const EventBaseFields = { · 87 unmodified lines | ||
| 769 | 764 | metadata: OrchestrationEventMetadata, |
| 770 | 765 | } as const; |
| 771 | 766 | |
| 772 | −const PersistedEventBaseFields = { | |
| 773 | − sequence: NonNegativeInt, | |
| 774 | − eventId: EventId, | |
| 775 | − aggregateKind: OrchestrationAggregateKind, | |
| 776 | − streamId: Schema.Union([ProjectId, ThreadId]), | |
| 777 | − streamVersion: NonNegativeInt, | |
| 778 | − occurredAt: IsoDateTime, | |
| 779 | − commandId: Schema.NullOr(CommandId), | |
| 780 | − causationEventId: Schema.NullOr(EventId), | |
| 781 | − correlationId: Schema.NullOr(CommandId), | |
| 782 | − actorKind: OrchestrationActorKind, | |
| 783 | − metadata: OrchestrationEventMetadata, | |
| 784 | −} as const; | |
| 785 | − | |
| 786 | 767 | export const OrchestrationEvent = Schema.Union([ |
| 787 | 768 | Schema.Struct({ |
| 788 | 769 | ...EventBaseFields, |
| @@ -887,110 +868,6 @@ export const OrchestrationEvent = Schema.Union([ · 98 unmodified lines | ||
| 887 | 868 | ]); |
| 888 | 869 | export type OrchestrationEvent = typeof OrchestrationEvent.Type; |
| 889 | 870 | |
| 890 | −export const OrchestrationPersistedEvent = Schema.Union([ | |
| 891 | − Schema.Struct({ | |
| 892 | − ...PersistedEventBaseFields, | |
| 893 | − eventType: Schema.Literal("project.created"), | |
| 894 | − payload: ProjectCreatedPayload, | |
| 895 | − }), | |
| 896 | − Schema.Struct({ | |
| 897 | − ...PersistedEventBaseFields, | |
| 898 | − eventType: Schema.Literal("project.meta-updated"), | |
| 899 | − payload: ProjectMetaUpdatedPayload, | |
| 900 | − }), | |
| 901 | − Schema.Struct({ | |
| 902 | − ...PersistedEventBaseFields, | |
| 903 | − eventType: Schema.Literal("project.deleted"), | |
| 904 | − payload: ProjectDeletedPayload, | |
| 905 | − }), | |
| 906 | − Schema.Struct({ | |
| 907 | − ...PersistedEventBaseFields, | |
| 908 | − eventType: Schema.Literal("thread.created"), | |
| 909 | − payload: ThreadCreatedPayload, | |
| 910 | − }), | |
| 911 | − Schema.Struct({ | |
| 912 | − ...PersistedEventBaseFields, | |
| 913 | − eventType: Schema.Literal("thread.deleted"), | |
| 914 | − payload: ThreadDeletedPayload, | |
| 915 | − }), | |
| 916 | − Schema.Struct({ | |
| 917 | − ...PersistedEventBaseFields, | |
| 918 | − eventType: Schema.Literal("thread.meta-updated"), | |
| 919 | − payload: ThreadMetaUpdatedPayload, | |
| 920 | − }), | |
| 921 | − Schema.Struct({ | |
| 922 | − ...PersistedEventBaseFields, | |
| 923 | − eventType: Schema.Literal("thread.runtime-mode-set"), | |
| 924 | − payload: ThreadRuntimeModeSetPayload, | |
| 925 | − }), | |
| 926 | − Schema.Struct({ | |
| 927 | − ...PersistedEventBaseFields, | |
| 928 | − eventType: Schema.Literal("thread.interaction-mode-set"), | |
| 929 | − payload: ThreadInteractionModeSetPayload, | |
| 930 | − }), | |
| 931 | − Schema.Struct({ | |
| 932 | − ...PersistedEventBaseFields, | |
| 933 | − eventType: Schema.Literal("thread.message-sent"), | |
| 934 | − payload: ThreadMessageSentPayload, | |
| 935 | − }), | |
| 936 | − Schema.Struct({ | |
| 937 | − ...PersistedEventBaseFields, | |
| 938 | − eventType: Schema.Literal("thread.turn-start-requested"), | |
| 939 | − payload: ThreadTurnStartRequestedPayload, | |
| 940 | − }), | |
| 941 | − Schema.Struct({ | |
| 942 | − ...PersistedEventBaseFields, | |
| 943 | − eventType: Schema.Literal("thread.turn-interrupt-requested"), | |
| 944 | − payload: ThreadTurnInterruptRequestedPayload, | |
| 945 | − }), | |
| 946 | − Schema.Struct({ | |
| 947 | − ...PersistedEventBaseFields, | |
| 948 | − eventType: Schema.Literal("thread.approval-response-requested"), | |
| 949 | − payload: ThreadApprovalResponseRequestedPayload, | |
| 950 | − }), | |
| 951 | − Schema.Struct({ | |
| 952 | − ...PersistedEventBaseFields, | |
| 953 | − eventType: Schema.Literal("thread.user-input-response-requested"), | |
| 954 | − payload: ThreadUserInputResponseRequestedPayload, | |
| 955 | − }), | |
| 956 | − Schema.Struct({ | |
| 957 | − ...PersistedEventBaseFields, | |
| 958 | − eventType: Schema.Literal("thread.checkpoint-revert-requested"), | |
| 959 | − payload: ThreadCheckpointRevertRequestedPayload, | |
| 960 | − }), | |
| 961 | − Schema.Struct({ | |
| 962 | − ...PersistedEventBaseFields, | |
| 963 | − eventType: Schema.Literal("thread.reverted"), | |
| 964 | − payload: ThreadRevertedPayload, | |
| 965 | − }), | |
| 966 | − Schema.Struct({ | |
| 967 | − ...PersistedEventBaseFields, | |
| 968 | − eventType: Schema.Literal("thread.session-stop-requested"), | |
| 969 | − payload: ThreadSessionStopRequestedPayload, | |
| 970 | − }), | |
| 971 | − Schema.Struct({ | |
| 972 | − ...PersistedEventBaseFields, | |
| 973 | − eventType: Schema.Literal("thread.session-set"), | |
| 974 | − payload: ThreadSessionSetPayload, | |
| 975 | − }), | |
| 976 | − Schema.Struct({ | |
| 977 | − ...PersistedEventBaseFields, | |
| 978 | − eventType: Schema.Literal("thread.proposed-plan-upserted"), | |
| 979 | − payload: ThreadProposedPlanUpsertedPayload, | |
| 980 | − }), | |
| 981 | − Schema.Struct({ | |
| 982 | − ...PersistedEventBaseFields, | |
| 983 | − eventType: Schema.Literal("thread.turn-diff-completed"), | |
| 984 | − payload: ThreadTurnDiffCompletedPayload, | |
| 985 | − }), | |
| 986 | − Schema.Struct({ | |
| 987 | − ...PersistedEventBaseFields, | |
| 988 | − eventType: Schema.Literal("thread.activity-appended"), | |
| 989 | − payload: ThreadActivityAppendedPayload, | |
| 990 | − }), | |
| 991 | −]); | |
| 992 | −export type OrchestrationPersistedEvent = typeof OrchestrationPersistedEvent.Type; | |
| 993 | − | |
| 994 | 871 | export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]); |
| 995 | 872 | export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type; |
| 996 | 873 | |
| @@ -19,7 +19,6 @@ import { · 18 unmodified lines | ||
| 19 | 19 | ProviderKind, |
| 20 | 20 | ProviderRequestKind, |
| 21 | 21 | ProviderSandboxMode, |
| 22 | − ProviderServiceTier, | |
| 23 | 22 | ProviderUserInputAnswers, |
| 24 | 23 | RuntimeMode, |
| 25 | 24 | } from "./orchestration"; |
| @@ -65,7 +64,6 @@ export const ProviderSessionStartInput = Schema.Struct({ · 39 unmodified lines | ||
| 65 | 64 | model: Schema.optional(TrimmedNonEmptyStringSchema), |
| 66 | 65 | modelOptions: Schema.optional(ProviderModelOptions), |
| 67 | 66 | resumeCursor: Schema.optional(Schema.Unknown), |
| 68 | − serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), | |
| 69 | 67 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), |
| 70 | 68 | sandboxMode: Schema.optional(ProviderSandboxMode), |
| 71 | 69 | providerOptions: Schema.optional(ProviderStartOptions), |
| @@ -82,7 +80,6 @@ export const ProviderSendTurnInput = Schema.Struct({ · 10 unmodified lines | ||
| 82 | 80 | Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), |
| 83 | 81 | ), |
| 84 | 82 | model: Schema.optional(TrimmedNonEmptyStringSchema), |
| 85 | − serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), | |
| 86 | 83 | modelOptions: Schema.optional(ProviderModelOptions), |
| 87 | 84 | interactionMode: Schema.optional(ProviderInteractionMode), |
| 88 | 85 | }); |
| @@ -580,6 +580,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 579 unmodified lines | ||
| 580 | 580 | ChildProcess.make({ |
| 581 | 581 | cwd: repoRoot, |
| 582 | 582 | ...commandOutputOptions(options.verbose), |
| 583 | + // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). | |
| 584 | + shell: process.platform === "win32", | |
| 583 | 585 | })`bun run build:desktop`, |
| 584 | 586 | ); |
| 585 | 587 | } |
| @@ -642,6 +644,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 56 unmodified lines | ||
| 642 | 644 | ChildProcess.make({ |
| 643 | 645 | cwd: stageAppDir, |
| 644 | 646 | ...commandOutputOptions(options.verbose), |
| 647 | + // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). | |
| 648 | + shell: process.platform === "win32", | |
| 645 | 649 | })`bun install --production`, |
| 646 | 650 | ); |
| 647 | 651 | |
| @@ -680,6 +684,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 32 unmodified lines | ||
| 680 | 684 | cwd: stageAppDir, |
| 681 | 685 | env: buildEnv, |
| 682 | 686 | ...commandOutputOptions(options.verbose), |
| 687 | + // Windows needs shell mode to resolve .cmd shims. | |
| 688 | + shell: process.platform === "win32", | |
| 683 | 689 | })`bunx electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`, |
| 684 | 690 | ); |
| 685 | 691 | |
| @@ -455,6 +455,8 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { · 454 unmodified lines | ||
| 455 | 455 | stderr: "inherit", |
| 456 | 456 | env, |
| 457 | 457 | extendEnv: false, |
| 458 | + // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). | |
| 459 | + shell: process.platform === "win32", | |
| 458 | 460 | // Keep turbo in the same process group so terminal signals (Ctrl+C) |
| 459 | 461 | // reach it directly. Effect defaults to detached: true on non-Windows, |
| 460 | 462 | // which would put turbo in a new group and require manual forwarding. |
| @@ -0,0 +1,113 @@ | ||
| 1 | +import { execFileSync } from "node:child_process"; | |
| 2 | +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | +import { tmpdir } from "node:os"; | |
| 4 | +import { dirname, join, resolve } from "node:path"; | |
| 5 | +import { fileURLToPath } from "node:url"; | |
| 6 | + | |
| 7 | +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); | |
| 8 | + | |
| 9 | +const workspaceFiles = [ | |
| 10 | + "package.json", | |
| 11 | + "bun.lock", | |
| 12 | + "apps/server/package.json", | |
| 13 | + "apps/desktop/package.json", | |
| 14 | + "apps/web/package.json", | |
| 15 | + "apps/marketing/package.json", | |
| 16 | + "packages/contracts/package.json", | |
| 17 | + "packages/shared/package.json", | |
| 18 | + "scripts/package.json", | |
| 19 | +] as const; | |
| 20 | + | |
| 21 | +function copyWorkspaceManifestFixture(targetRoot: string): void { | |
| 22 | + for (const relativePath of workspaceFiles) { | |
| 23 | + const sourcePath = resolve(repoRoot, relativePath); | |
| 24 | + const destinationPath = resolve(targetRoot, relativePath); | |
| 25 | + mkdirSync(dirname(destinationPath), { recursive: true }); | |
| 26 | + cpSync(sourcePath, destinationPath); | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +function writeMacManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } { | |
| 31 | + const assetDirectory = resolve(targetRoot, "release-assets"); | |
| 32 | + mkdirSync(assetDirectory, { recursive: true }); | |
| 33 | + | |
| 34 | + const arm64Path = resolve(assetDirectory, "latest-mac.yml"); | |
| 35 | + const x64Path = resolve(assetDirectory, "latest-mac-x64.yml"); | |
| 36 | + | |
| 37 | + writeFileSync( | |
| 38 | + arm64Path, | |
| 39 | + `version: 9.9.9-smoke.0 | |
| 40 | +files: | |
| 41 | + - url: T3-Code-9.9.9-smoke.0-arm64.zip | |
| 42 | + sha512: arm64zip | |
| 43 | + size: 125621344 | |
| 44 | + - url: T3-Code-9.9.9-smoke.0-arm64.dmg | |
| 45 | + sha512: arm64dmg | |
| 46 | + size: 131754935 | |
| 47 | +path: T3-Code-9.9.9-smoke.0-arm64.zip | |
| 48 | +sha512: arm64zip | |
| 49 | +releaseDate: '2026-03-08T10:32:14.587Z' | |
| 50 | +`, | |
| 51 | + ); | |
| 52 | + | |
| 53 | + writeFileSync( | |
| 54 | + x64Path, | |
| 55 | + `version: 9.9.9-smoke.0 | |
| 56 | +files: | |
| 57 | + - url: T3-Code-9.9.9-smoke.0-x64.zip | |
| 58 | + sha512: x64zip | |
| 59 | + size: 132000112 | |
| 60 | + - url: T3-Code-9.9.9-smoke.0-x64.dmg | |
| 61 | + sha512: x64dmg | |
| 62 | + size: 138148807 | |
| 63 | +path: T3-Code-9.9.9-smoke.0-x64.zip | |
| 64 | +sha512: x64zip | |
| 65 | +releaseDate: '2026-03-08T10:36:07.540Z' | |
| 66 | +`, | |
| 67 | + ); | |
| 68 | + | |
| 69 | + return { arm64Path, x64Path }; | |
| 70 | +} | |
| 71 | + | |
| 72 | +function assertContains(haystack: string, needle: string, message: string): void { | |
| 73 | + if (!haystack.includes(needle)) { | |
| 74 | + throw new Error(message); | |
| 75 | + } | |
| 76 | +} | |
| 77 | + | |
| 78 | +const tempRoot = mkdtempSync(join(tmpdir(), "t3-release-smoke-")); | |
| 79 | + | |
| 80 | +try { | |
| 81 | + copyWorkspaceManifestFixture(tempRoot); | |
| 82 | + | |
| 83 | + execFileSync( | |
| 84 | + process.execPath, | |
| 85 | + [resolve(repoRoot, "scripts/update-release-package-versions.ts"), "9.9.9-smoke.0", "--root", tempRoot], | |
| 86 | + { | |
| 87 | + cwd: repoRoot, | |
| 88 | + stdio: "inherit", | |
| 89 | + }, | |
| 90 | + ); | |
| 91 | + | |
| 92 | + execFileSync("bun", ["install", "--lockfile-only", "--ignore-scripts"], { | |
| 93 | + cwd: tempRoot, | |
| 94 | + stdio: "inherit", | |
| 95 | + }); | |
| 96 | + | |
| 97 | + const lockfile = readFileSync(resolve(tempRoot, "bun.lock"), "utf8"); | |
| 98 | + assertContains(lockfile, `"version": "9.9.9-smoke.0"`, "Expected bun.lock to contain the smoke version."); | |
| 99 | + | |
| 100 | + const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); | |
| 101 | + execFileSync(process.execPath, [resolve(repoRoot, "scripts/merge-mac-update-manifests.ts"), arm64Path, x64Path], { | |
| 102 | + cwd: repoRoot, | |
| 103 | + stdio: "inherit", | |
| 104 | + }); | |
| 105 | + | |
| 106 | + const mergedManifest = readFileSync(arm64Path, "utf8"); | |
| 107 | + assertContains(mergedManifest, "T3-Code-9.9.9-smoke.0-arm64.zip", "Merged manifest is missing the arm64 asset."); | |
| 108 | + assertContains(mergedManifest, "T3-Code-9.9.9-smoke.0-x64.zip", "Merged manifest is missing the x64 asset."); | |
| 109 | + | |
| 110 | + console.log("Release smoke checks passed."); | |
| 111 | +} finally { | |
| 112 | + rmSync(tempRoot, { recursive: true, force: true }); | |
| 113 | +} | |
| @@ -0,0 +1,111 @@ | ||
| 1 | +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; | |
| 2 | +import { resolve } from "node:path"; | |
| 3 | +import { fileURLToPath } from "node:url"; | |
| 4 | + | |
| 5 | +export const releasePackageFiles = [ | |
| 6 | + "apps/server/package.json", | |
| 7 | + "apps/desktop/package.json", | |
| 8 | + "apps/web/package.json", | |
| 9 | + "packages/contracts/package.json", | |
| 10 | +] as const; | |
| 11 | + | |
| 12 | +interface UpdateReleasePackageVersionsOptions { | |
| 13 | + readonly rootDir?: string; | |
| 14 | +} | |
| 15 | + | |
| 16 | +interface MutablePackageJson { | |
| 17 | + version?: string; | |
| 18 | + [key: string]: unknown; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export function updateReleasePackageVersions( | |
| 22 | + version: string, | |
| 23 | + options: UpdateReleasePackageVersionsOptions = {}, | |
| 24 | +): { changed: boolean } { | |
| 25 | + const rootDir = resolve(options.rootDir ?? process.cwd()); | |
| 26 | + let changed = false; | |
| 27 | + | |
| 28 | + for (const relativePath of releasePackageFiles) { | |
| 29 | + const filePath = resolve(rootDir, relativePath); | |
| 30 | + const packageJson = JSON.parse(readFileSync(filePath, "utf8")) as MutablePackageJson; | |
| 31 | + if (packageJson.version === version) { | |
| 32 | + continue; | |
| 33 | + } | |
| 34 | + | |
| 35 | + packageJson.version = version; | |
| 36 | + writeFileSync(filePath, `${JSON.stringify(packageJson, null, 2)}\n`); | |
| 37 | + changed = true; | |
| 38 | + } | |
| 39 | + | |
| 40 | + return { changed }; | |
| 41 | +} | |
| 42 | + | |
| 43 | +function parseArgs(argv: ReadonlyArray<string>): { | |
| 44 | + version: string; | |
| 45 | + rootDir: string | undefined; | |
| 46 | + writeGithubOutput: boolean; | |
| 47 | +} { | |
| 48 | + let version: string | undefined; | |
| 49 | + let rootDir: string | undefined; | |
| 50 | + let writeGithubOutput = false; | |
| 51 | + | |
| 52 | + for (let index = 0; index < argv.length; index += 1) { | |
| 53 | + const argument = argv[index]; | |
| 54 | + if (argument === undefined) { | |
| 55 | + continue; | |
| 56 | + } | |
| 57 | + | |
| 58 | + if (argument === "--github-output") { | |
| 59 | + writeGithubOutput = true; | |
| 60 | + continue; | |
| 61 | + } | |
| 62 | + | |
| 63 | + if (argument === "--root") { | |
| 64 | + rootDir = argv[index + 1]; | |
| 65 | + if (!rootDir) { | |
| 66 | + throw new Error("Missing value for --root."); | |
| 67 | + } | |
| 68 | + index += 1; | |
| 69 | + continue; | |
| 70 | + } | |
| 71 | + | |
| 72 | + if (argument.startsWith("--")) { | |
| 73 | + throw new Error(`Unknown argument: ${argument}`); | |
| 74 | + } | |
| 75 | + | |
| 76 | + if (version !== undefined) { | |
| 77 | + throw new Error("Only one release version can be provided."); | |
| 78 | + } | |
| 79 | + version = argument; | |
| 80 | + } | |
| 81 | + | |
| 82 | + if (!version) { | |
| 83 | + throw new Error( | |
| 84 | + "Usage: node scripts/update-release-package-versions.ts <version> [--root <path>] [--github-output]", | |
| 85 | + ); | |
| 86 | + } | |
| 87 | + | |
| 88 | + return { version, rootDir, writeGithubOutput }; | |
| 89 | +} | |
| 90 | + | |
| 91 | +const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); | |
| 92 | + | |
| 93 | +if (isMain) { | |
| 94 | + const { version, rootDir, writeGithubOutput } = parseArgs(process.argv.slice(2)); | |
| 95 | + const { changed } = updateReleasePackageVersions( | |
| 96 | + version, | |
| 97 | + rootDir === undefined ? {} : { rootDir }, | |
| 98 | + ); | |
| 99 | + | |
| 100 | + if (!changed) { | |
| 101 | + console.log("All package.json versions already match release version."); | |
| 102 | + } | |
| 103 | + | |
| 104 | + if (writeGithubOutput) { | |
| 105 | + const githubOutputPath = process.env.GITHUB_OUTPUT; | |
| 106 | + if (!githubOutputPath) { | |
| 107 | + throw new Error("GITHUB_OUTPUT is required when --github-output is set."); | |
| 108 | + } | |
| 109 | + appendFileSync(githubOutputPath, `changed=${changed}\n`); | |
| 110 | + } | |
| 111 | +} | |