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 | run: | | 69 | run: | |
| 70 | test -f apps/desktop/dist-electron/preload.js | 70 | test -f apps/desktop/dist-electron/preload.js |
| 71 | grep -nE "desktopBridge|getWsUrl|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js | 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 | needs: [preflight, build, publish_cli] | 255 | needs: [preflight, build, publish_cli] |
| 256 | runs-on: ubuntu-24.04 | 256 | runs-on: ubuntu-24.04 |
| 257 | steps: | 257 | steps: |
| 258 | - name: Checkout | ||
| 259 | uses: actions/checkout@v4 | ||
| 260 | with: | ||
| 261 | ref: ${{ needs.preflight.outputs.ref }} | ||
| 262 | |||
| 263 | - name: Setup Node | ||
| 264 | uses: actions/setup-node@v4 | ||
| 265 | with: | ||
| 266 | node-version-file: package.json | ||
| 267 | |||
| 258 | - name: Download all desktop artifacts | 268 | - name: Download all desktop artifacts |
| 259 | uses: actions/download-artifact@v4 | 269 | uses: actions/download-artifact@v4 |
| 260 | with: | 270 | with: |
| @@ -312,33 +322,7 @@ jobs: · 51 unmodified lines | |||
| 312 | name: Update version strings | 322 | name: Update version strings |
| 313 | env: | 323 | env: |
| 314 | RELEASE_VERSION: ${{ needs.preflight.outputs.version }} | 324 | RELEASE_VERSION: ${{ needs.preflight.outputs.version }} |
| 315 | run: | | 325 | run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output |
| 316 | node --input-type=module -e ' | ||
| 317 | import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; | ||
| 318 | |||
| 319 | const files = [ | ||
| 320 | "apps/server/package.json", | ||
| 321 | "apps/desktop/package.json", | ||
| 322 | "apps/web/package.json", | ||
| 323 | "packages/contracts/package.json", | ||
| 324 | ]; | ||
| 325 | |||
| 326 | let changed = false; | ||
| 327 | for (const file of files) { | ||
| 328 | const packageJson = JSON.parse(readFileSync(file, "utf8")); | ||
| 329 | if (packageJson.version !== process.env.RELEASE_VERSION) { | ||
| 330 | packageJson.version = process.env.RELEASE_VERSION; | ||
| 331 | writeFileSync(file, `${JSON.stringify(packageJson, null, 2)}\n`); | ||
| 332 | changed = true; | ||
| 333 | } | ||
| 334 | } | ||
| 335 | |||
| 336 | if (!changed) { | ||
| 337 | console.log("All package.json versions already match release version."); | ||
| 338 | } | ||
| 339 | |||
| 340 | appendFileSync(process.env.GITHUB_OUTPUT, `changed=${changed}\n`); | ||
| 341 | ' | ||
| 342 | 326 | ||
| 343 | - name: Format package.json files | 327 | - name: Format package.json files |
| 344 | if: steps.update_versions.outputs.changed == 'true' | 328 | if: steps.update_versions.outputs.changed == 'true' |
| @@ -346,7 +330,7 @@ jobs: · 1 unmodified line | |||
| 346 | 330 | ||
| 347 | - name: Refresh lockfile | 331 | - name: Refresh lockfile |
| 348 | if: steps.update_versions.outputs.changed == 'true' | 332 | if: steps.update_versions.outputs.changed == 'true' |
| 349 | run: bun install | 333 | run: bun install --lockfile-only --ignore-scripts |
| 350 | 334 | ||
| 351 | - name: Commit and push version bump | 335 | - name: Commit and push version bump |
| 352 | if: steps.update_versions.outputs.changed == 'true' | 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 | We are not accepting contributions yet. | 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 | Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). | 28 | Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@t3tools/desktop", | 2 | "name": "@t3tools/desktop", |
| 3 | "version": "0.0.5", | 3 | "version": "0.0.9", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "main": "dist-electron/main.js", | 5 | "main": "dist-electron/main.js", |
| 6 | "scripts": { | 6 | "scripts": { |
| @@ -31,6 +31,7 @@ import { · 30 unmodified lines | |||
| 31 | reduceDesktopUpdateStateOnNoUpdate, | 31 | reduceDesktopUpdateStateOnNoUpdate, |
| 32 | reduceDesktopUpdateStateOnUpdateAvailable, | 32 | reduceDesktopUpdateStateOnUpdateAvailable, |
| 33 | } from "./updateMachine"; | 33 | } from "./updateMachine"; |
| 34 | import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; | ||
| 34 | 35 | ||
| 35 | fixPath(); | 36 | fixPath(); |
| 36 | 37 | ||
| @@ -50,6 +51,8 @@ const ROOT_DIR = Path.resolve(__dirname, "../../.."); · 13 unmodified lines | |||
| 50 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | 51 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 51 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | 52 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; |
| 52 | const APP_USER_MODEL_ID = "com.t3tools.t3code"; | 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 | const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; | 56 | const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; |
| 54 | const COMMIT_HASH_DISPLAY_LENGTH = 12; | 57 | const COMMIT_HASH_DISPLAY_LENGTH = 12; |
| 55 | const LOG_DIR = Path.join(STATE_DIR, "logs"); | 58 | const LOG_DIR = Path.join(STATE_DIR, "logs"); |
| @@ -78,7 +81,13 @@ let backendLogSink: RotatingFileSink | null = null; · 22 unmodified lines | |||
| 78 | let restoreStdIoCapture: (() => void) | null = null; | 81 | let restoreStdIoCapture: (() => void) | null = null; |
| 79 | 82 | ||
| 80 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; | 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 | function logTimestamp(): string { | 92 | function logTimestamp(): string { |
| 84 | return new Date().toISOString(); | 93 | return new Date().toISOString(); |
| @@ -112,6 +121,25 @@ function formatErrorMessage(error: unknown): string { · 27 unmodified lines | |||
| 112 | return String(error); | 121 | return String(error); |
| 113 | } | 122 | } |
| 114 | 123 | ||
| 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 | function writeDesktopStreamChunk( | 143 | function writeDesktopStreamChunk( |
| 116 | streamName: "stdout" | "stderr", | 144 | streamName: "stdout" | "stderr", |
| 117 | chunk: unknown, | 145 | chunk: unknown, |
| @@ -574,6 +602,34 @@ function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { · 456 unmodified lines | |||
| 574 | return resolveResourcePath(`icon.${ext}`); | 602 | return resolveResourcePath(`icon.${ext}`); |
| 575 | } | 603 | } |
| 576 | 604 | ||
| 605 | /** | ||
| 606 | * Resolve the Electron userData directory path. | ||
| 607 | * | ||
| 608 | * Electron derives the default userData path from `productName` in | ||
| 609 | * package.json, which currently produces directories with spaces and | ||
| 610 | * parentheses (e.g. `~/.config/T3 Code (Alpha)` on Linux). This is | ||
| 611 | * unfriendly for shell usage and violates Linux naming conventions. | ||
| 612 | * | ||
| 613 | * We override it to a clean lowercase name (`t3code`). If the legacy | ||
| 614 | * directory already exists we keep using it so existing users don't | ||
| 615 | * lose their Chromium profile data (localStorage, cookies, sessions). | ||
| 616 | */ | ||
| 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 | function configureAppIdentity(): void { | 633 | function configureAppIdentity(): void { |
| 578 | app.setName(APP_DISPLAY_NAME); | 634 | app.setName(APP_DISPLAY_NAME); |
| 579 | const commitHash = resolveAboutCommitHash(); | 635 | const commitHash = resolveAboutCommitHash(); |
| @@ -659,6 +715,7 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed · 79 unmodified lines | |||
| 659 | } | 715 | } |
| 660 | updateDownloadInFlight = true; | 716 | updateDownloadInFlight = true; |
| 661 | setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState)); | 717 | setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState)); |
| 718 | autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); | ||
| 662 | console.info("[desktop-updater] Downloading update..."); | 719 | console.info("[desktop-updater] Downloading update..."); |
| 663 | 720 | ||
| 664 | try { | 721 | try { |
| @@ -697,7 +754,7 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed · 32 unmodified lines | |||
| 697 | function configureAutoUpdater(): void { | 754 | function configureAutoUpdater(): void { |
| 698 | const enabled = shouldEnableAutoUpdates(); | 755 | const enabled = shouldEnableAutoUpdates(); |
| 699 | setUpdateState({ | 756 | setUpdateState({ |
| 700 | ...createInitialDesktopUpdateState(app.getVersion()), | 757 | ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo), |
| 701 | enabled, | 758 | enabled, |
| 702 | status: enabled ? "idle" : "disabled", | 759 | status: enabled ? "idle" : "disabled", |
| 703 | }); | 760 | }); |
| @@ -731,8 +788,15 @@ function configureAutoUpdater(): void { · 27 unmodified lines | |||
| 731 | autoUpdater.channel = DESKTOP_UPDATE_CHANNEL; | 788 | autoUpdater.channel = DESKTOP_UPDATE_CHANNEL; |
| 732 | autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE; | 789 | autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE; |
| 733 | autoUpdater.allowDowngrade = false; | 790 | autoUpdater.allowDowngrade = false; |
| 791 | autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); | ||
| 734 | let lastLoggedDownloadMilestone = -1; | 792 | let lastLoggedDownloadMilestone = -1; |
| 735 | 793 | ||
| 794 | if (isArm64HostRunningIntelBuild(desktopRuntimeInfo)) { | ||
| 795 | console.info( | ||
| 796 | "[desktop-updater] Apple Silicon host detected while running Intel build; updates will switch to arm64 packages.", | ||
| 797 | ); | ||
| 798 | } | ||
| 799 | |||
| 736 | autoUpdater.on("checking-for-update", () => { | 800 | autoUpdater.on("checking-for-update", () => { |
| 737 | console.info("[desktop-updater] Looking for updates..."); | 801 | console.info("[desktop-updater] Looking for updates..."); |
| 738 | }); | 802 | }); |
| @@ -1033,23 +1097,13 @@ function registerIpcHandlers(): void { · 294 unmodified lines | |||
| 1033 | 1097 | ||
| 1034 | ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); | 1098 | ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); |
| 1035 | ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { | 1099 | ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { |
| 1036 | if (typeof rawUrl !== "string" || rawUrl.length === 0) { | 1100 | const externalUrl = getSafeExternalUrl(rawUrl); |
| 1037 | return false; | 1101 | if (!externalUrl) { |
| 1038 | } | ||
| 1039 | |||
| 1040 | let parsedUrl: URL; | ||
| 1041 | try { | ||
| 1042 | parsedUrl = new URL(rawUrl); | ||
| 1043 | } catch { | ||
| 1044 | return false; | ||
| 1045 | } | ||
| 1046 | |||
| 1047 | if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { | ||
| 1048 | return false; | 1102 | return false; |
| 1049 | } | 1103 | } |
| 1050 | 1104 | ||
| 1051 | try { | 1105 | try { |
| 1052 | await shell.openExternal(parsedUrl.toString()); | 1106 | await shell.openExternal(externalUrl); |
| 1053 | return true; | 1107 | return true; |
| 1054 | } catch { | 1108 | } catch { |
| 1055 | return false; | 1109 | return false; |
| @@ -1114,7 +1168,42 @@ function createWindow(): BrowserWindow { · 58 unmodified lines | |||
| 1114 | }, | 1168 | }, |
| 1115 | }); | 1169 | }); |
| 1116 | 1170 | ||
| 1117 | window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); | 1171 | window.webContents.on("context-menu", (event, params) => { |
| 1172 | event.preventDefault(); | ||
| 1173 | |||
| 1174 | const menuTemplate: MenuItemConstructorOptions[] = []; | ||
| 1175 | |||
| 1176 | if (params.misspelledWord) { | ||
| 1177 | for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { | ||
| 1178 | menuTemplate.push({ | ||
| 1179 | label: suggestion, | ||
| 1180 | click: () => window.webContents.replaceMisspelling(suggestion), | ||
| 1181 | }); | ||
| 1182 | } | ||
| 1183 | if (params.dictionarySuggestions.length === 0) { | ||
| 1184 | menuTemplate.push({ label: "No suggestions", enabled: false }); | ||
| 1185 | } | ||
| 1186 | menuTemplate.push({ type: "separator" }); | ||
| 1187 | } | ||
| 1188 | |||
| 1189 | menuTemplate.push( | ||
| 1190 | { role: "cut", enabled: params.editFlags.canCut }, | ||
| 1191 | { role: "copy", enabled: params.editFlags.canCopy }, | ||
| 1192 | { role: "paste", enabled: params.editFlags.canPaste }, | ||
| 1193 | { role: "selectAll", enabled: params.editFlags.canSelectAll }, | ||
| 1194 | ); | ||
| 1195 | |||
| 1196 | Menu.buildFromTemplate(menuTemplate).popup({ window }); | ||
| 1197 | }); | ||
| 1198 | |||
| 1199 | window.webContents.setWindowOpenHandler(({ url }) => { | ||
| 1200 | const externalUrl = getSafeExternalUrl(url); | ||
| 1201 | if (externalUrl) { | ||
| 1202 | void shell.openExternal(externalUrl); | ||
| 1203 | } | ||
| 1204 | return { action: "deny" }; | ||
| 1205 | }); | ||
| 1206 | |||
| 1118 | window.on("page-title-updated", (event) => { | 1207 | window.on("page-title-updated", (event) => { |
| 1119 | event.preventDefault(); | 1208 | event.preventDefault(); |
| 1120 | window.setTitle(APP_DISPLAY_NAME); | 1209 | window.setTitle(APP_DISPLAY_NAME); |
| @@ -1143,6 +1232,11 @@ function createWindow(): BrowserWindow { · 22 unmodified lines | |||
| 1143 | return window; | 1232 | return window; |
| 1144 | } | 1233 | } |
| 1145 | 1234 | ||
| 1235 | // Override Electron's userData path before the `ready` event so that | ||
| 1236 | // Chromium session data uses a filesystem-friendly directory name. | ||
| 1237 | // Must be called synchronously at the top level — before `app.whenReady()`. | ||
| 1238 | app.setPath("userData", resolveUserDataPath()); | ||
| 1239 | |||
| 1146 | configureAppIdentity(); | 1240 | configureAppIdentity(); |
| 1147 | 1241 | ||
| 1148 | async function bootstrap(): Promise<void> { | 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 | reduceDesktopUpdateStateOnUpdateAvailable, | 13 | reduceDesktopUpdateStateOnUpdateAvailable, |
| 14 | } from "./updateMachine"; | 14 | } from "./updateMachine"; |
| 15 | 15 | ||
| 16 | const runtimeInfo = { | ||
| 17 | hostArch: "x64", | ||
| 18 | appArch: "x64", | ||
| 19 | runningUnderArm64Translation: false, | ||
| 20 | } as const; | ||
| 21 | |||
| 16 | describe("updateMachine", () => { | 22 | describe("updateMachine", () => { |
| 17 | it("clears transient errors when a check starts", () => { | 23 | it("clears transient errors when a check starts", () => { |
| 18 | const state = reduceDesktopUpdateStateOnCheckStart( | 24 | const state = reduceDesktopUpdateStateOnCheckStart( |
| 19 | { | 25 | { |
| 20 | ...createInitialDesktopUpdateState("1.0.0"), | 26 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), |
| 21 | enabled: true, | 27 | enabled: true, |
| 22 | status: "error", | 28 | status: "error", |
| 23 | message: "network", | 29 | message: "network", |
| @@ -36,7 +42,7 @@ describe("updateMachine", () => { · 12 unmodified lines | |||
| 36 | it("records a check failure without exposing an action", () => { | 42 | it("records a check failure without exposing an action", () => { |
| 37 | const state = reduceDesktopUpdateStateOnCheckFailure( | 43 | const state = reduceDesktopUpdateStateOnCheckFailure( |
| 38 | { | 44 | { |
| 39 | ...createInitialDesktopUpdateState("1.0.0"), | 45 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), |
| 40 | enabled: true, | 46 | enabled: true, |
| 41 | status: "checking", | 47 | status: "checking", |
| 42 | }, | 48 | }, |
| @@ -52,7 +58,7 @@ describe("updateMachine", () => { · 9 unmodified lines | |||
| 52 | it("preserves available version on download failure for retry", () => { | 58 | it("preserves available version on download failure for retry", () => { |
| 53 | const state = reduceDesktopUpdateStateOnDownloadFailure( | 59 | const state = reduceDesktopUpdateStateOnDownloadFailure( |
| 54 | { | 60 | { |
| 55 | ...createInitialDesktopUpdateState("1.0.0"), | 61 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), |
| 56 | enabled: true, | 62 | enabled: true, |
| 57 | status: "downloading", | 63 | status: "downloading", |
| 58 | availableVersion: "1.1.0", | 64 | availableVersion: "1.1.0", |
| @@ -70,7 +76,7 @@ describe("updateMachine", () => { · 11 unmodified lines | |||
| 70 | it("transitions to downloaded and then preserves install retry state", () => { | 76 | it("transitions to downloaded and then preserves install retry state", () => { |
| 71 | const downloaded = reduceDesktopUpdateStateOnDownloadComplete( | 77 | const downloaded = reduceDesktopUpdateStateOnDownloadComplete( |
| 72 | { | 78 | { |
| 73 | ...createInitialDesktopUpdateState("1.0.0"), | 79 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), |
| 74 | enabled: true, | 80 | enabled: true, |
| 75 | status: "downloading", | 81 | status: "downloading", |
| 76 | availableVersion: "1.1.0", | 82 | availableVersion: "1.1.0", |
| @@ -89,7 +95,7 @@ describe("updateMachine", () => { · 12 unmodified lines | |||
| 89 | it("clears stale download state when no update is available", () => { | 95 | it("clears stale download state when no update is available", () => { |
| 90 | const state = reduceDesktopUpdateStateOnNoUpdate( | 96 | const state = reduceDesktopUpdateStateOnNoUpdate( |
| 91 | { | 97 | { |
| 92 | ...createInitialDesktopUpdateState("1.0.0"), | 98 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), |
| 93 | enabled: true, | 99 | enabled: true, |
| 94 | status: "error", | 100 | status: "error", |
| 95 | availableVersion: "1.1.0", | 101 | availableVersion: "1.1.0", |
| @@ -111,7 +117,7 @@ describe("updateMachine", () => { · 15 unmodified lines | |||
| 111 | it("tracks available, download start, and progress cleanly", () => { | 117 | it("tracks available, download start, and progress cleanly", () => { |
| 112 | const available = reduceDesktopUpdateStateOnUpdateAvailable( | 118 | const available = reduceDesktopUpdateStateOnUpdateAvailable( |
| 113 | { | 119 | { |
| 114 | ...createInitialDesktopUpdateState("1.0.0"), | 120 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), |
| 115 | enabled: true, | 121 | enabled: true, |
| 116 | status: "checking", | 122 | status: "checking", |
| 117 | }, | 123 | }, |
| @@ -1,12 +1,18 @@ | |||
| 1 | import type { DesktopUpdateState } from "@t3tools/contracts"; | 1 | import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState"; | 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 | return { | 9 | return { |
| 7 | enabled: false, | 10 | enabled: false, |
| 8 | status: "disabled", | 11 | status: "disabled", |
| 9 | currentVersion, | 12 | currentVersion, |
| 13 | hostArch: runtimeInfo.hostArch, | ||
| 14 | appArch: runtimeInfo.appArch, | ||
| 15 | runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation, | ||
| 10 | availableVersion: null, | 16 | availableVersion: null, |
| 11 | downloadedVersion: null, | 17 | downloadedVersion: null, |
| 12 | downloadPercent: null, | 18 | downloadPercent: null, |
| @@ -152,4 +158,3 @@ export function reduceDesktopUpdateStateOnInstallFailure( · 139 unmodified lines | |||
| 152 | canRetry: true, | 158 | canRetry: true, |
| 153 | }; | 159 | }; |
| 154 | } | 160 | } |
| 155 | |||
| @@ -12,6 +12,9 @@ const baseState: DesktopUpdateState = { · 11 unmodified lines | |||
| 12 | enabled: true, | 12 | enabled: true, |
| 13 | status: "idle", | 13 | status: "idle", |
| 14 | currentVersion: "1.0.0", | 14 | currentVersion: "1.0.0", |
| 15 | hostArch: "x64", | ||
| 16 | appArch: "x64", | ||
| 17 | runningUnderArm64Translation: false, | ||
| 15 | availableVersion: null, | 18 | availableVersion: null, |
| 16 | downloadedVersion: null, | 19 | downloadedVersion: null, |
| 17 | downloadPercent: null, | 20 | downloadPercent: null, |
| @@ -77,12 +77,10 @@ · 76 unmodified lines | |||
| 77 | const ua = navigator.userAgent; | 77 | const ua = navigator.userAgent; |
| 78 | if (/Win/i.test(ua)) return { os: "win", label: "Download for Windows" }; | 78 | if (/Win/i.test(ua)) return { os: "win", label: "Download for Windows" }; |
| 79 | if (/Mac/i.test(ua)) { | 79 | if (/Mac/i.test(ua)) { |
| 80 | const isArm = | ||
| 81 | /arm|aarch/i.test(navigator.platform ?? "") || ua.includes("ARM"); | ||
| 82 | return { | 80 | return { |
| 83 | os: "mac", | 81 | os: "mac", |
| 84 | label: `Download for macOS`, | 82 | label: `Download for macOS`, |
| 85 | arch: isArm ? "arm64" : "x64", | 83 | arch: "arm64", |
| 86 | }; | 84 | }; |
| 87 | } | 85 | } |
| 88 | if (/Linux/i.test(ua)) return { os: "linux", label: "Download for Linux" }; | 86 | if (/Linux/i.test(ua)) return { os: "linux", label: "Download for Linux" }; |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "t3", | 2 | "name": "t3", |
| 3 | "version": "0.0.5", | 3 | "version": "0.0.9", |
| 4 | "repository": { | 4 | "repository": { |
| 5 | "type": "git", | 5 | "type": "git", |
| 6 | "url": "https://github.com/pingdotgg/t3code", | 6 | "url": "https://github.com/pingdotgg/t3code", |
| @@ -132,6 +132,7 @@ const buildCmd = Command.make( · 131 unmodified lines | |||
| 132 | cwd: serverDir, | 132 | cwd: serverDir, |
| 133 | stdout: config.verbose ? "inherit" : "ignore", | 133 | stdout: config.verbose ? "inherit" : "ignore", |
| 134 | stderr: "inherit", | 134 | stderr: "inherit", |
| 135 | // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). | ||
| 135 | shell: process.platform === "win32", | 136 | shell: process.platform === "win32", |
| 136 | })`bun tsdown`, | 137 | })`bun tsdown`, |
| 137 | ); | 138 | ); |
| @@ -226,6 +227,7 @@ const publishCmd = Command.make( · 88 unmodified lines | |||
| 226 | cwd: serverDir, | 227 | cwd: serverDir, |
| 227 | stdout: config.verbose ? "inherit" : "ignore", | 228 | stdout: config.verbose ? "inherit" : "ignore", |
| 228 | stderr: "inherit", | 229 | stderr: "inherit", |
| 230 | // Windows needs shell mode to resolve .cmd shims. | ||
| 229 | shell: process.platform === "win32", | 231 | shell: process.platform === "win32", |
| 230 | }), | 232 | }), |
| 231 | ); | 233 | ); |
| @@ -5,7 +5,6 @@ import { · 4 unmodified lines | |||
| 5 | type OrchestrationEvent, | 5 | type OrchestrationEvent, |
| 6 | type ProviderModelOptions, | 6 | type ProviderModelOptions, |
| 7 | type ProviderKind, | 7 | type ProviderKind, |
| 8 | type ProviderServiceTier, | ||
| 9 | type ProviderStartOptions, | 8 | type ProviderStartOptions, |
| 10 | type OrchestrationSession, | 9 | type OrchestrationSession, |
| 11 | ThreadId, | 10 | ThreadId, |
| @@ -206,7 +205,6 @@ const make = Effect.gen(function* () { · 194 unmodified lines | |||
| 206 | readonly provider?: ProviderKind; | 205 | readonly provider?: ProviderKind; |
| 207 | readonly model?: string; | 206 | readonly model?: string; |
| 208 | readonly modelOptions?: ProviderModelOptions; | 207 | readonly modelOptions?: ProviderModelOptions; |
| 209 | readonly serviceTier?: ProviderServiceTier | null; | ||
| 210 | readonly providerOptions?: ProviderStartOptions; | 208 | readonly providerOptions?: ProviderStartOptions; |
| 211 | }, | 209 | }, |
| 212 | ) { | 210 | ) { |
| @@ -242,7 +240,6 @@ const make = Effect.gen(function* () { · 29 unmodified lines | |||
| 242 | : {}), | 240 | : {}), |
| 243 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), | 241 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), |
| 244 | ...(desiredModel ? { model: desiredModel } : {}), | 242 | ...(desiredModel ? { model: desiredModel } : {}), |
| 245 | ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}), | ||
| 246 | ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), | 243 | ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), |
| 247 | ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}), | 244 | ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}), |
| 248 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), | 245 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), |
| @@ -328,7 +325,6 @@ const make = Effect.gen(function* () { · 79 unmodified lines | |||
| 328 | readonly attachments?: ReadonlyArray<ChatAttachment>; | 325 | readonly attachments?: ReadonlyArray<ChatAttachment>; |
| 329 | readonly provider?: ProviderKind; | 326 | readonly provider?: ProviderKind; |
| 330 | readonly model?: string; | 327 | readonly model?: string; |
| 331 | readonly serviceTier?: ProviderServiceTier | null; | ||
| 332 | readonly modelOptions?: ProviderModelOptions; | 328 | readonly modelOptions?: ProviderModelOptions; |
| 333 | readonly providerOptions?: ProviderStartOptions; | 329 | readonly providerOptions?: ProviderStartOptions; |
| 334 | readonly interactionMode?: "default" | "plan"; | 330 | readonly interactionMode?: "default" | "plan"; |
| @@ -344,7 +340,6 @@ const make = Effect.gen(function* () { · 9 unmodified lines | |||
| 344 | yield* ensureSessionForThread(input.threadId, input.createdAt, { | 340 | yield* ensureSessionForThread(input.threadId, input.createdAt, { |
| 345 | ...(input.provider !== undefined ? { provider: input.provider } : {}), | 341 | ...(input.provider !== undefined ? { provider: input.provider } : {}), |
| 346 | ...(input.model !== undefined ? { model: input.model } : {}), | 342 | ...(input.model !== undefined ? { model: input.model } : {}), |
| 347 | ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), | ||
| 348 | ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), | 343 | ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), |
| 349 | ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), | 344 | ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), |
| 350 | }); | 345 | }); |
| @@ -366,7 +361,6 @@ const make = Effect.gen(function* () { · 15 unmodified lines | |||
| 366 | ...(normalizedInput ? { input: normalizedInput } : {}), | 361 | ...(normalizedInput ? { input: normalizedInput } : {}), |
| 367 | ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), | 362 | ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), |
| 368 | ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), | 363 | ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), |
| 369 | ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), | ||
| 370 | ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), | 364 | ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), |
| 371 | ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), | 365 | ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), |
| 372 | }); | 366 | }); |
| @@ -481,9 +475,6 @@ const make = Effect.gen(function* () { · 108 unmodified lines | |||
| 481 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), | 475 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), |
| 482 | ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), | 476 | ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), |
| 483 | ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), | 477 | ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), |
| 484 | ...(event.payload.serviceTier !== undefined | ||
| 485 | ? { serviceTier: event.payload.serviceTier } | ||
| 486 | : {}), | ||
| 487 | ...(event.payload.modelOptions !== undefined | 478 | ...(event.payload.modelOptions !== undefined |
| 488 | ? { modelOptions: event.payload.modelOptions } | 479 | ? { modelOptions: event.payload.modelOptions } |
| 489 | : {}), | 480 | : {}), |
| @@ -301,7 +301,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 300 unmodified lines | |||
| 301 | messageId: command.message.messageId, | 301 | messageId: command.message.messageId, |
| 302 | ...(command.provider !== undefined ? { provider: command.provider } : {}), | 302 | ...(command.provider !== undefined ? { provider: command.provider } : {}), |
| 303 | ...(command.model !== undefined ? { model: command.model } : {}), | 303 | ...(command.model !== undefined ? { model: command.model } : {}), |
| 304 | ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}), | ||
| 305 | ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), | 304 | ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), |
| 306 | ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}), | 305 | ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}), |
| 307 | assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, | 306 | assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, |
| @@ -1354,7 +1354,6 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => · 1353 unmodified lines | |||
| 1354 | threadId: input.threadId, | 1354 | threadId: input.threadId, |
| 1355 | ...(input.input !== undefined ? { input: input.input } : {}), | 1355 | ...(input.input !== undefined ? { input: input.input } : {}), |
| 1356 | ...(input.model !== undefined ? { model: input.model } : {}), | 1356 | ...(input.model !== undefined ? { model: input.model } : {}), |
| 1357 | ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), | ||
| 1358 | ...(input.modelOptions?.codex?.reasoningEffort !== undefined | 1357 | ...(input.modelOptions?.codex?.reasoningEffort !== undefined |
| 1359 | ? { effort: input.modelOptions.codex.reasoningEffort } | 1358 | ? { effort: input.modelOptions.codex.reasoningEffort } |
| 1360 | : {}), | 1359 | : {}), |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@t3tools/web", | 2 | "name": "@t3tools/web", |
| 3 | "version": "0.0.5", | 3 | "version": "0.0.9", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "scripts": { | 6 | "scripts": { |
| @@ -4,8 +4,6 @@ import { · 3 unmodified lines | |||
| 4 | getAppModelOptions, | 4 | getAppModelOptions, |
| 5 | getSlashModelOptions, | 5 | getSlashModelOptions, |
| 6 | normalizeCustomModelSlugs, | 6 | normalizeCustomModelSlugs, |
| 7 | resolveAppServiceTier, | ||
| 8 | shouldShowFastTierIcon, | ||
| 9 | resolveAppModelSelection, | 7 | resolveAppModelSelection, |
| 10 | } from "./appSettings"; | 8 | } from "./appSettings"; |
| 11 | 9 | ||
| @@ -84,22 +82,3 @@ describe("getSlashModelOptions", () => { · 72 unmodified lines | |||
| 84 | expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); | 82 | expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); |
| 85 | }); | 83 | }); |
| 86 | }); | 84 | }); |
| 87 | |||
| 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 | import { useCallback, useSyncExternalStore } from "react"; | 1 | import { useCallback, useSyncExternalStore } from "react"; |
| 2 | import { Option, Schema } from "effect"; | 2 | import { Option, Schema } from "effect"; |
| 3 | import { type ProviderKind, type ProviderServiceTier } from "@t3tools/contracts"; | 3 | import { type ProviderKind } from "@t3tools/contracts"; |
| 4 | import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; | 4 | import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; |
| 5 | 5 | ||
| 6 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; | 6 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; |
| 7 | const MAX_CUSTOM_MODEL_COUNT = 32; | 7 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| 8 | export const MAX_CUSTOM_MODEL_LENGTH = 256; | 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 | const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = { | 9 | const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = { |
| 30 | codex: new Set(getModelOptions("codex").map((option) => option.slug)), | 10 | codex: new Set(getModelOptions("codex").map((option) => option.slug)), |
| 31 | }; | 11 | }; |
| @@ -41,7 +21,6 @@ const AppSettingsSchema = Schema.Struct({ · 9 unmodified lines | |||
| 41 | enableAssistantStreaming: Schema.Boolean.pipe( | 21 | enableAssistantStreaming: Schema.Boolean.pipe( |
| 42 | Schema.withConstructorDefault(() => Option.some(false)), | 22 | Schema.withConstructorDefault(() => Option.some(false)), |
| 43 | ), | 23 | ), |
| 44 | codexServiceTier: AppServiceTierSchema.pipe(Schema.withConstructorDefault(() => Option.some("auto"))), | ||
| 45 | customCodexModels: Schema.Array(Schema.String).pipe( | 24 | customCodexModels: Schema.Array(Schema.String).pipe( |
| 46 | Schema.withConstructorDefault(() => Option.some([])), | 25 | Schema.withConstructorDefault(() => Option.some([])), |
| 47 | ), | 26 | ), |
| @@ -53,22 +32,6 @@ export interface AppModelOption { · 5 unmodified lines | |||
| 53 | isCustom: boolean; | 32 | isCustom: boolean; |
| 54 | } | 33 | } |
| 55 | 34 | ||
| 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 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); | 35 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); |
| 73 | 36 | ||
| 74 | let listeners: Array<() => void> = []; | 37 | let listeners: Array<() => void> = []; |
| @@ -92,6 +92,8 @@ import { · 91 unmodified lines | |||
| 92 | buildPlanImplementationThreadTitle, | 92 | buildPlanImplementationThreadTitle, |
| 93 | buildPlanImplementationPrompt, | 93 | buildPlanImplementationPrompt, |
| 94 | buildProposedPlanMarkdownFilename, | 94 | buildProposedPlanMarkdownFilename, |
| 95 | downloadPlanAsTextFile, | ||
| 96 | normalizePlanMarkdownForExport, | ||
| 95 | proposedPlanTitle, | 97 | proposedPlanTitle, |
| 96 | resolvePlanFollowUpSubmission, | 98 | resolvePlanFollowUpSubmission, |
| 97 | } from "../proposedPlan"; | 99 | } from "../proposedPlan"; |
| @@ -122,8 +124,9 @@ import { · 24 unmodified lines | |||
| 122 | shortcutLabelForCommand, | 124 | shortcutLabelForCommand, |
| 123 | } from "../keybindings"; | 125 | } from "../keybindings"; |
| 124 | import ChatMarkdown from "./ChatMarkdown"; | 126 | import ChatMarkdown from "./ChatMarkdown"; |
| 127 | import PlanSidebar from "./PlanSidebar"; | ||
| 125 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 128 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 126 | import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; | 129 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; |
| 127 | import { | 130 | import { |
| 128 | BotIcon, | 131 | BotIcon, |
| 129 | ChevronDownIcon, | 132 | ChevronDownIcon, |
| @@ -135,13 +138,13 @@ import { · 5 unmodified lines | |||
| 135 | DiffIcon, | 138 | DiffIcon, |
| 136 | EllipsisIcon, | 139 | EllipsisIcon, |
| 137 | FolderClosedIcon, | 140 | FolderClosedIcon, |
| 141 | ListTodoIcon, | ||
| 138 | LockIcon, | 142 | LockIcon, |
| 139 | LockOpenIcon, | 143 | LockOpenIcon, |
| 140 | Undo2Icon, | 144 | Undo2Icon, |
| 141 | XIcon, | 145 | XIcon, |
| 142 | CopyIcon, | 146 | CopyIcon, |
| 143 | CheckIcon, | 147 | CheckIcon, |
| 144 | ZapIcon, | ||
| 145 | } from "lucide-react"; | 148 | } from "lucide-react"; |
| 146 | import { Button } from "./ui/button"; | 149 | import { Button } from "./ui/button"; |
| 147 | import { Input } from "./ui/input"; | 150 | import { Input } from "./ui/input"; |
| @@ -201,9 +204,6 @@ import { readNativeApi } from "~/nativeApi"; · 53 unmodified lines | |||
| 201 | import { | 204 | import { |
| 202 | getAppModelOptions, | 205 | getAppModelOptions, |
| 203 | resolveAppModelSelection, | 206 | resolveAppModelSelection, |
| 204 | resolveAppServiceTier, | ||
| 205 | shouldShowFastTierIcon, | ||
| 206 | type AppServiceTier, | ||
| 207 | useAppSettings, | 207 | useAppSettings, |
| 208 | } from "../appSettings"; | 208 | } from "../appSettings"; |
| 209 | import { | 209 | import { |
| @@ -291,21 +291,7 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { · 81 unmodified lines | |||
| 291 | return "text-muted-foreground/40"; | 291 | return "text-muted-foreground/40"; |
| 292 | } | 292 | } |
| 293 | 293 | ||
| 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 | interface ExpandedImageItem { | 296 | interface ExpandedImageItem { |
| 311 | src: string; | 297 | src: string; |
| @@ -420,7 +406,6 @@ type ComposerCommandItem = · 108 unmodified lines | |||
| 420 | model: ModelSlug; | 406 | model: ModelSlug; |
| 421 | label: string; | 407 | label: string; |
| 422 | description: string; | 408 | description: string; |
| 423 | showFastBadge: boolean; | ||
| 424 | }; | 409 | }; |
| 425 | 410 | ||
| 426 | type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; | 411 | type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; |
| @@ -531,9 +516,6 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { · 104 unmodified lines | |||
| 531 | </Badge> | 516 | </Badge> |
| 532 | ) : null} | 517 | ) : null} |
| 533 | <span className="flex min-w-0 items-center gap-1.5 truncate"> | 518 | <span className="flex min-w-0 items-center gap-1.5 truncate"> |
| 534 | {props.item.type === "model" && props.item.showFastBadge ? ( | ||
| 535 | <ZapIcon className="size-3.5 shrink-0 text-amber-500" /> | ||
| 536 | ) : null} | ||
| 537 | <span className="truncate">{props.item.label}</span> | 519 | <span className="truncate">{props.item.label}</span> |
| 538 | </span> | 520 | </span> |
| 539 | <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span> | 521 | <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span> |
| @@ -656,6 +638,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 116 unmodified lines | |||
| 656 | const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = | 638 | const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = |
| 657 | useState<Record<string, number>>({}); | 639 | useState<Record<string, number>>({}); |
| 658 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); | 640 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 641 | const [planSidebarOpen, setPlanSidebarOpen] = useState(false); | ||
| 642 | // Tracks whether the user explicitly dismissed the sidebar for the active turn. | ||
| 643 | const planSidebarDismissedForTurnRef = useRef<string | null>(null); | ||
| 644 | // When set, the thread-change reset effect will open the sidebar instead of closing it. | ||
| 645 | // Used by "Implement in new thread" to carry the sidebar-open intent across navigation. | ||
| 646 | const planSidebarOpenOnNextThreadRef = useRef(false); | ||
| 659 | const [nowTick, setNowTick] = useState(() => Date.now()); | 647 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 660 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); | 648 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 661 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); | 649 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| @@ -757,11 +745,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 95 unmodified lines | |||
| 757 | composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; | 745 | composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; |
| 758 | const isServerThread = serverThread !== undefined; | 746 | const isServerThread = serverThread !== undefined; |
| 759 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; | 747 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 760 | const diffSearch = useMemo( | 748 | const diffOpen = rawSearch.diff === "1"; |
| 761 | () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), | ||
| 762 | [rawSearch], | ||
| 763 | ); | ||
| 764 | const diffOpen = diffSearch.diff === "1"; | ||
| 765 | const activeThreadId = activeThread?.id ?? null; | 749 | const activeThreadId = activeThread?.id ?? null; |
| 766 | const activeLatestTurn = activeThread?.latestTurn ?? null; | 750 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 767 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | 751 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| @@ -793,8 +777,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 25 unmodified lines | |||
| 793 | activeThread.messages.length > 0 || | 777 | activeThread.messages.length > 0 || |
| 794 | activeThread.session !== null), | 778 | activeThread.session !== null), |
| 795 | ); | 779 | ); |
| 796 | const selectedServiceTierSetting = settings.codexServiceTier; | ||
| 797 | const selectedServiceTier = resolveAppServiceTier(selectedServiceTierSetting); | ||
| 798 | const lockedProvider: ProviderKind | null = hasThreadStarted | 780 | const lockedProvider: ProviderKind | null = hasThreadStarted |
| 799 | ? (sessionProvider ?? selectedProviderByThreadId ?? null) | 781 | ? (sessionProvider ?? selectedProviderByThreadId ?? null) |
| 800 | : null; | 782 | : null; |
| @@ -1262,10 +1244,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 461 unmodified lines | |||
| 1262 | model: slug, | 1244 | model: slug, |
| 1263 | label: name, | 1245 | label: name, |
| 1264 | description: `${providerLabel} · ${slug}`, | 1246 | description: `${providerLabel} · ${slug}`, |
| 1265 | showFastBadge: | ||
| 1266 | provider === "codex" && shouldShowFastTierIcon(slug, selectedServiceTierSetting), | ||
| 1267 | })); | 1247 | })); |
| 1268 | }, [composerTrigger, searchableModelOptions, selectedServiceTierSetting, workspaceEntries]); | 1248 | }, [composerTrigger, searchableModelOptions, workspaceEntries]); |
| 1269 | const composerMenuOpen = Boolean(composerTrigger); | 1249 | const composerMenuOpen = Boolean(composerTrigger); |
| 1270 | const activeComposerMenuItem = useMemo( | 1250 | const activeComposerMenuItem = useMemo( |
| 1271 | () => | 1251 | () => |
| @@ -1621,6 +1601,36 @@ export default function ChatView({ threadId }: ChatViewProps) { · 349 unmodified lines | |||
| 1621 | }, | 1601 | }, |
| 1622 | [activeProject, persistProjectScripts], | 1602 | [activeProject, persistProjectScripts], |
| 1623 | ); | 1603 | ); |
| 1604 | const deleteProjectScript = useCallback( | ||
| 1605 | async (scriptId: string) => { | ||
| 1606 | if (!activeProject) return; | ||
| 1607 | const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); | ||
| 1608 | |||
| 1609 | const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; | ||
| 1610 | |||
| 1611 | try { | ||
| 1612 | await persistProjectScripts({ | ||
| 1613 | projectId: activeProject.id, | ||
| 1614 | projectCwd: activeProject.cwd, | ||
| 1615 | previousScripts: activeProject.scripts, | ||
| 1616 | nextScripts, | ||
| 1617 | keybinding: null, | ||
| 1618 | keybindingCommand: commandForProjectScript(scriptId), | ||
| 1619 | }); | ||
| 1620 | toastManager.add({ | ||
| 1621 | type: "success", | ||
| 1622 | title: `Deleted action "${deletedName ?? "Unknown"}"`, | ||
| 1623 | }); | ||
| 1624 | } catch (error) { | ||
| 1625 | toastManager.add({ | ||
| 1626 | type: "error", | ||
| 1627 | title: "Could not delete action", | ||
| 1628 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | ||
| 1629 | }); | ||
| 1630 | } | ||
| 1631 | }, | ||
| 1632 | [activeProject, persistProjectScripts], | ||
| 1633 | ); | ||
| 1624 | 1634 | ||
| 1625 | const handleRuntimeModeChange = useCallback( | 1635 | const handleRuntimeModeChange = useCallback( |
| 1626 | (mode: RuntimeMode) => { | 1636 | (mode: RuntimeMode) => { |
| @@ -1763,6 +1773,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 136 unmodified lines | |||
| 1763 | "button, summary, [role='button'], [data-scroll-anchor-target]", | 1773 | "button, summary, [role='button'], [data-scroll-anchor-target]", |
| 1764 | ); | 1774 | ); |
| 1765 | if (!trigger || !scrollContainer.contains(trigger)) return; | 1775 | if (!trigger || !scrollContainer.contains(trigger)) return; |
| 1776 | if (trigger.closest("[data-scroll-anchor-ignore]")) return; | ||
| 1766 | 1777 | ||
| 1767 | pendingInteractionAnchorRef.current = { | 1778 | pendingInteractionAnchorRef.current = { |
| 1768 | element: trigger, | 1779 | element: trigger, |
| @@ -1911,8 +1922,17 @@ export default function ChatView({ threadId }: ChatViewProps) { · 142 unmodified lines | |||
| 1911 | 1922 | ||
| 1912 | useEffect(() => { | 1923 | useEffect(() => { |
| 1913 | setExpandedWorkGroups({}); | 1924 | setExpandedWorkGroups({}); |
| 1925 | if (planSidebarOpenOnNextThreadRef.current) { | ||
| 1926 | planSidebarOpenOnNextThreadRef.current = false; | ||
| 1927 | setPlanSidebarOpen(true); | ||
| 1928 | } else { | ||
| 1929 | setPlanSidebarOpen(false); | ||
| 1930 | } | ||
| 1931 | planSidebarDismissedForTurnRef.current = null; | ||
| 1914 | }, [activeThread?.id]); | 1932 | }, [activeThread?.id]); |
| 1915 | 1933 | ||
| 1934 | |||
| 1935 | |||
| 1916 | useEffect(() => { | 1936 | useEffect(() => { |
| 1917 | if (!composerMenuOpen) { | 1937 | if (!composerMenuOpen) { |
| 1918 | setComposerHighlightedItemId(null); | 1938 | setComposerHighlightedItemId(null); |
| @@ -2633,7 +2653,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 714 unmodified lines | |||
| 2633 | attachments: turnAttachments, | 2653 | attachments: turnAttachments, |
| 2634 | }, | 2654 | }, |
| 2635 | model: selectedModel || undefined, | 2655 | model: selectedModel || undefined, |
| 2636 | serviceTier: selectedServiceTier, | ||
| 2637 | ...(selectedModelOptionsForDispatch | 2656 | ...(selectedModelOptionsForDispatch |
| 2638 | ? { modelOptions: selectedModelOptionsForDispatch } | 2657 | ? { modelOptions: selectedModelOptionsForDispatch } |
| 2639 | : {}), | 2658 | : {}), |
| @@ -2925,6 +2944,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 285 unmodified lines | |||
| 2925 | interactionMode: nextInteractionMode, | 2944 | interactionMode: nextInteractionMode, |
| 2926 | createdAt: messageCreatedAt, | 2945 | createdAt: messageCreatedAt, |
| 2927 | }); | 2946 | }); |
| 2947 | // Optimistically open the plan sidebar when implementing (not refining). | ||
| 2948 | // "default" mode here means the agent is executing the plan, which produces | ||
| 2949 | // step-tracking activities that the sidebar will display. | ||
| 2950 | if (nextInteractionMode === "default") { | ||
| 2951 | planSidebarDismissedForTurnRef.current = null; | ||
| 2952 | setPlanSidebarOpen(true); | ||
| 2953 | } | ||
| 2928 | sendInFlightRef.current = false; | 2954 | sendInFlightRef.current = false; |
| 2929 | } catch (err) { | 2955 | } catch (err) { |
| 2930 | setOptimisticUserMessages((existing) => | 2956 | setOptimisticUserMessages((existing) => |
| @@ -3033,6 +3059,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 102 unmodified lines | |||
| 3033 | .then(() => api.orchestration.getSnapshot()) | 3059 | .then(() => api.orchestration.getSnapshot()) |
| 3034 | .then((snapshot) => { | 3060 | .then((snapshot) => { |
| 3035 | syncServerReadModel(snapshot); | 3061 | syncServerReadModel(snapshot); |
| 3062 | // Signal that the plan sidebar should open on the new thread. | ||
| 3063 | planSidebarOpenOnNextThreadRef.current = true; | ||
| 3036 | return navigate({ | 3064 | return navigate({ |
| 3037 | to: "/$threadId", | 3065 | to: "/$threadId", |
| 3038 | params: { threadId: nextThreadId }, | 3066 | params: { threadId: nextThreadId }, |
| @@ -3428,14 +3456,21 @@ export default function ChatView({ threadId }: ChatViewProps) { · 389 unmodified lines | |||
| 3428 | }} | 3456 | }} |
| 3429 | onAddProjectScript={saveProjectScript} | 3457 | onAddProjectScript={saveProjectScript} |
| 3430 | onUpdateProjectScript={updateProjectScript} | 3458 | onUpdateProjectScript={updateProjectScript} |
| 3459 | onDeleteProjectScript={deleteProjectScript} | ||
| 3431 | onToggleDiff={onToggleDiff} | 3460 | onToggleDiff={onToggleDiff} |
| 3432 | /> | 3461 | /> |
| 3433 | </header> | 3462 | </header> |
| 3434 | 3463 | ||
| 3435 | {/* Error banner */} | 3464 | {/* Error banner */} |
| 3436 | <ProviderHealthBanner status={activeProviderStatus} /> | 3465 | <ProviderHealthBanner status={activeProviderStatus} /> |
| 3437 | <ThreadErrorBanner error={activeThread.error} /> | 3466 | <ThreadErrorBanner |
| 3438 | <PlanModePanel activePlan={activePlan} /> | 3467 | error={activeThread.error} |
| 3468 | onDismiss={() => setThreadError(activeThread.id, null)} | ||
| 3469 | /> | ||
| 3470 | {/* Main content area with optional plan sidebar */} | ||
| 3471 | <div className="flex min-h-0 flex-1"> | ||
| 3472 | {/* Chat column */} | ||
| 3473 | <div className="flex min-h-0 min-w-0 flex-1 flex-col"> | ||
| 3439 | 3474 | ||
| 3440 | {/* Messages */} | 3475 | {/* Messages */} |
| 3441 | <div | 3476 | <div |
| @@ -3509,6 +3544,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 67 unmodified lines | |||
| 3509 | answers={activePendingDraftAnswers} | 3544 | answers={activePendingDraftAnswers} |
| 3510 | questionIndex={activePendingQuestionIndex} | 3545 | questionIndex={activePendingQuestionIndex} |
| 3511 | onSelectOption={onSelectActivePendingUserInputOption} | 3546 | onSelectOption={onSelectActivePendingUserInputOption} |
| 3547 | onAdvance={onAdvanceActivePendingUserInput} | ||
| 3512 | /> | 3548 | /> |
| 3513 | </div> | 3549 | </div> |
| 3514 | ) : showPlanFollowUpPrompt && activeProposedPlan ? ( | 3550 | ) : showPlanFollowUpPrompt && activeProposedPlan ? ( |
| @@ -3651,7 +3687,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 136 unmodified lines | |||
| 3651 | model={selectedModelForPickerWithCustomFallback} | 3687 | model={selectedModelForPickerWithCustomFallback} |
| 3652 | lockedProvider={lockedProvider} | 3688 | lockedProvider={lockedProvider} |
| 3653 | modelOptionsByProvider={modelOptionsByProvider} | 3689 | modelOptionsByProvider={modelOptionsByProvider} |
| 3654 | serviceTierSetting={selectedServiceTierSetting} | ||
| 3655 | onProviderModelChange={onProviderModelSelect} | 3690 | onProviderModelChange={onProviderModelSelect} |
| 3656 | /> | 3691 | /> |
| 3657 | 3692 | ||
| @@ -3715,6 +3750,43 @@ export default function ChatView({ threadId }: ChatViewProps) { · 57 unmodified lines | |||
| 3715 | {runtimeMode === "full-access" ? "Full access" : "Supervised"} | 3750 | {runtimeMode === "full-access" ? "Full access" : "Supervised"} |
| 3716 | </span> | 3751 | </span> |
| 3717 | </Button> | 3752 | </Button> |
| 3753 | |||
| 3754 | {/* Plan sidebar toggle */} | ||
| 3755 | {(activePlan || activeProposedPlan || planSidebarOpen) ? ( | ||
| 3756 | <> | ||
| 3757 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | ||
| 3758 | <Button | ||
| 3759 | variant="ghost" | ||
| 3760 | className={cn( | ||
| 3761 | "shrink-0 whitespace-nowrap px-2 sm:px-3", | ||
| 3762 | planSidebarOpen | ||
| 3763 | ? "text-blue-400 hover:text-blue-300" | ||
| 3764 | : "text-muted-foreground/70 hover:text-foreground/80", | ||
| 3765 | )} | ||
| 3766 | size="sm" | ||
| 3767 | type="button" | ||
| 3768 | onClick={() => { | ||
| 3769 | setPlanSidebarOpen((open) => { | ||
| 3770 | if (open) { | ||
| 3771 | // Closing: track dismissal for current turn | ||
| 3772 | const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; | ||
| 3773 | if (turnKey) { | ||
| 3774 | planSidebarDismissedForTurnRef.current = turnKey; | ||
| 3775 | } | ||
| 3776 | } else { | ||
| 3777 | // Re-opening: clear dismissal tracking | ||
| 3778 | planSidebarDismissedForTurnRef.current = null; | ||
| 3779 | } | ||
| 3780 | return !open; | ||
| 3781 | }); | ||
| 3782 | }} | ||
| 3783 | title={planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} | ||
| 3784 | > | ||
| 3785 | <ListTodoIcon /> | ||
| 3786 | <span className="sr-only sm:not-sr-only">Plan</span> | ||
| 3787 | </Button> | ||
| 3788 | </> | ||
| 3789 | ) : null} | ||
| 3718 | </div> | 3790 | </div> |
| 3719 | 3791 | ||
| 3720 | {/* Right side: send / stop button */} | 3792 | {/* Right side: send / stop button */} |
| @@ -3881,6 +3953,27 @@ export default function ChatView({ threadId }: ChatViewProps) { · 160 unmodified lines | |||
| 3881 | </form> | 3953 | </form> |
| 3882 | </div> | 3954 | </div> |
| 3883 | 3955 | ||
| 3956 | </div>{/* end chat column */} | ||
| 3957 | |||
| 3958 | {/* Plan sidebar */} | ||
| 3959 | {planSidebarOpen ? ( | ||
| 3960 | <PlanSidebar | ||
| 3961 | activePlan={activePlan} | ||
| 3962 | activeProposedPlan={activeProposedPlan} | ||
| 3963 | markdownCwd={gitCwd ?? undefined} | ||
| 3964 | workspaceRoot={activeProject?.cwd ?? undefined} | ||
| 3965 | onClose={() => { | ||
| 3966 | setPlanSidebarOpen(false); | ||
| 3967 | // Track that the user explicitly dismissed for this turn so auto-open won't fight them. | ||
| 3968 | const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; | ||
| 3969 | if (turnKey) { | ||
| 3970 | planSidebarDismissedForTurnRef.current = turnKey; | ||
| 3971 | } | ||
| 3972 | }} | ||
| 3973 | /> | ||
| 3974 | ) : null} | ||
| 3975 | </div>{/* end horizontal flex container */} | ||
| 3976 | |||
| 3884 | {isGitRepo && ( | 3977 | {isGitRepo && ( |
| 3885 | <BranchToolbar | 3978 | <BranchToolbar |
| 3886 | threadId={activeThread.id} | 3979 | threadId={activeThread.id} |
| @@ -4005,6 +4098,7 @@ interface ChatHeaderProps { · 118 unmodified lines | |||
| 4005 | onRunProjectScript: (script: ProjectScript) => void; | 4098 | onRunProjectScript: (script: ProjectScript) => void; |
| 4006 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; | 4099 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; |
| 4007 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; | 4100 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; |
| 4101 | onDeleteProjectScript: (scriptId: string) => Promise<void>; | ||
| 4008 | onToggleDiff: () => void; | 4102 | onToggleDiff: () => void; |
| 4009 | } | 4103 | } |
| 4010 | 4104 | ||
| @@ -4024,6 +4118,7 @@ const ChatHeader = memo(function ChatHeader({ · 13 unmodified lines | |||
| 4024 | onRunProjectScript, | 4118 | onRunProjectScript, |
| 4025 | onAddProjectScript, | 4119 | onAddProjectScript, |
| 4026 | onUpdateProjectScript, | 4120 | onUpdateProjectScript, |
| 4121 | onDeleteProjectScript, | ||
| 4027 | onToggleDiff, | 4122 | onToggleDiff, |
| 4028 | }: ChatHeaderProps) { | 4123 | }: ChatHeaderProps) { |
| 4029 | return ( | 4124 | return ( |
| @@ -4056,6 +4151,7 @@ const ChatHeader = memo(function ChatHeader({ · 26 unmodified lines | |||
| 4056 | onRunScript={onRunProjectScript} | 4151 | onRunScript={onRunProjectScript} |
| 4057 | onAddScript={onAddProjectScript} | 4152 | onAddScript={onAddProjectScript} |
| 4058 | onUpdateScript={onUpdateProjectScript} | 4153 | onUpdateScript={onUpdateProjectScript} |
| 4154 | onDeleteScript={onDeleteProjectScript} | ||
| 4059 | /> | 4155 | /> |
| 4060 | )} | 4156 | )} |
| 4061 | {activeProjectName && ( | 4157 | {activeProjectName && ( |
| @@ -4095,7 +4191,13 @@ const ChatHeader = memo(function ChatHeader({ · 33 unmodified lines | |||
| 4095 | ); | 4191 | ); |
| 4096 | }); | 4192 | }); |
| 4097 | 4193 | ||
| 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 | if (!error) return null; | 4201 | if (!error) return null; |
| 4100 | return ( | 4202 | return ( |
| 4101 | <div className="pt-3 mx-auto max-w-3xl"> | 4203 | <div className="pt-3 mx-auto max-w-3xl"> |
| @@ -4104,6 +4206,18 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st · 2 unmodified lines | |||
| 4104 | <AlertDescription className="line-clamp-3" title={error}> | 4206 | <AlertDescription className="line-clamp-3" title={error}> |
| 4105 | {error} | 4207 | {error} |
| 4106 | </AlertDescription> | 4208 | </AlertDescription> |
| 4209 | {onDismiss && ( | ||
| 4210 | <AlertAction> | ||
| 4211 | <button | ||
| 4212 | type="button" | ||
| 4213 | aria-label="Dismiss error" | ||
| 4214 | className="inline-flex size-6 items-center justify-center rounded-md text-destructive/60 transition-colors hover:text-destructive" | ||
| 4215 | onClick={onDismiss} | ||
| 4216 | > | ||
| 4217 | <XIcon className="size-3.5" /> | ||
| 4218 | </button> | ||
| 4219 | </AlertAction> | ||
| 4220 | )} | ||
| 4107 | </Alert> | 4221 | </Alert> |
| 4108 | </div> | 4222 | </div> |
| 4109 | ); | 4223 | ); |
| @@ -4219,61 +4333,13 @@ const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActi · 109 unmodified lines | |||
| 4219 | ); | 4333 | ); |
| 4220 | }); | 4334 | }); |
| 4221 | 4335 | ||
| 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 | interface PendingUserInputPanelProps { | 4336 | interface PendingUserInputPanelProps { |
| 4272 | pendingUserInputs: PendingUserInput[]; | 4337 | pendingUserInputs: PendingUserInput[]; |
| 4273 | respondingRequestIds: ApprovalRequestId[]; | 4338 | respondingRequestIds: ApprovalRequestId[]; |
| 4274 | answers: Record<string, PendingUserInputDraftAnswer>; | 4339 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 4275 | questionIndex: number; | 4340 | questionIndex: number; |
| 4276 | onSelectOption: (questionId: string, optionLabel: string) => void; | 4341 | onSelectOption: (questionId: string, optionLabel: string) => void; |
| 4342 | onAdvance: () => void; | ||
| 4277 | } | 4343 | } |
| 4278 | 4344 | ||
| 4279 | const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ | 4345 | const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ |
| @@ -4282,6 +4348,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane · 2 unmodified lines | |||
| 4282 | answers, | 4348 | answers, |
| 4283 | questionIndex, | 4349 | questionIndex, |
| 4284 | onSelectOption, | 4350 | onSelectOption, |
| 4351 | onAdvance, | ||
| 4285 | }: PendingUserInputPanelProps) { | 4352 | }: PendingUserInputPanelProps) { |
| 4286 | if (pendingUserInputs.length === 0) return null; | 4353 | if (pendingUserInputs.length === 0) return null; |
| 4287 | const activePrompt = pendingUserInputs[0]; | 4354 | const activePrompt = pendingUserInputs[0]; |
| @@ -4295,6 +4362,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane · 7 unmodified lines | |||
| 4295 | answers={answers} | 4362 | answers={answers} |
| 4296 | questionIndex={questionIndex} | 4363 | questionIndex={questionIndex} |
| 4297 | onSelectOption={onSelectOption} | 4364 | onSelectOption={onSelectOption} |
| 4365 | onAdvance={onAdvance} | ||
| 4298 | /> | 4366 | /> |
| 4299 | ); | 4367 | ); |
| 4300 | }); | 4368 | }); |
| @@ -4305,42 +4373,135 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 4 unmodified lines | |||
| 4305 | answers, | 4373 | answers, |
| 4306 | questionIndex, | 4374 | questionIndex, |
| 4307 | onSelectOption, | 4375 | onSelectOption, |
| 4376 | onAdvance, | ||
| 4308 | }: { | 4377 | }: { |
| 4309 | prompt: PendingUserInput; | 4378 | prompt: PendingUserInput; |
| 4310 | isResponding: boolean; | 4379 | isResponding: boolean; |
| 4311 | answers: Record<string, PendingUserInputDraftAnswer>; | 4380 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 4312 | questionIndex: number; | 4381 | questionIndex: number; |
| 4313 | onSelectOption: (questionId: string, optionLabel: string) => void; | 4382 | onSelectOption: (questionId: string, optionLabel: string) => void; |
| 4383 | onAdvance: () => void; | ||
| 4314 | }) { | 4384 | }) { |
| 4315 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); | 4385 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); |
| 4316 | const activeQuestion = progress.activeQuestion; | 4386 | const activeQuestion = progress.activeQuestion; |
| 4387 | const autoAdvanceTimerRef = useRef<number | null>(null); | ||
| 4388 | |||
| 4389 | // Clear auto-advance timer on unmount | ||
| 4390 | useEffect(() => { | ||
| 4391 | return () => { | ||
| 4392 | if (autoAdvanceTimerRef.current !== null) { | ||
| 4393 | window.clearTimeout(autoAdvanceTimerRef.current); | ||
| 4394 | } | ||
| 4395 | }; | ||
| 4396 | }, []); | ||
| 4397 | |||
| 4398 | const selectOptionAndAutoAdvance = useCallback( | ||
| 4399 | (questionId: string, optionLabel: string) => { | ||
| 4400 | onSelectOption(questionId, optionLabel); | ||
| 4401 | if (autoAdvanceTimerRef.current !== null) { | ||
| 4402 | window.clearTimeout(autoAdvanceTimerRef.current); | ||
| 4403 | } | ||
| 4404 | autoAdvanceTimerRef.current = window.setTimeout(() => { | ||
| 4405 | autoAdvanceTimerRef.current = null; | ||
| 4406 | onAdvance(); | ||
| 4407 | }, 200); | ||
| 4408 | }, | ||
| 4409 | [onSelectOption, onAdvance], | ||
| 4410 | ); | ||
| 4411 | |||
| 4412 | // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance. | ||
| 4413 | // Works even when the Lexical composer (contenteditable) has focus — the composer | ||
| 4414 | // doubles as a custom-answer field during user input, and when it's empty the digit | ||
| 4415 | // keys should pick options instead of typing into the editor. | ||
| 4416 | useEffect(() => { | ||
| 4417 | if (!activeQuestion || isResponding) return; | ||
| 4418 | const handler = (event: globalThis.KeyboardEvent) => { | ||
| 4419 | if (event.metaKey || event.ctrlKey || event.altKey) return; | ||
| 4420 | const target = event.target; | ||
| 4421 | if ( | ||
| 4422 | target instanceof HTMLInputElement || | ||
| 4423 | target instanceof HTMLTextAreaElement | ||
| 4424 | ) { | ||
| 4425 | return; | ||
| 4426 | } | ||
| 4427 | // If the user has started typing a custom answer in the contenteditable | ||
| 4428 | // composer, let digit keys pass through so they can type numbers. | ||
| 4429 | if (target instanceof HTMLElement && target.isContentEditable) { | ||
| 4430 | const hasCustomText = progress.customAnswer.length > 0; | ||
| 4431 | if (hasCustomText) return; | ||
| 4432 | } | ||
| 4433 | const digit = Number.parseInt(event.key, 10); | ||
| 4434 | if (Number.isNaN(digit) || digit < 1 || digit > 9) return; | ||
| 4435 | const optionIndex = digit - 1; | ||
| 4436 | if (optionIndex >= activeQuestion.options.length) return; | ||
| 4437 | const option = activeQuestion.options[optionIndex]; | ||
| 4438 | if (!option) return; | ||
| 4439 | event.preventDefault(); | ||
| 4440 | selectOptionAndAutoAdvance(activeQuestion.id, option.label); | ||
| 4441 | }; | ||
| 4442 | document.addEventListener("keydown", handler); | ||
| 4443 | return () => document.removeEventListener("keydown", handler); | ||
| 4444 | }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]); | ||
| 4317 | 4445 | ||
| 4318 | if (!activeQuestion) { | 4446 | if (!activeQuestion) { |
| 4319 | return null; | 4447 | return null; |
| 4320 | } | 4448 | } |
| 4321 | 4449 | ||
| 4322 | return ( | 4450 | return ( |
| 4323 | <div className="px-4 py-4 sm:px-5"> | 4451 | <div className="px-4 py-3 sm:px-5"> |
| 4324 | <div className="flex gap-2"> | 4452 | <div className="flex items-center gap-3"> |
| 4325 | <span className="uppercase text-sm tracking-[0.2em]"> | 4453 | <div className="flex items-center gap-2"> |
| 4326 | {questionIndex + 1}/{prompt.questions.length} {activeQuestion.header} | 4454 | {prompt.questions.length > 1 ? ( |
| 4327 | </span> | 4455 | <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-[10px] font-medium tabular-nums text-muted-foreground/60"> |
| 4328 | <div className="text-sm font-medium">{activeQuestion.question}</div> | 4456 | {questionIndex + 1}/{prompt.questions.length} |
| 4457 | </span> | ||
| 4458 | ) : null} | ||
| 4459 | <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/50 uppercase"> | ||
| 4460 | {activeQuestion.header} | ||
| 4461 | </span> | ||
| 4462 | </div> | ||
| 4329 | </div> | 4463 | </div> |
| 4330 | <div className="mt-3 flex flex-wrap gap-2"> | 4464 | <p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p> |
| 4331 | {activeQuestion.options.map((option) => { | 4465 | <div className="mt-3 space-y-1"> |
| 4466 | {activeQuestion.options.map((option, index) => { | ||
| 4332 | const isSelected = progress.selectedOptionLabel === option.label; | 4467 | const isSelected = progress.selectedOptionLabel === option.label; |
| 4468 | const shortcutKey = index < 9 ? index + 1 : null; | ||
| 4333 | return ( | 4469 | return ( |
| 4334 | <Button | 4470 | <button |
| 4335 | key={`${activeQuestion.id}:${option.label}`} | 4471 | key={`${activeQuestion.id}:${option.label}`} |
| 4336 | size="sm" | 4472 | type="button" |
| 4337 | variant={isSelected ? "default" : "outline"} | ||
| 4338 | disabled={isResponding} | 4473 | disabled={isResponding} |
| 4339 | onClick={() => onSelectOption(activeQuestion.id, option.label)} | 4474 | onClick={() => selectOptionAndAutoAdvance(activeQuestion.id, option.label)} |
| 4340 | title={option.description} | 4475 | className={cn( |
| 4476 | "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150", | ||
| 4477 | isSelected | ||
| 4478 | ? "border-blue-500/40 bg-blue-500/8 text-foreground" | ||
| 4479 | : "border-transparent bg-muted/20 text-foreground/80 hover:bg-muted/40 hover:border-border/40", | ||
| 4480 | isResponding && "opacity-50 cursor-not-allowed", | ||
| 4481 | )} | ||
| 4341 | > | 4482 | > |
| 4342 | {option.label} | 4483 | {shortcutKey !== null ? ( |
| 4343 | </Button> | 4484 | <kbd |
| 4485 | className={cn( | ||
| 4486 | "flex size-5 shrink-0 items-center justify-center rounded text-[11px] font-medium tabular-nums transition-colors duration-150", | ||
| 4487 | isSelected | ||
| 4488 | ? "bg-blue-500/20 text-blue-400" | ||
| 4489 | : "bg-muted/40 text-muted-foreground/50 group-hover:bg-muted/60 group-hover:text-muted-foreground/70", | ||
| 4490 | )} | ||
| 4491 | > | ||
| 4492 | {shortcutKey} | ||
| 4493 | </kbd> | ||
| 4494 | ) : null} | ||
| 4495 | <div className="min-w-0 flex-1"> | ||
| 4496 | <span className="text-sm font-medium">{option.label}</span> | ||
| 4497 | {option.description && option.description !== option.label ? ( | ||
| 4498 | <span className="ml-2 text-xs text-muted-foreground/50">{option.description}</span> | ||
| 4499 | ) : null} | ||
| 4500 | </div> | ||
| 4501 | {isSelected ? ( | ||
| 4502 | <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> | ||
| 4503 | ) : null} | ||
| 4504 | </button> | ||
| 4344 | ); | 4505 | ); |
| 4345 | })} | 4506 | })} |
| 4346 | </div> | 4507 | </div> |
| @@ -4554,7 +4715,7 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ · 207 unmodified lines | |||
| 4554 | const saveContents = normalizePlanMarkdownForExport(planMarkdown); | 4715 | const saveContents = normalizePlanMarkdownForExport(planMarkdown); |
| 4555 | 4716 | ||
| 4556 | const handleDownload = () => { | 4717 | const handleDownload = () => { |
| 4557 | downloadTextFile(downloadFilename, saveContents); | 4718 | downloadPlanAsTextFile(downloadFilename, saveContents); |
| 4558 | }; | 4719 | }; |
| 4559 | 4720 | ||
| 4560 | const openSaveDialog = () => { | 4721 | const openSaveDialog = () => { |
| @@ -4646,7 +4807,12 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ · 85 unmodified lines | |||
| 4646 | </div> | 4807 | </div> |
| 4647 | {canCollapse ? ( | 4808 | {canCollapse ? ( |
| 4648 | <div className="mt-4 flex justify-center"> | 4809 | <div className="mt-4 flex justify-center"> |
| 4649 | <Button size="sm" variant="outline" onClick={() => setExpanded((value) => !value)}> | 4810 | <Button |
| 4811 | size="sm" | ||
| 4812 | variant="outline" | ||
| 4813 | data-scroll-anchor-ignore | ||
| 4814 | onClick={() => setExpanded((value) => !value)} | ||
| 4815 | > | ||
| 4650 | {expanded ? "Collapse plan" : "Expand plan"} | 4816 | {expanded ? "Collapse plan" : "Expand plan"} |
| 4651 | </Button> | 4817 | </Button> |
| 4652 | </div> | 4818 | </div> |
| @@ -5368,7 +5534,6 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 715 unmodified lines | |||
| 5368 | model: ModelSlug; | 5534 | model: ModelSlug; |
| 5369 | lockedProvider: ProviderKind | null; | 5535 | lockedProvider: ProviderKind | null; |
| 5370 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>; | 5536 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>; |
| 5371 | serviceTierSetting: AppServiceTier; | ||
| 5372 | disabled?: boolean; | 5537 | disabled?: boolean; |
| 5373 | onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; | 5538 | onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; |
| 5374 | }) { | 5539 | }) { |
| @@ -5401,9 +5566,6 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 26 unmodified lines | |||
| 5401 | > | 5566 | > |
| 5402 | <span className="flex min-w-0 items-center gap-2"> | 5567 | <span className="flex min-w-0 items-center gap-2"> |
| 5403 | <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" /> | 5568 | <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" /> |
| 5404 | {props.provider === "codex" && shouldShowFastTierIcon(props.model, props.serviceTierSetting) ? ( | ||
| 5405 | <ZapIcon className="size-3.5 shrink-0 text-amber-500" /> | ||
| 5406 | ) : null} | ||
| 5407 | <span className="truncate">{selectedModelLabel}</span> | 5569 | <span className="truncate">{selectedModelLabel}</span> |
| 5408 | <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" /> | 5570 | <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" /> |
| 5409 | </span> | 5571 | </span> |
| @@ -5446,10 +5608,6 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 36 unmodified lines | |||
| 5446 | value={modelOption.slug} | 5608 | value={modelOption.slug} |
| 5447 | onClick={() => setIsMenuOpen(false)} | 5609 | onClick={() => setIsMenuOpen(false)} |
| 5448 | > | 5610 | > |
| 5449 | {option.value === "codex" && | ||
| 5450 | shouldShowFastTierIcon(modelOption.slug, props.serviceTierSetting) ? ( | ||
| 5451 | <ZapIcon className="size-3.5 shrink-0 text-amber-500" /> | ||
| 5452 | ) : null} | ||
| 5453 | {modelOption.name} | 5611 | {modelOption.name} |
| 5454 | </MenuRadioItem> | 5612 | </MenuRadioItem> |
| 5455 | ))} | 5613 | ))} |
| @@ -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 | SettingsIcon, | 14 | SettingsIcon, |
| 15 | WrenchIcon, | 15 | WrenchIcon, |
| 16 | } from "lucide-react"; | 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 | import { | 19 | import { |
| 20 | keybindingValueForCommand, | 20 | keybindingValueForCommand, |
| @@ -27,6 +27,15 @@ import { · 6 unmodified lines | |||
| 27 | } from "~/projectScripts"; | 27 | } from "~/projectScripts"; |
| 28 | import { shortcutLabelForCommand } from "~/keybindings"; | 28 | import { shortcutLabelForCommand } from "~/keybindings"; |
| 29 | import { isMacPlatform } from "~/lib/utils"; | 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 | import { Button } from "./ui/button"; | 39 | import { Button } from "./ui/button"; |
| 31 | import { | 40 | import { |
| 32 | Dialog, | 41 | Dialog, |
| @@ -84,6 +93,7 @@ interface ProjectScriptsControlProps { · 51 unmodified lines | |||
| 84 | onRunScript: (script: ProjectScript) => void; | 93 | onRunScript: (script: ProjectScript) => void; |
| 85 | onAddScript: (input: NewProjectScriptInput) => Promise<void> | void; | 94 | onAddScript: (input: NewProjectScriptInput) => Promise<void> | void; |
| 86 | onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void; | 95 | onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void; |
| 96 | onDeleteScript: (scriptId: string) => Promise<void> | void; | ||
| 87 | } | 97 | } |
| 88 | 98 | ||
| 89 | function normalizeShortcutKeyToken(key: string): string | null { | 99 | function normalizeShortcutKeyToken(key: string): string | null { |
| @@ -144,6 +154,7 @@ export default function ProjectScriptsControl({ · 54 unmodified lines | |||
| 144 | onRunScript, | 154 | onRunScript, |
| 145 | onAddScript, | 155 | onAddScript, |
| 146 | onUpdateScript, | 156 | onUpdateScript, |
| 157 | onDeleteScript, | ||
| 147 | }: ProjectScriptsControlProps) { | 158 | }: ProjectScriptsControlProps) { |
| 148 | const addScriptFormId = React.useId(); | 159 | const addScriptFormId = React.useId(); |
| 149 | const [editingScriptId, setEditingScriptId] = useState<string | null>(null); | 160 | const [editingScriptId, setEditingScriptId] = useState<string | null>(null); |
| @@ -155,6 +166,7 @@ export default function ProjectScriptsControl({ · 5 unmodified lines | |||
| 155 | const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); | 166 | const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); |
| 156 | const [keybinding, setKeybinding] = useState(""); | 167 | const [keybinding, setKeybinding] = useState(""); |
| 157 | const [validationError, setValidationError] = useState<string | null>(null); | 168 | const [validationError, setValidationError] = useState<string | null>(null); |
| 169 | const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); | ||
| 158 | 170 | ||
| 159 | const primaryScript = useMemo(() => { | 171 | const primaryScript = useMemo(() => { |
| 160 | if (preferredScriptId) { | 172 | if (preferredScriptId) { |
| @@ -247,6 +259,13 @@ export default function ProjectScriptsControl({ · 86 unmodified lines | |||
| 247 | setDialogOpen(true); | 259 | setDialogOpen(true); |
| 248 | }; | 260 | }; |
| 249 | 261 | ||
| 262 | const confirmDeleteScript = useCallback(() => { | ||
| 263 | if (!editingScriptId) return; | ||
| 264 | setDeleteConfirmOpen(false); | ||
| 265 | setDialogOpen(false); | ||
| 266 | void onDeleteScript(editingScriptId); | ||
| 267 | }, [editingScriptId, onDeleteScript]); | ||
| 268 | |||
| 250 | return ( | 269 | return ( |
| 251 | <> | 270 | <> |
| 252 | {primaryScript ? ( | 271 | {primaryScript ? ( |
| @@ -440,6 +459,16 @@ export default function ProjectScriptsControl({ · 187 unmodified lines | |||
| 440 | </form> | 459 | </form> |
| 441 | </DialogPanel> | 460 | </DialogPanel> |
| 442 | <DialogFooter> | 461 | <DialogFooter> |
| 462 | {isEditing && ( | ||
| 463 | <Button | ||
| 464 | type="button" | ||
| 465 | variant="destructive-outline" | ||
| 466 | className="mr-auto" | ||
| 467 | onClick={() => setDeleteConfirmOpen(true)} | ||
| 468 | > | ||
| 469 | Delete | ||
| 470 | </Button> | ||
| 471 | )} | ||
| 443 | <Button | 472 | <Button |
| 444 | type="button" | 473 | type="button" |
| 445 | variant="outline" | 474 | variant="outline" |
| @@ -455,6 +484,21 @@ export default function ProjectScriptsControl({ · 9 unmodified lines | |||
| 455 | </DialogFooter> | 484 | </DialogFooter> |
| 456 | </DialogPopup> | 485 | </DialogPopup> |
| 457 | </Dialog> | 486 | </Dialog> |
| 487 | |||
| 488 | <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> | ||
| 489 | <AlertDialogPopup> | ||
| 490 | <AlertDialogHeader> | ||
| 491 | <AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle> | ||
| 492 | <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription> | ||
| 493 | </AlertDialogHeader> | ||
| 494 | <AlertDialogFooter> | ||
| 495 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | ||
| 496 | <Button variant="destructive" onClick={confirmDeleteScript}> | ||
| 497 | Delete action | ||
| 498 | </Button> | ||
| 499 | </AlertDialogFooter> | ||
| 500 | </AlertDialogPopup> | ||
| 501 | </AlertDialog> | ||
| 458 | </> | 502 | </> |
| 459 | ); | 503 | ); |
| 460 | } | 504 | } |
| @@ -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 | import { | 1 | import { |
| 2 | ArrowLeftIcon, | ||
| 2 | ChevronRightIcon, | 3 | ChevronRightIcon, |
| 3 | FolderIcon, | 4 | FolderIcon, |
| 4 | GitPullRequestIcon, | 5 | GitPullRequestIcon, |
| 6 | PlusIcon, | ||
| 5 | RocketIcon, | 7 | RocketIcon, |
| 8 | SettingsIcon, | ||
| 6 | SquarePenIcon, | 9 | SquarePenIcon, |
| 7 | TerminalIcon, | 10 | TerminalIcon, |
| 11 | TriangleAlertIcon, | ||
| 8 | } from "lucide-react"; | 12 | } from "lucide-react"; |
| 9 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 13 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 10 | import { | 14 | import { |
| @@ -17,15 +21,14 @@ import { · 6 unmodified lines | |||
| 17 | type ResolvedKeybindingsConfig, | 21 | type ResolvedKeybindingsConfig, |
| 18 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| 19 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | 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 | import { useAppSettings } from "../appSettings"; | 25 | import { useAppSettings } from "../appSettings"; |
| 22 | import { isElectron } from "../env"; | 26 | import { isElectron } from "../env"; |
| 23 | import { APP_STAGE_LABEL } from "../branding"; | 27 | import { APP_STAGE_LABEL } from "../branding"; |
| 24 | import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; | 28 | import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; |
| 25 | import { useStore } from "../store"; | 29 | import { useStore } from "../store"; |
| 26 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; | 30 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; |
| 27 | import { type Thread } from "../types"; | 31 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; |
| 28 | import { derivePendingApprovals } from "../session-logic"; | ||
| 29 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; | 32 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 30 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 33 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 31 | import { readNativeApi } from "../nativeApi"; | 34 | import { readNativeApi } from "../nativeApi"; |
| @@ -33,14 +36,18 @@ import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraft · 1 unmodified line | |||
| 33 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 36 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 34 | import { toastManager } from "./ui/toast"; | 37 | import { toastManager } from "./ui/toast"; |
| 35 | import { | 38 | import { |
| 39 | getArm64IntelBuildWarningDescription, | ||
| 36 | getDesktopUpdateActionError, | 40 | getDesktopUpdateActionError, |
| 37 | getDesktopUpdateButtonTooltip, | 41 | getDesktopUpdateButtonTooltip, |
| 38 | isDesktopUpdateButtonDisabled, | 42 | isDesktopUpdateButtonDisabled, |
| 39 | resolveDesktopUpdateButtonAction, | 43 | resolveDesktopUpdateButtonAction, |
| 44 | shouldShowArm64IntelBuildWarning, | ||
| 40 | shouldHighlightDesktopUpdateError, | 45 | shouldHighlightDesktopUpdateError, |
| 41 | shouldShowDesktopUpdateButton, | 46 | shouldShowDesktopUpdateButton, |
| 42 | shouldToastDesktopUpdateActionResult, | 47 | shouldToastDesktopUpdateActionResult, |
| 43 | } from "./desktopUpdate.logic"; | 48 | } from "./desktopUpdate.logic"; |
| 49 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; | ||
| 50 | import { Button } from "./ui/button"; | ||
| 44 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | 51 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 45 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 52 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 46 | import { | 53 | import { |
| @@ -60,6 +67,7 @@ import { · 13 unmodified lines | |||
| 60 | } from "./ui/sidebar"; | 67 | } from "./ui/sidebar"; |
| 61 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 68 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 62 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | 69 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 70 | import { resolveThreadStatusPill } from "./Sidebar.logic"; | ||
| 63 | 71 | ||
| 64 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 72 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 65 | const THREAD_PREVIEW_LIMIT = 6; | 73 | const THREAD_PREVIEW_LIMIT = 6; |
| @@ -81,13 +89,6 @@ function formatRelativeTime(iso: string): string { · 15 unmodified lines | |||
| 81 | return `${Math.floor(hours / 24)}d ago`; | 89 | return `${Math.floor(hours / 24)}d ago`; |
| 82 | } | 90 | } |
| 83 | 91 | ||
| 84 | interface ThreadStatusPill { | ||
| 85 | label: "Working" | "Connecting" | "Completed" | "Pending Approval"; | ||
| 86 | colorClass: string; | ||
| 87 | dotClass: string; | ||
| 88 | pulse: boolean; | ||
| 89 | } | ||
| 90 | |||
| 91 | interface TerminalStatusIndicator { | 92 | interface TerminalStatusIndicator { |
| 92 | label: "Terminal process running"; | 93 | label: "Terminal process running"; |
| 93 | colorClass: string; | 94 | colorClass: string; |
| @@ -103,57 +104,6 @@ interface PrStatusIndicator { · 9 unmodified lines | |||
| 103 | 104 | ||
| 104 | type ThreadPr = GitStatusResult["pr"]; | 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 | function terminalStatusFromRunningIds( | 107 | function terminalStatusFromRunningIds( |
| 158 | runningTerminalIds: string[], | 108 | runningTerminalIds: string[], |
| 159 | ): TerminalStatusIndicator | null { | 109 | ): TerminalStatusIndicator | null { |
| @@ -278,6 +228,7 @@ export default function Sidebar() { · 118 unmodified lines | |||
| 278 | (store) => store.clearProjectDraftThreadById, | 228 | (store) => store.clearProjectDraftThreadById, |
| 279 | ); | 229 | ); |
| 280 | const navigate = useNavigate(); | 230 | const navigate = useNavigate(); |
| 231 | const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); | ||
| 281 | const { settings: appSettings } = useAppSettings(); | 232 | const { settings: appSettings } = useAppSettings(); |
| 282 | const routeThreadId = useParams({ | 233 | const routeThreadId = useParams({ |
| 283 | strict: false, | 234 | strict: false, |
| @@ -293,6 +244,8 @@ export default function Sidebar() { · 9 unmodified lines | |||
| 293 | const [newCwd, setNewCwd] = useState(""); | 244 | const [newCwd, setNewCwd] = useState(""); |
| 294 | const [isPickingFolder, setIsPickingFolder] = useState(false); | 245 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| 295 | const [isAddingProject, setIsAddingProject] = useState(false); | 246 | const [isAddingProject, setIsAddingProject] = useState(false); |
| 247 | const [addProjectError, setAddProjectError] = useState<string | null>(null); | ||
| 248 | const addProjectInputRef = useRef<HTMLInputElement | null>(null); | ||
| 296 | const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); | 249 | const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); |
| 297 | const [renamingTitle, setRenamingTitle] = useState(""); | 250 | const [renamingTitle, setRenamingTitle] = useState(""); |
| 298 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< | 251 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< |
| @@ -301,6 +254,8 @@ export default function Sidebar() { · 2 unmodified lines | |||
| 301 | const renamingCommittedRef = useRef(false); | 254 | const renamingCommittedRef = useRef(false); |
| 302 | const renamingInputRef = useRef<HTMLInputElement | null>(null); | 255 | const renamingInputRef = useRef<HTMLInputElement | null>(null); |
| 303 | const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null); | 256 | const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null); |
| 257 | const shouldBrowseForProjectImmediately = isElectron; | ||
| 258 | const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; | ||
| 304 | const pendingApprovalByThreadId = useMemo(() => { | 259 | const pendingApprovalByThreadId = useMemo(() => { |
| 305 | const map = new Map<ThreadId, boolean>(); | 260 | const map = new Map<ThreadId, boolean>(); |
| 306 | for (const thread of threads) { | 261 | for (const thread of threads) { |
| @@ -308,6 +263,13 @@ export default function Sidebar() { · 1 unmodified line | |||
| 308 | } | 263 | } |
| 309 | return map; | 264 | return map; |
| 310 | }, [threads]); | 265 | }, [threads]); |
| 266 | const pendingUserInputByThreadId = useMemo(() => { | ||
| 267 | const map = new Map<ThreadId, boolean>(); | ||
| 268 | for (const thread of threads) { | ||
| 269 | map.set(thread.id, derivePendingUserInputs(thread.activities).length > 0); | ||
| 270 | } | ||
| 271 | return map; | ||
| 272 | }, [threads]); | ||
| 311 | const projectCwdById = useMemo( | 273 | const projectCwdById = useMemo( |
| 312 | () => new Map(projects.map((project) => [project.id, project.cwd] as const)), | 274 | () => new Map(projects.map((project) => [project.id, project.cwd] as const)), |
| 313 | [projects], | 275 | [projects], |
| @@ -486,6 +448,7 @@ export default function Sidebar() { · 172 unmodified lines | |||
| 486 | const finishAddingProject = () => { | 448 | const finishAddingProject = () => { |
| 487 | setIsAddingProject(false); | 449 | setIsAddingProject(false); |
| 488 | setNewCwd(""); | 450 | setNewCwd(""); |
| 451 | setAddProjectError(null); | ||
| 489 | setAddingProject(false); | 452 | setAddingProject(false); |
| 490 | }; | 453 | }; |
| 491 | 454 | ||
| @@ -511,18 +474,29 @@ export default function Sidebar() { · 19 unmodified lines | |||
| 511 | }); | 474 | }); |
| 512 | await handleNewThread(projectId).catch(() => undefined); | 475 | await handleNewThread(projectId).catch(() => undefined); |
| 513 | } catch (error) { | 476 | } catch (error) { |
| 477 | const description = | ||
| 478 | error instanceof Error ? error.message : "An error occurred while adding the project."; | ||
| 514 | setIsAddingProject(false); | 479 | setIsAddingProject(false); |
| 515 | toastManager.add({ | 480 | if (shouldBrowseForProjectImmediately) { |
| 516 | type: "error", | 481 | toastManager.add({ |
| 517 | title: "Unable to add project", | 482 | type: "error", |
| 518 | description: | 483 | title: "Failed to add project", |
| 519 | error instanceof Error ? error.message : "An error occurred while adding the project.", | 484 | description, |
| 520 | }); | 485 | }); |
| 486 | } else { | ||
| 487 | setAddProjectError(description); | ||
| 488 | } | ||
| 521 | return; | 489 | return; |
| 522 | } | 490 | } |
| 523 | finishAddingProject(); | 491 | finishAddingProject(); |
| 524 | }, | 492 | }, |
| 525 | [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects], | 493 | [ |
| 494 | focusMostRecentThreadForProject, | ||
| 495 | handleNewThread, | ||
| 496 | isAddingProject, | ||
| 497 | projects, | ||
| 498 | shouldBrowseForProjectImmediately, | ||
| 499 | ], | ||
| 526 | ); | 500 | ); |
| 527 | 501 | ||
| 528 | const handleAddProject = () => { | 502 | const handleAddProject = () => { |
| @@ -541,10 +515,21 @@ export default function Sidebar() { · 12 unmodified lines | |||
| 541 | } | 515 | } |
| 542 | if (pickedPath) { | 516 | if (pickedPath) { |
| 543 | await addProjectFromPath(pickedPath); | 517 | await addProjectFromPath(pickedPath); |
| 518 | } else if (!shouldBrowseForProjectImmediately) { | ||
| 519 | addProjectInputRef.current?.focus(); | ||
| 544 | } | 520 | } |
| 545 | setIsPickingFolder(false); | 521 | setIsPickingFolder(false); |
| 546 | }; | 522 | }; |
| 547 | 523 | ||
| 524 | const handleStartAddProject = () => { | ||
| 525 | setAddProjectError(null); | ||
| 526 | if (shouldBrowseForProjectImmediately) { | ||
| 527 | void handlePickFolder(); | ||
| 528 | return; | ||
| 529 | } | ||
| 530 | setAddingProject((prev) => !prev); | ||
| 531 | }; | ||
| 532 | |||
| 548 | const cancelRename = useCallback(() => { | 533 | const cancelRename = useCallback(() => { |
| 549 | setRenamingThreadId(null); | 534 | setRenamingThreadId(null); |
| 550 | renamingInputRef.current = null; | 535 | renamingInputRef.current = null; |
| @@ -881,6 +866,12 @@ export default function Sidebar() { · 330 unmodified lines | |||
| 881 | const desktopUpdateButtonAction = desktopUpdateState | 866 | const desktopUpdateButtonAction = desktopUpdateState |
| 882 | ? resolveDesktopUpdateButtonAction(desktopUpdateState) | 867 | ? resolveDesktopUpdateButtonAction(desktopUpdateState) |
| 883 | : "none"; | 868 | : "none"; |
| 869 | const showArm64IntelBuildWarning = | ||
| 870 | isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); | ||
| 871 | const arm64IntelBuildWarningDescription = | ||
| 872 | desktopUpdateState && showArm64IntelBuildWarning | ||
| 873 | ? getArm64IntelBuildWarningDescription(desktopUpdateState) | ||
| 874 | : null; | ||
| 884 | const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled | 875 | const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled |
| 885 | ? "cursor-not-allowed opacity-60" | 876 | ? "cursor-not-allowed opacity-60" |
| 886 | : "hover:bg-accent hover:text-foreground"; | 877 | : "hover:bg-accent hover:text-foreground"; |
| @@ -978,7 +969,7 @@ export default function Sidebar() { · 91 unmodified lines | |||
| 978 | const wordmark = ( | 969 | const wordmark = ( |
| 979 | <div className="flex items-center gap-2"> | 970 | <div className="flex items-center gap-2"> |
| 980 | <SidebarTrigger className="shrink-0 md:hidden" /> | 971 | <SidebarTrigger className="shrink-0 md:hidden" /> |
| 981 | <div className="flex min-w-0 flex-1 items-center gap-1 mt-2 ml-1"> | 972 | <div className="flex min-w-0 flex-1 items-center gap-1 mt-1.5 ml-1"> |
| 982 | <T3Wordmark /> | 973 | <T3Wordmark /> |
| 983 | <span className="truncate text-sm font-medium tracking-tight text-muted-foreground"> | 974 | <span className="truncate text-sm font-medium tracking-tight text-muted-foreground"> |
| 984 | Code | 975 | Code |
| @@ -994,7 +985,7 @@ export default function Sidebar() { · 9 unmodified lines | |||
| 994 | <> | 985 | <> |
| 995 | {isElectron ? ( | 986 | {isElectron ? ( |
| 996 | <> | 987 | <> |
| 997 | <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[82px]"> | 988 | <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]"> |
| 998 | {wordmark} | 989 | {wordmark} |
| 999 | {showDesktopUpdateButton && ( | 990 | {showDesktopUpdateButton && ( |
| 1000 | <Tooltip> | 991 | <Tooltip> |
| @@ -1005,7 +996,7 @@ export default function Sidebar() { · 4 unmodified lines | |||
| 1005 | aria-label={desktopUpdateTooltip} | 996 | aria-label={desktopUpdateTooltip} |
| 1006 | aria-disabled={desktopUpdateButtonDisabled || undefined} | 997 | aria-disabled={desktopUpdateButtonDisabled || undefined} |
| 1007 | disabled={desktopUpdateButtonDisabled} | 998 | disabled={desktopUpdateButtonDisabled} |
| 1008 | className={`inline-flex size-7 ml-auto mt-2 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`} | 999 | className={`inline-flex size-7 ml-auto mt-1.5 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`} |
| 1009 | onClick={handleDesktopUpdateButtonClick} | 1000 | onClick={handleDesktopUpdateButtonClick} |
| 1010 | > | 1001 | > |
| 1011 | <RocketIcon className="size-3.5" /> | 1002 | <RocketIcon className="size-3.5" /> |
| @@ -1024,7 +1015,121 @@ export default function Sidebar() { · 12 unmodified lines | |||
| 1024 | )} | 1015 | )} |
| 1025 | 1016 | ||
| 1026 | <SidebarContent className="gap-0"> | 1017 | <SidebarContent className="gap-0"> |
| 1018 | {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( | ||
| 1019 | <SidebarGroup className="px-2 pt-2 pb-0"> | ||
| 1020 | <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8"> | ||
| 1021 | <TriangleAlertIcon /> | ||
| 1022 | <AlertTitle>Intel build on Apple Silicon</AlertTitle> | ||
| 1023 | <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription> | ||
| 1024 | {desktopUpdateButtonAction !== "none" ? ( | ||
| 1025 | <AlertAction> | ||
| 1026 | <Button | ||
| 1027 | size="xs" | ||
| 1028 | variant="outline" | ||
| 1029 | disabled={desktopUpdateButtonDisabled} | ||
| 1030 | onClick={handleDesktopUpdateButtonClick} | ||
| 1031 | > | ||
| 1032 | {desktopUpdateButtonAction === "download" | ||
| 1033 | ? "Download ARM build" | ||
| 1034 | : "Install ARM build"} | ||
| 1035 | </Button> | ||
| 1036 | </AlertAction> | ||
| 1037 | ) : null} | ||
| 1038 | </Alert> | ||
| 1039 | </SidebarGroup> | ||
| 1040 | ) : null} | ||
| 1027 | <SidebarGroup className="px-2 py-2"> | 1041 | <SidebarGroup className="px-2 py-2"> |
| 1042 | <div className="mb-1 flex items-center justify-between px-2"> | ||
| 1043 | <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60"> | ||
| 1044 | Projects | ||
| 1045 | </span> | ||
| 1046 | <Tooltip> | ||
| 1047 | <TooltipTrigger | ||
| 1048 | render={ | ||
| 1049 | <button | ||
| 1050 | type="button" | ||
| 1051 | aria-label="Add project" | ||
| 1052 | aria-pressed={shouldShowProjectPathEntry} | ||
| 1053 | className="inline-flex size-5 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" | ||
| 1054 | onClick={handleStartAddProject} | ||
| 1055 | /> | ||
| 1056 | } | ||
| 1057 | > | ||
| 1058 | <PlusIcon | ||
| 1059 | className={`size-3.5 transition-transform duration-150 ${ | ||
| 1060 | shouldShowProjectPathEntry ? "rotate-45" : "rotate-0" | ||
| 1061 | }`} | ||
| 1062 | /> | ||
| 1063 | </TooltipTrigger> | ||
| 1064 | <TooltipPopup side="right">Add project</TooltipPopup> | ||
| 1065 | </Tooltip> | ||
| 1066 | </div> | ||
| 1067 | |||
| 1068 | {shouldShowProjectPathEntry && ( | ||
| 1069 | <div className="mb-2 px-1"> | ||
| 1070 | {isElectron && ( | ||
| 1071 | <button | ||
| 1072 | type="button" | ||
| 1073 | className="mb-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-border bg-secondary py-1.5 text-xs text-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60" | ||
| 1074 | onClick={() => void handlePickFolder()} | ||
| 1075 | disabled={isPickingFolder || isAddingProject} | ||
| 1076 | > | ||
| 1077 | <FolderIcon className="size-3.5" /> | ||
| 1078 | {isPickingFolder ? "Picking folder..." : "Browse for folder"} | ||
| 1079 | </button> | ||
| 1080 | )} | ||
| 1081 | <div className="flex gap-1.5"> | ||
| 1082 | <input | ||
| 1083 | ref={addProjectInputRef} | ||
| 1084 | className={`min-w-0 flex-1 rounded-md border bg-secondary px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none ${ | ||
| 1085 | addProjectError | ||
| 1086 | ? "border-red-500/70 focus:border-red-500" | ||
| 1087 | : "border-border focus:border-ring" | ||
| 1088 | }`} | ||
| 1089 | placeholder="/path/to/project" | ||
| 1090 | value={newCwd} | ||
| 1091 | onChange={(event) => { | ||
| 1092 | setNewCwd(event.target.value); | ||
| 1093 | setAddProjectError(null); | ||
| 1094 | }} | ||
| 1095 | onKeyDown={(event) => { | ||
| 1096 | if (event.key === "Enter") handleAddProject(); | ||
| 1097 | if (event.key === "Escape") { | ||
| 1098 | setAddingProject(false); | ||
| 1099 | setAddProjectError(null); | ||
| 1100 | } | ||
| 1101 | }} | ||
| 1102 | autoFocus | ||
| 1103 | /> | ||
| 1104 | <button | ||
| 1105 | type="button" | ||
| 1106 | className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60" | ||
| 1107 | onClick={handleAddProject} | ||
| 1108 | disabled={isAddingProject} | ||
| 1109 | > | ||
| 1110 | {isAddingProject ? "Adding..." : "Add"} | ||
| 1111 | </button> | ||
| 1112 | </div> | ||
| 1113 | {addProjectError && ( | ||
| 1114 | <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400"> | ||
| 1115 | {addProjectError} | ||
| 1116 | </p> | ||
| 1117 | )} | ||
| 1118 | <div className="mt-1.5 px-0.5"> | ||
| 1119 | <button | ||
| 1120 | type="button" | ||
| 1121 | className="text-[11px] text-muted-foreground/50 transition-colors hover:text-muted-foreground" | ||
| 1122 | onClick={() => { | ||
| 1123 | setAddingProject(false); | ||
| 1124 | setAddProjectError(null); | ||
| 1125 | }} | ||
| 1126 | > | ||
| 1127 | Cancel | ||
| 1128 | </button> | ||
| 1129 | </div> | ||
| 1130 | </div> | ||
| 1131 | )} | ||
| 1132 | |||
| 1028 | <SidebarMenu> | 1133 | <SidebarMenu> |
| 1029 | {projects.map((project) => { | 1134 | {projects.map((project) => { |
| 1030 | const projectThreads = threads | 1135 | const projectThreads = threads |
| @@ -1112,10 +1217,11 @@ export default function Sidebar() { · 81 unmodified lines | |||
| 1112 | <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0"> | 1217 | <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0"> |
| 1113 | {visibleThreads.map((thread) => { | 1218 | {visibleThreads.map((thread) => { |
| 1114 | const isActive = routeThreadId === thread.id; | 1219 | const isActive = routeThreadId === thread.id; |
| 1115 | const threadStatus = threadStatusPill( | 1220 | const threadStatus = resolveThreadStatusPill({ |
| 1116 | thread, | 1221 | thread, |
| 1117 | pendingApprovalByThreadId.get(thread.id) === true, | 1222 | hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true, |
| 1118 | ); | 1223 | hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true, |
| 1224 | }); | ||
| 1119 | const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); | 1225 | const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); |
| 1120 | const terminalStatus = terminalStatusFromRunningIds( | 1226 | const terminalStatus = terminalStatusFromRunningIds( |
| 1121 | selectThreadTerminalState(terminalStateByThreadId, thread.id) | 1227 | selectThreadTerminalState(terminalStateByThreadId, thread.id) |
| @@ -1286,70 +1392,39 @@ export default function Sidebar() { · 164 unmodified lines | |||
| 1286 | })} | 1392 | })} |
| 1287 | </SidebarMenu> | 1393 | </SidebarMenu> |
| 1288 | 1394 | ||
| 1289 | {projects.length === 0 && !addingProject && ( | 1395 | {projects.length === 0 && !shouldShowProjectPathEntry && ( |
| 1290 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> | 1396 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> |
| 1291 | No projects yet. | 1397 | No projects yet |
| 1292 | <br /> | ||
| 1293 | Add one to get started. | ||
| 1294 | </div> | 1398 | </div> |
| 1295 | )} | 1399 | )} |
| 1296 | </SidebarGroup> | 1400 | </SidebarGroup> |
| 1297 | </SidebarContent> | 1401 | </SidebarContent> |
| 1298 | 1402 | ||
| 1299 | <SidebarSeparator /> | 1403 | <SidebarSeparator /> |
| 1300 | <SidebarFooter className="gap-0 p-3"> | 1404 | <SidebarFooter className="p-2"> |
| 1301 | {addingProject ? ( | 1405 | <SidebarMenu> |
| 1302 | <> | 1406 | <SidebarMenuItem> |
| 1303 | <p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70"> | 1407 | {isOnSettings ? ( |
| 1304 | Add project | 1408 | <SidebarMenuButton |
| 1305 | </p> | 1409 | size="sm" |
| 1306 | <input | 1410 | className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground" |
| 1307 | className="mb-2 w-full rounded-md border border-border bg-secondary px-2 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-ring focus:outline-none" | 1411 | onClick={() => window.history.back()} |
| 1308 | placeholder="/path/to/project" | ||
| 1309 | value={newCwd} | ||
| 1310 | onChange={(event) => setNewCwd(event.target.value)} | ||
| 1311 | onKeyDown={(event) => { | ||
| 1312 | if (event.key === "Enter") handleAddProject(); | ||
| 1313 | if (event.key === "Escape") setAddingProject(false); | ||
| 1314 | }} | ||
| 1315 | /> | ||
| 1316 | {isElectron && ( | ||
| 1317 | <button | ||
| 1318 | type="button" | ||
| 1319 | className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" | ||
| 1320 | onClick={() => void handlePickFolder()} | ||
| 1321 | disabled={isPickingFolder || isAddingProject} | ||
| 1322 | > | 1412 | > |
| 1323 | {isPickingFolder ? "Picking folder..." : "Browse for folder"} | 1413 | <ArrowLeftIcon className="size-3.5" /> |
| 1324 | </button> | 1414 | <span className="text-xs">Back</span> |
| 1325 | )} | 1415 | </SidebarMenuButton> |
| 1326 | <div className="flex gap-2"> | 1416 | ) : ( |
| 1327 | <button | 1417 | <SidebarMenuButton |
| 1328 | type="button" | 1418 | size="sm" |
| 1329 | className="flex-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90" | 1419 | className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground" |
| 1330 | onClick={handleAddProject} | 1420 | onClick={() => void navigate({ to: "/settings" })} |
| 1331 | disabled={isAddingProject} | ||
| 1332 | > | ||
| 1333 | {isAddingProject ? "Adding..." : "Add"} | ||
| 1334 | </button> | ||
| 1335 | <button | ||
| 1336 | type="button" | ||
| 1337 | className="flex-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground/80 transition-colors duration-150 hover:bg-secondary" | ||
| 1338 | onClick={() => setAddingProject(false)} | ||
| 1339 | > | 1421 | > |
| 1340 | Cancel | 1422 | <SettingsIcon className="size-3.5" /> |
| 1341 | </button> | 1423 | <span className="text-xs">Settings</span> |
| 1342 | </div> | 1424 | </SidebarMenuButton> |
| 1343 | </> | 1425 | )} |
| 1344 | ) : ( | 1426 | </SidebarMenuItem> |
| 1345 | <button | 1427 | </SidebarMenu> |
| 1346 | type="button" | ||
| 1347 | className="flex w-full items-center justify-center gap-1 rounded-md border border-dashed border-border py-2 text-xs text-muted-foreground/70 transition-colors duration-150 hover:border-ring hover:text-muted-foreground" | ||
| 1348 | onClick={() => setAddingProject(true)} | ||
| 1349 | > | ||
| 1350 | + Add project | ||
| 1351 | </button> | ||
| 1352 | )} | ||
| 1353 | </SidebarFooter> | 1428 | </SidebarFooter> |
| 1354 | </> | 1429 | </> |
| 1355 | ); | 1430 | ); |
| @@ -2,11 +2,13 @@ import { describe, expect, it } from "vitest"; · 1 unmodified line | |||
| 2 | import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; | 2 | import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | getArm64IntelBuildWarningDescription, | ||
| 5 | getDesktopUpdateActionError, | 6 | getDesktopUpdateActionError, |
| 6 | getDesktopUpdateButtonTooltip, | 7 | getDesktopUpdateButtonTooltip, |
| 7 | isDesktopUpdateButtonDisabled, | 8 | isDesktopUpdateButtonDisabled, |
| 8 | resolveDesktopUpdateButtonAction, | 9 | resolveDesktopUpdateButtonAction, |
| 9 | shouldHighlightDesktopUpdateError, | 10 | shouldHighlightDesktopUpdateError, |
| 11 | shouldShowArm64IntelBuildWarning, | ||
| 10 | shouldShowDesktopUpdateButton, | 12 | shouldShowDesktopUpdateButton, |
| 11 | shouldToastDesktopUpdateActionResult, | 13 | shouldToastDesktopUpdateActionResult, |
| 12 | } from "./desktopUpdate.logic"; | 14 | } from "./desktopUpdate.logic"; |
| @@ -15,6 +17,9 @@ const baseState: DesktopUpdateState = { · 2 unmodified lines | |||
| 15 | enabled: true, | 17 | enabled: true, |
| 16 | status: "idle", | 18 | status: "idle", |
| 17 | currentVersion: "1.0.0", | 19 | currentVersion: "1.0.0", |
| 20 | hostArch: "x64", | ||
| 21 | appArch: "x64", | ||
| 22 | runningUnderArm64Translation: false, | ||
| 18 | availableVersion: null, | 23 | availableVersion: null, |
| 19 | downloadedVersion: null, | 24 | downloadedVersion: null, |
| 20 | downloadPercent: null, | 25 | downloadPercent: null, |
| @@ -171,4 +176,30 @@ describe("desktop update UI helpers", () => { · 150 unmodified lines | |||
| 171 | }), | 176 | }), |
| 172 | ).toBe(false); | 177 | ).toBe(false); |
| 173 | }); | 178 | }); |
| 179 | |||
| 180 | it("shows an Apple Silicon warning for Intel builds under Rosetta", () => { | ||
| 181 | const state: DesktopUpdateState = { | ||
| 182 | ...baseState, | ||
| 183 | hostArch: "arm64", | ||
| 184 | appArch: "x64", | ||
| 185 | runningUnderArm64Translation: true, | ||
| 186 | }; | ||
| 187 | |||
| 188 | expect(shouldShowArm64IntelBuildWarning(state)).toBe(true); | ||
| 189 | expect(getArm64IntelBuildWarningDescription(state)).toContain("Apple Silicon"); | ||
| 190 | expect(getArm64IntelBuildWarningDescription(state)).toContain("Intel build"); | ||
| 191 | }); | ||
| 192 | |||
| 193 | it("changes the warning copy when a native build update is ready to download", () => { | ||
| 194 | const state: DesktopUpdateState = { | ||
| 195 | ...baseState, | ||
| 196 | hostArch: "arm64", | ||
| 197 | appArch: "x64", | ||
| 198 | runningUnderArm64Translation: true, | ||
| 199 | status: "available", | ||
| 200 | availableVersion: "1.1.0", | ||
| 201 | }; | ||
| 202 | |||
| 203 | expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update"); | ||
| 204 | }); | ||
| 174 | }); | 205 | }); |
| @@ -32,10 +32,29 @@ export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): · 31 unmodified lines | |||
| 32 | return resolveDesktopUpdateButtonAction(state) !== "none"; | 32 | return resolveDesktopUpdateButtonAction(state) !== "none"; |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 35 | export function shouldShowArm64IntelBuildWarning(state: DesktopUpdateState | null): boolean { | ||
| 36 | return state?.hostArch === "arm64" && state.appArch === "x64"; | ||
| 37 | } | ||
| 38 | |||
| 35 | export function isDesktopUpdateButtonDisabled(state: DesktopUpdateState | null): boolean { | 39 | export function isDesktopUpdateButtonDisabled(state: DesktopUpdateState | null): boolean { |
| 36 | return state?.status === "downloading"; | 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 | export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string { | 58 | export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string { |
| 40 | if (state.status === "available") { | 59 | if (state.status === "available") { |
| 41 | return `Update ${state.availableVersion ?? "available"} ready to download`; | 60 | return `Update ${state.availableVersion ?? "available"} ready to download`; |
| @@ -566,7 +566,8 @@ function SidebarRail({ · 565 unmodified lines | |||
| 566 | <button | 566 | <button |
| 567 | aria-label={railLabel} | 567 | aria-label={railLabel} |
| 568 | className={cn( | 568 | className={cn( |
| 569 | "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex", | 569 | /* disable pointer events only when offcanvas sidebar is collapsed, that's when the rail sits over the native scrollbar on windows and linux. icon mode stays fully clickable. */ |
| 570 | "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none", | ||
| 570 | "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", | 571 | "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", |
| 571 | "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", | 572 | "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", |
| 572 | "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full", | 573 | "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full", |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { assert, describe, it } from "vitest"; | 1 | import { assert, describe, it } from "vitest"; |
| 2 | import { shouldHideCollapsedToastContent } from "./toast.logic"; | 2 | import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; |
| 3 | 3 | ||
| 4 | describe("shouldHideCollapsedToastContent", () => { | 4 | describe("shouldHideCollapsedToastContent", () => { |
| 5 | it("keeps a single visible toast readable", () => { | 5 | it("keeps a single visible toast readable", () => { |
| @@ -14,3 +14,42 @@ describe("shouldHideCollapsedToastContent", () => { · 8 unmodified lines | |||
| 14 | assert.equal(shouldHideCollapsedToastContent(1, 3), true); | 14 | assert.equal(shouldHideCollapsedToastContent(1, 3), true); |
| 15 | }); | 15 | }); |
| 16 | }); | 16 | }); |
| 17 | |||
| 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 | if (visibleToastCount <= 1) return false; | 7 | if (visibleToastCount <= 1) return false; |
| 8 | return visibleToastIndex > 0; | 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 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { Toast } from "@base-ui/react/toast"; | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | import { useEffect } from "react"; | 4 | import { useEffect, type CSSProperties } from "react"; |
| 5 | import { useParams } from "@tanstack/react-router"; | 5 | import { useParams } from "@tanstack/react-router"; |
| 6 | import { ThreadId } from "@t3tools/contracts"; | 6 | import { ThreadId } from "@t3tools/contracts"; |
| 7 | import { | 7 | import { |
| @@ -14,7 +14,7 @@ import { · 6 unmodified lines | |||
| 14 | 14 | ||
| 15 | import { cn } from "~/lib/utils"; | 15 | import { cn } from "~/lib/utils"; |
| 16 | import { buttonVariants } from "~/components/ui/button"; | 16 | import { buttonVariants } from "~/components/ui/button"; |
| 17 | import { shouldHideCollapsedToastContent } from "./toast.logic"; | 17 | import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; |
| 18 | 18 | ||
| 19 | type ThreadToastData = { | 19 | type ThreadToastData = { |
| 20 | threadId?: ThreadId | null; | 20 | threadId?: ThreadId | null; |
| @@ -158,6 +158,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 137 unmodified lines | |||
| 158 | const visibleToasts = toasts.filter((toast) => | 158 | const visibleToasts = toasts.filter((toast) => |
| 159 | shouldRenderForActiveThread(toast.data, activeThreadId), | 159 | shouldRenderForActiveThread(toast.data, activeThreadId), |
| 160 | ); | 160 | ); |
| 161 | const visibleToastLayout = buildVisibleToastLayout(visibleToasts); | ||
| 161 | 162 | ||
| 162 | useEffect(() => { | 163 | useEffect(() => { |
| 163 | const activeToastIds = new Set(toasts.map((toast) => toast.id)); | 164 | const activeToastIds = new Set(toasts.map((toast) => toast.id)); |
| @@ -183,12 +184,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 19 unmodified lines | |||
| 183 | )} | 184 | )} |
| 184 | data-position={position} | 185 | data-position={position} |
| 185 | data-slot="toast-viewport" | 186 | data-slot="toast-viewport" |
| 187 | style={ | ||
| 188 | { | ||
| 189 | "--toast-frontmost-height": `${visibleToastLayout.frontmostHeight}px`, | ||
| 190 | } as CSSProperties | ||
| 191 | } | ||
| 186 | > | 192 | > |
| 187 | {visibleToasts.map((toast, visibleIndex) => { | 193 | {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => { |
| 188 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; | 194 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; |
| 189 | const hideCollapsedContent = shouldHideCollapsedToastContent( | 195 | const hideCollapsedContent = shouldHideCollapsedToastContent( |
| 190 | visibleIndex, | 196 | visibleIndex, |
| 191 | visibleToasts.length, | 197 | visibleToastLayout.items.length, |
| 192 | ); | 198 | ); |
| 193 | 199 | ||
| 194 | return ( | 200 | return ( |
| @@ -241,6 +247,12 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 46 unmodified lines | |||
| 241 | )} | 247 | )} |
| 242 | data-position={position} | 248 | data-position={position} |
| 243 | key={toast.id} | 249 | key={toast.id} |
| 250 | style={ | ||
| 251 | { | ||
| 252 | "--toast-index": visibleIndex, | ||
| 253 | "--toast-offset-y": `${offsetY}px`, | ||
| 254 | } as CSSProperties | ||
| 255 | } | ||
| 244 | swipeDirection={ | 256 | swipeDirection={ |
| 245 | position.includes("center") | 257 | position.includes("center") |
| 246 | ? [isTop ? "up" : "down"] | 258 | ? [isTop ? "up" : "down"] |
| @@ -49,3 +49,19 @@ export function buildProposedPlanMarkdownFilename(planMarkdown: string): string · 48 unmodified lines | |||
| 49 | const title = proposedPlanTitle(planMarkdown); | 49 | const title = proposedPlanTitle(planMarkdown); |
| 50 | return `${sanitizePlanFileSegment(title ?? "plan")}.md`; | 50 | return `${sanitizePlanFileSegment(title ?? "plan")}.md`; |
| 51 | } | 51 | } |
| 52 | |||
| 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 | import { useCallback, useState } from "react"; | 3 | import { useCallback, useState } from "react"; |
| 4 | import { type ProviderKind } from "@t3tools/contracts"; | 4 | import { type ProviderKind } from "@t3tools/contracts"; |
| 5 | import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; | 5 | import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; |
| 6 | import { ZapIcon } from "lucide-react"; | 6 | |
| 7 | 7 | import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; | |
| 8 | import { | ||
| 9 | APP_SERVICE_TIER_OPTIONS, | ||
| 10 | MAX_CUSTOM_MODEL_LENGTH, | ||
| 11 | shouldShowFastTierIcon, | ||
| 12 | useAppSettings, | ||
| 13 | } from "../appSettings"; | ||
| 14 | import { isElectron } from "../env"; | 8 | import { isElectron } from "../env"; |
| 15 | import { useTheme } from "../hooks/useTheme"; | 9 | import { useTheme } from "../hooks/useTheme"; |
| 16 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 10 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| @@ -18,7 +12,6 @@ import { ensureNativeApi } from "../nativeApi"; · 1 unmodified line | |||
| 18 | import { preferredTerminalEditor } from "../terminal-links"; | 12 | import { preferredTerminalEditor } from "../terminal-links"; |
| 19 | import { Button } from "../components/ui/button"; | 13 | import { Button } from "../components/ui/button"; |
| 20 | import { Input } from "../components/ui/input"; | 14 | import { Input } from "../components/ui/input"; |
| 21 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../components/ui/select"; | ||
| 22 | import { Switch } from "../components/ui/switch"; | 15 | import { Switch } from "../components/ui/switch"; |
| 23 | import { SidebarInset } from "~/components/ui/sidebar"; | 16 | import { SidebarInset } from "~/components/ui/sidebar"; |
| 24 | 17 | ||
| @@ -103,7 +96,6 @@ function SettingsRouteView() { · 78 unmodified lines | |||
| 103 | 96 | ||
| 104 | const codexBinaryPath = settings.codexBinaryPath; | 97 | const codexBinaryPath = settings.codexBinaryPath; |
| 105 | const codexHomePath = settings.codexHomePath; | 98 | const codexHomePath = settings.codexHomePath; |
| 106 | const codexServiceTier = settings.codexServiceTier; | ||
| 107 | const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; | 99 | const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; |
| 108 | 100 | ||
| 109 | const openKeybindingsFile = useCallback(() => { | 101 | const openKeybindingsFile = useCallback(() => { |
| @@ -310,43 +302,6 @@ function SettingsRouteView() { · 200 unmodified lines | |||
| 310 | </div> | 302 | </div> |
| 311 | 303 | ||
| 312 | <div className="space-y-5"> | 304 | <div className="space-y-5"> |
| 313 | <label className="block space-y-1"> | ||
| 314 | <span className="text-xs font-medium text-foreground">Default service tier</span> | ||
| 315 | <Select | ||
| 316 | items={APP_SERVICE_TIER_OPTIONS.map((option) => ({ | ||
| 317 | label: option.label, | ||
| 318 | value: option.value, | ||
| 319 | }))} | ||
| 320 | value={codexServiceTier} | ||
| 321 | onValueChange={(value) => { | ||
| 322 | if (!value) return; | ||
| 323 | updateSettings({ codexServiceTier: value }); | ||
| 324 | }} | ||
| 325 | > | ||
| 326 | <SelectTrigger> | ||
| 327 | <SelectValue /> | ||
| 328 | </SelectTrigger> | ||
| 329 | <SelectPopup alignItemWithTrigger={false}> | ||
| 330 | {APP_SERVICE_TIER_OPTIONS.map((option) => ( | ||
| 331 | <SelectItem key={option.value} value={option.value}> | ||
| 332 | <div className="flex min-w-0 items-center gap-2"> | ||
| 333 | {option.value === "fast" ? ( | ||
| 334 | <ZapIcon className="size-3.5 text-amber-500" /> | ||
| 335 | ) : ( | ||
| 336 | <span className="size-3.5 shrink-0" aria-hidden="true" /> | ||
| 337 | )} | ||
| 338 | <span className="truncate">{option.label}</span> | ||
| 339 | </div> | ||
| 340 | </SelectItem> | ||
| 341 | ))} | ||
| 342 | </SelectPopup> | ||
| 343 | </Select> | ||
| 344 | <span className="text-xs text-muted-foreground"> | ||
| 345 | {APP_SERVICE_TIER_OPTIONS.find((option) => option.value === codexServiceTier) | ||
| 346 | ?.description ?? "Use Codex defaults without forcing a service tier."} | ||
| 347 | </span> | ||
| 348 | </label> | ||
| 349 | |||
| 350 | {MODEL_PROVIDER_SETTINGS.map((providerSettings) => { | 305 | {MODEL_PROVIDER_SETTINGS.map((providerSettings) => { |
| 351 | const provider = providerSettings.provider; | 306 | const provider = providerSettings.provider; |
| 352 | const customModels = getCustomModelsForProvider(settings, provider); | 307 | const customModels = getCustomModelsForProvider(settings, provider); |
| @@ -445,14 +400,9 @@ function SettingsRouteView() { · 92 unmodified lines | |||
| 445 | key={`${provider}:${slug}`} | 400 | key={`${provider}:${slug}`} |
| 446 | className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2" | 401 | className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2" |
| 447 | > | 402 | > |
| 448 | <div className="flex min-w-0 flex-1 items-center gap-2"> | 403 | <code className="min-w-0 flex-1 truncate text-xs text-foreground"> |
| 449 | {provider === "codex" && shouldShowFastTierIcon(slug, codexServiceTier) ? ( | 404 | {slug} |
| 450 | <ZapIcon className="size-3.5 shrink-0 text-amber-500" /> | 405 | </code> |
| 451 | ) : null} | ||
| 452 | <code className="min-w-0 flex-1 truncate text-xs text-foreground"> | ||
| 453 | {slug} | ||
| 454 | </code> | ||
| 455 | </div> | ||
| 456 | <Button | 406 | <Button |
| 457 | size="xs" | 407 | size="xs" |
| 458 | variant="ghost" | 408 | variant="ghost" |
| @@ -25,6 +25,7 @@ export interface AppState { · 24 unmodified lines | |||
| 25 | 25 | ||
| 26 | const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; | 26 | const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; |
| 27 | const LEGACY_PERSISTED_STATE_KEYS = [ | 27 | const LEGACY_PERSISTED_STATE_KEYS = [ |
| 28 | "t3code:renderer-state:v7", | ||
| 28 | "t3code:renderer-state:v6", | 29 | "t3code:renderer-state:v6", |
| 29 | "t3code:renderer-state:v5", | 30 | "t3code:renderer-state:v5", |
| 30 | "t3code:renderer-state:v4", | 31 | "t3code:renderer-state:v4", |
| @@ -4,6 +4,14 @@ import { tanstackRouter } from "@tanstack/router-plugin/vite"; · 3 unmodified lines | |||
| 4 | import { defineConfig } from "vite"; | 4 | import { defineConfig } from "vite"; |
| 5 | 5 | ||
| 6 | const port = Number(process.env.PORT ?? 5733); | 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 | export default defineConfig({ | 16 | export default defineConfig({ |
| 9 | plugins: [ | 17 | plugins: [ |
| @@ -42,5 +50,6 @@ export default defineConfig({ · 32 unmodified lines | |||
| 42 | build: { | 50 | build: { |
| 43 | outDir: "dist", | 51 | outDir: "dist", |
| 44 | emptyOutDir: true, | 52 | emptyOutDir: true, |
| 53 | sourcemap: buildSourcemap, | ||
| 45 | }, | 54 | }, |
| 46 | }); | 55 | }); |
| @@ -14,7 +14,7 @@ · 13 unmodified lines | |||
| 14 | }, | 14 | }, |
| 15 | "apps/desktop": { | 15 | "apps/desktop": { |
| 16 | "name": "@t3tools/desktop", | 16 | "name": "@t3tools/desktop", |
| 17 | "version": "0.0.5", | 17 | "version": "0.0.9", |
| 18 | "dependencies": { | 18 | "dependencies": { |
| 19 | "effect": "catalog:", | 19 | "effect": "catalog:", |
| 20 | "electron": "40.6.0", | 20 | "electron": "40.6.0", |
| @@ -43,7 +43,7 @@ · 22 unmodified lines | |||
| 43 | }, | 43 | }, |
| 44 | "apps/server": { | 44 | "apps/server": { |
| 45 | "name": "t3", | 45 | "name": "t3", |
| 46 | "version": "0.0.5", | 46 | "version": "0.0.9", |
| 47 | "bin": { | 47 | "bin": { |
| 48 | "t3": "./dist/index.mjs", | 48 | "t3": "./dist/index.mjs", |
| 49 | }, | 49 | }, |
| @@ -72,7 +72,7 @@ · 22 unmodified lines | |||
| 72 | }, | 72 | }, |
| 73 | "apps/web": { | 73 | "apps/web": { |
| 74 | "name": "@t3tools/web", | 74 | "name": "@t3tools/web", |
| 75 | "version": "0.0.5", | 75 | "version": "0.0.9", |
| 76 | "dependencies": { | 76 | "dependencies": { |
| 77 | "@base-ui/react": "^1.2.0", | 77 | "@base-ui/react": "^1.2.0", |
| 78 | "@lexical/react": "^0.41.0", | 78 | "@lexical/react": "^0.41.0", |
| @@ -116,7 +116,7 @@ · 37 unmodified lines | |||
| 116 | }, | 116 | }, |
| 117 | "packages/contracts": { | 117 | "packages/contracts": { |
| 118 | "name": "@t3tools/contracts", | 118 | "name": "@t3tools/contracts", |
| 119 | "version": "0.0.5", | 119 | "version": "0.0.9", |
| 120 | "dependencies": { | 120 | "dependencies": { |
| 121 | "effect": "catalog:", | 121 | "effect": "catalog:", |
| 122 | }, | 122 | }, |
| @@ -45,6 +45,7 @@ · 44 unmodified lines | |||
| 45 | "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", | 45 | "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", |
| 46 | "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", | 46 | "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", |
| 47 | "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", | 47 | "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", |
| 48 | "release:smoke": "node scripts/release-smoke.ts", | ||
| 48 | "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", | 49 | "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", |
| 49 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" | 50 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" |
| 50 | }, | 51 | }, |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@t3tools/contracts", | 2 | "name": "@t3tools/contracts", |
| 3 | "version": "0.0.5", | 3 | "version": "0.0.9", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "files": [ | 5 | "files": [ |
| 6 | "dist" | 6 | "dist" |
| @@ -58,10 +58,21 @@ export type DesktopUpdateStatus = · 57 unmodified lines | |||
| 58 | | "downloaded" | 58 | | "downloaded" |
| 59 | | "error"; | 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 | export interface DesktopUpdateState { | 69 | export interface DesktopUpdateState { |
| 62 | enabled: boolean; | 70 | enabled: boolean; |
| 63 | status: DesktopUpdateStatus; | 71 | status: DesktopUpdateStatus; |
| 64 | currentVersion: string; | 72 | currentVersion: string; |
| 73 | hostArch: DesktopRuntimeArch; | ||
| 74 | appArch: DesktopRuntimeArch; | ||
| 75 | runningUnderArm64Translation: boolean; | ||
| 65 | availableVersion: string | null; | 76 | availableVersion: string | null; |
| 66 | downloadedVersion: string | null; | 77 | downloadedVersion: string | null; |
| 67 | downloadPercent: number | null; | 78 | downloadPercent: number | null; |
| @@ -42,8 +42,6 @@ export const ProviderSandboxMode = Schema.Literals([ · 41 unmodified lines | |||
| 42 | "danger-full-access", | 42 | "danger-full-access", |
| 43 | ]); | 43 | ]); |
| 44 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; | 44 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; |
| 45 | export const ProviderServiceTier = Schema.Literals(["fast", "flex"]); | ||
| 46 | export type ProviderServiceTier = typeof ProviderServiceTier.Type; | ||
| 47 | export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; | 45 | export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; |
| 48 | const CodexProviderStartOptions = Schema.Struct({ | 46 | const CodexProviderStartOptions = Schema.Struct({ |
| 49 | binaryPath: Schema.optional(TrimmedNonEmptyString), | 47 | binaryPath: Schema.optional(TrimmedNonEmptyString), |
| @@ -371,7 +369,6 @@ export const ThreadTurnStartCommand = Schema.Struct({ · 321 unmodified lines | |||
| 371 | }), | 369 | }), |
| 372 | provider: Schema.optional(ProviderKind), | 370 | provider: Schema.optional(ProviderKind), |
| 373 | model: Schema.optional(TrimmedNonEmptyString), | 371 | model: Schema.optional(TrimmedNonEmptyString), |
| 374 | serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), | ||
| 375 | modelOptions: Schema.optional(ProviderModelOptions), | 372 | modelOptions: Schema.optional(ProviderModelOptions), |
| 376 | providerOptions: Schema.optional(ProviderStartOptions), | 373 | providerOptions: Schema.optional(ProviderStartOptions), |
| 377 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | 374 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| @@ -394,7 +391,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({ · 16 unmodified lines | |||
| 394 | }), | 391 | }), |
| 395 | provider: Schema.optional(ProviderKind), | 392 | provider: Schema.optional(ProviderKind), |
| 396 | model: Schema.optional(TrimmedNonEmptyString), | 393 | model: Schema.optional(TrimmedNonEmptyString), |
| 397 | serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), | ||
| 398 | modelOptions: Schema.optional(ProviderModelOptions), | 394 | modelOptions: Schema.optional(ProviderModelOptions), |
| 399 | providerOptions: Schema.optional(ProviderStartOptions), | 395 | providerOptions: Schema.optional(ProviderStartOptions), |
| 400 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | 396 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| @@ -675,7 +671,6 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ · 274 unmodified lines | |||
| 675 | messageId: MessageId, | 671 | messageId: MessageId, |
| 676 | provider: Schema.optional(ProviderKind), | 672 | provider: Schema.optional(ProviderKind), |
| 677 | model: Schema.optional(TrimmedNonEmptyString), | 673 | model: Schema.optional(TrimmedNonEmptyString), |
| 678 | serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), | ||
| 679 | modelOptions: Schema.optional(ProviderModelOptions), | 674 | modelOptions: Schema.optional(ProviderModelOptions), |
| 680 | providerOptions: Schema.optional(ProviderStartOptions), | 675 | providerOptions: Schema.optional(ProviderStartOptions), |
| 681 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | 676 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| @@ -769,20 +764,6 @@ const EventBaseFields = { · 87 unmodified lines | |||
| 769 | metadata: OrchestrationEventMetadata, | 764 | metadata: OrchestrationEventMetadata, |
| 770 | } as const; | 765 | } as const; |
| 771 | 766 | ||
| 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 | export const OrchestrationEvent = Schema.Union([ | 767 | export const OrchestrationEvent = Schema.Union([ |
| 787 | Schema.Struct({ | 768 | Schema.Struct({ |
| 788 | ...EventBaseFields, | 769 | ...EventBaseFields, |
| @@ -887,110 +868,6 @@ export const OrchestrationEvent = Schema.Union([ · 98 unmodified lines | |||
| 887 | ]); | 868 | ]); |
| 888 | export type OrchestrationEvent = typeof OrchestrationEvent.Type; | 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 | export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]); | 871 | export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]); |
| 995 | export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type; | 872 | export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type; |
| 996 | 873 | ||
| @@ -19,7 +19,6 @@ import { · 18 unmodified lines | |||
| 19 | ProviderKind, | 19 | ProviderKind, |
| 20 | ProviderRequestKind, | 20 | ProviderRequestKind, |
| 21 | ProviderSandboxMode, | 21 | ProviderSandboxMode, |
| 22 | ProviderServiceTier, | ||
| 23 | ProviderUserInputAnswers, | 22 | ProviderUserInputAnswers, |
| 24 | RuntimeMode, | 23 | RuntimeMode, |
| 25 | } from "./orchestration"; | 24 | } from "./orchestration"; |
| @@ -65,7 +64,6 @@ export const ProviderSessionStartInput = Schema.Struct({ · 39 unmodified lines | |||
| 65 | model: Schema.optional(TrimmedNonEmptyStringSchema), | 64 | model: Schema.optional(TrimmedNonEmptyStringSchema), |
| 66 | modelOptions: Schema.optional(ProviderModelOptions), | 65 | modelOptions: Schema.optional(ProviderModelOptions), |
| 67 | resumeCursor: Schema.optional(Schema.Unknown), | 66 | resumeCursor: Schema.optional(Schema.Unknown), |
| 68 | serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), | ||
| 69 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), | 67 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), |
| 70 | sandboxMode: Schema.optional(ProviderSandboxMode), | 68 | sandboxMode: Schema.optional(ProviderSandboxMode), |
| 71 | providerOptions: Schema.optional(ProviderStartOptions), | 69 | providerOptions: Schema.optional(ProviderStartOptions), |
| @@ -82,7 +80,6 @@ export const ProviderSendTurnInput = Schema.Struct({ · 10 unmodified lines | |||
| 82 | Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), | 80 | Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), |
| 83 | ), | 81 | ), |
| 84 | model: Schema.optional(TrimmedNonEmptyStringSchema), | 82 | model: Schema.optional(TrimmedNonEmptyStringSchema), |
| 85 | serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), | ||
| 86 | modelOptions: Schema.optional(ProviderModelOptions), | 83 | modelOptions: Schema.optional(ProviderModelOptions), |
| 87 | interactionMode: Schema.optional(ProviderInteractionMode), | 84 | interactionMode: Schema.optional(ProviderInteractionMode), |
| 88 | }); | 85 | }); |
| @@ -580,6 +580,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 579 unmodified lines | |||
| 580 | ChildProcess.make({ | 580 | ChildProcess.make({ |
| 581 | cwd: repoRoot, | 581 | cwd: repoRoot, |
| 582 | ...commandOutputOptions(options.verbose), | 582 | ...commandOutputOptions(options.verbose), |
| 583 | // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). | ||
| 584 | shell: process.platform === "win32", | ||
| 583 | })`bun run build:desktop`, | 585 | })`bun run build:desktop`, |
| 584 | ); | 586 | ); |
| 585 | } | 587 | } |
| @@ -642,6 +644,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 56 unmodified lines | |||
| 642 | ChildProcess.make({ | 644 | ChildProcess.make({ |
| 643 | cwd: stageAppDir, | 645 | cwd: stageAppDir, |
| 644 | ...commandOutputOptions(options.verbose), | 646 | ...commandOutputOptions(options.verbose), |
| 647 | // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). | ||
| 648 | shell: process.platform === "win32", | ||
| 645 | })`bun install --production`, | 649 | })`bun install --production`, |
| 646 | ); | 650 | ); |
| 647 | 651 | ||
| @@ -680,6 +684,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 32 unmodified lines | |||
| 680 | cwd: stageAppDir, | 684 | cwd: stageAppDir, |
| 681 | env: buildEnv, | 685 | env: buildEnv, |
| 682 | ...commandOutputOptions(options.verbose), | 686 | ...commandOutputOptions(options.verbose), |
| 687 | // Windows needs shell mode to resolve .cmd shims. | ||
| 688 | shell: process.platform === "win32", | ||
| 683 | })`bunx electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`, | 689 | })`bunx electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`, |
| 684 | ); | 690 | ); |
| 685 | 691 | ||
| @@ -455,6 +455,8 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { · 454 unmodified lines | |||
| 455 | stderr: "inherit", | 455 | stderr: "inherit", |
| 456 | env, | 456 | env, |
| 457 | extendEnv: false, | 457 | extendEnv: false, |
| 458 | // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). | ||
| 459 | shell: process.platform === "win32", | ||
| 458 | // Keep turbo in the same process group so terminal signals (Ctrl+C) | 460 | // Keep turbo in the same process group so terminal signals (Ctrl+C) |
| 459 | // reach it directly. Effect defaults to detached: true on non-Windows, | 461 | // reach it directly. Effect defaults to detached: true on non-Windows, |
| 460 | // which would put turbo in a new group and require manual forwarding. | 462 | // which would put turbo in a new group and require manual forwarding. |
| @@ -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 | } | ||