diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 10630fd67..7c4283080 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -28,3 +28,4 @@ github:PatrickBauer github:realAhmedRoach github:shiroyasha9 github:Yash-Singh1 +github:Ymit24 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdeef963e..7d6df8557 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -20,12 +20,12 @@ jobs: bun-version-file: package.json - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json - name: Cache Bun and Turbo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.bun/install/cache @@ -35,7 +35,7 @@ jobs: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}- - name: Cache Playwright browsers - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ hashFiles('bun.lock') }} @@ -78,7 +78,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -86,9 +86,9 @@ jobs: bun-version-file: package.json - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json - name: Exercise release-only workflow steps - run: node scripts/release-smoke.ts + run: bun run scripts/release-smoke.ts diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml new file mode 100644 index 000000000..1d5a29930 --- /dev/null +++ b/.github/workflows/pr-size.yml @@ -0,0 +1,202 @@ +name: PR Size + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] + +permissions: + contents: read + +jobs: + prepare-config: + name: Prepare PR size config + runs-on: ubuntu-24.04 + outputs: + labels_json: ${{ steps.config.outputs.labels_json }} + steps: + - id: config + name: Build PR size label config + uses: actions/github-script@v8 + with: + result-encoding: string + script: | + const managedLabels = [ + { + name: "size:XS", + color: "0e8a16", + description: "0-9 changed lines (additions + deletions).", + }, + { + name: "size:S", + color: "5ebd3e", + description: "10-29 changed lines (additions + deletions).", + }, + { + name: "size:M", + color: "fbca04", + description: "30-99 changed lines (additions + deletions).", + }, + { + name: "size:L", + color: "fe7d37", + description: "100-499 changed lines (additions + deletions).", + }, + { + name: "size:XL", + color: "d93f0b", + description: "500-999 changed lines (additions + deletions).", + }, + { + name: "size:XXL", + color: "b60205", + description: "1,000+ changed lines (additions + deletions).", + }, + ]; + + core.setOutput("labels_json", JSON.stringify(managedLabels)); + sync-label-definitions: + name: Sync PR size label definitions + needs: prepare-config + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-24.04 + permissions: + contents: read + issues: write + steps: + - name: Ensure PR size labels exist + uses: actions/github-script@v8 + env: + PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} + with: + script: | + const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]"); + + for (const label of managedLabels) { + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + }); + + if ( + existing.color !== label.color || + (existing.description ?? "") !== label.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } + } + } + label: + name: Label PR size + needs: [prepare-config, sync-label-definitions] + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-24.04 + permissions: + contents: read + issues: read + pull-requests: write + concurrency: + group: pr-size-${{ github.event.pull_request.number }} + cancel-in-progress: true + steps: + - name: Sync PR size label + uses: actions/github-script@v8 + env: + PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} + with: + script: | + const issueNumber = context.payload.pull_request.number; + const additions = context.payload.pull_request.additions ?? 0; + const deletions = context.payload.pull_request.deletions ?? 0; + const changedLines = additions + deletions; + const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]"); + + const managedLabelNames = new Set(managedLabels.map((label) => label.name)); + + const resolveSizeLabel = (totalChangedLines) => { + if (totalChangedLines < 10) { + return "size:XS"; + } + + if (totalChangedLines < 30) { + return "size:S"; + } + + if (totalChangedLines < 100) { + return "size:M"; + } + + if (totalChangedLines < 500) { + return "size:L"; + } + + if (totalChangedLines < 1000) { + return "size:XL"; + } + + return "size:XXL"; + }; + + const nextLabelName = resolveSizeLabel(changedLines); + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + + for (const label of currentLabels) { + if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { + continue; + } + + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: label.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + if (!currentLabels.some((label) => label.name === nextLabelName)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [nextLabelName], + }); + } + + core.info(`PR #${issueNumber}: ${changedLines} changed lines -> ${nextLabelName}`); diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml index 976a9a097..c4abb08b7 100644 --- a/.github/workflows/pr-vouch.yml +++ b/.github/workflows/pr-vouch.yml @@ -25,7 +25,7 @@ jobs: targets: ${{ steps.collect.outputs.targets }} steps: - id: collect - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | if (context.eventName === "pull_request_target") { @@ -85,7 +85,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Sync PR labels - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: PR_NUMBER: ${{ matrix.target.number }} VOUCH_STATUS: ${{ steps.vouch.outputs.status }} @@ -111,7 +111,7 @@ jobs: }, ]; - const managedLabelNames = managedLabels.map((label) => label.name); + const managedLabelNames = new Set(managedLabels.map((label) => label.name)); for (const label of managedLabels) { try { @@ -138,13 +138,19 @@ jobs: throw error; } - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } } } @@ -159,17 +165,35 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, + per_page: 100, }); - const preservedLabels = currentLabels - .map((label) => label.name) - .filter((name) => !managedLabelNames.includes(name)); + for (const label of currentLabels) { + if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { + continue; + } - await github.rest.issues.setLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [...preservedLabels, nextLabelName], - }); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: label.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + if (!currentLabels.some((label) => label.name === nextLabelName)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [nextLabelName], + }); + } core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40a46d5fc..e479e2b12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: ref: ${{ github.sha }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - id: release_meta name: Resolve release version @@ -61,7 +61,7 @@ jobs: bun-version-file: package.json - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json @@ -107,7 +107,7 @@ jobs: arch: x64 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} fetch-depth: 0 @@ -118,7 +118,7 @@ jobs: bun-version-file: package.json - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json @@ -217,29 +217,74 @@ jobs: fi - name: Upload build artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: desktop-${{ matrix.platform }}-${{ matrix.arch }} path: release-publish/* if-no-files-found: error + publish_cli: + name: Publish CLI to npm + needs: [preflight, build] + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Align package versions to release version + run: bun run scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + + - name: Build CLI package + run: bun run build --filter=@t3tools/web --filter=t3 + + - name: Publish CLI package + run: | + TAG="latest" + if [[ "${{ needs.preflight.outputs.is_prerelease }}" == "true" ]]; then + TAG="next" + fi + bun run apps/server/scripts/cli.ts publish --tag "$TAG" --app-version "${{ needs.preflight.outputs.version }}" --verbose + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + release: name: Publish GitHub Release - needs: [preflight, build] + needs: [preflight, build, publish_cli] runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json - name: Download all desktop artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: desktop-* merge-multiple: true @@ -247,7 +292,7 @@ jobs: - name: Merge macOS updater manifests run: | - node scripts/merge-mac-update-manifests.ts \ + bun run scripts/merge-mac-update-manifests.ts \ release-assets/latest-mac.yml \ release-assets/latest-mac-x64.yml rm -f release-assets/latest-mac-x64.yml @@ -272,11 +317,11 @@ jobs: finalize: name: Finalize release - needs: [preflight, release] + needs: [preflight, publish_cli, release] runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: main fetch-depth: 0 @@ -287,7 +332,7 @@ jobs: bun-version-file: package.json - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2021ecdcb..8b734a99b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ You can still open an issue or PR, but please do so knowing there is a high chan 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. -PRs are automatically labeled with a `vouch:*` trust status. +PRs are automatically labeled with a `vouch:*` trust status and a `size:*` diff size based on changed lines. If you are an external contributor, expect `vouch:unvouched` until we explicitly add you to [.github/VOUCHED.td](.github/VOUCHED.td). diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 001cebba1..0c00fed4e 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -51,7 +51,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.new`: create new terminal (in focused terminal context by default) - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state -- `chat.newLocal`: create a new local chat thread for the active project (no worktree context) +- `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor - `script.{id}.run`: run a project script by id (for example `script.test.run`) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 7db5ae204..213fca130 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -77,12 +77,13 @@ function startApp() { } }); - app.once("exit", () => { + app.once("exit", (code, signal) => { if (currentApp === app) { currentApp = null; } - if (!shuttingDown && !expectedExits.has(app)) { + const exitedAbnormally = signal !== null || code !== 0; + if (!shuttingDown && !expectedExits.has(app) && exitedAbnormally) { scheduleRestart(); } }); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 443492ada..460684929 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -535,7 +535,28 @@ function handleCheckForUpdatesMenuClick(): void { if (!BrowserWindow.getAllWindows().length) { mainWindow = createWindow(); } - void checkForUpdates("menu"); + void checkForUpdatesFromMenu(); +} + +async function checkForUpdatesFromMenu(): Promise { + await checkForUpdates("menu"); + + if (updateState.status === "up-to-date") { + void dialog.showMessageBox({ + type: "info", + title: "You're up to date!", + message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, + buttons: ["OK"], + }); + } else if (updateState.status === "error") { + void dialog.showMessageBox({ + type: "warning", + title: "Update check failed", + message: "Could not check for updates.", + detail: updateState.message ?? "An unknown error occurred. Please try again later.", + buttons: ["OK"], + }); + } } function configureApplicationMenu(): void { @@ -619,6 +640,7 @@ function configureApplicationMenu(): void { function resolveResourcePath(fileName: string): string | null { const candidates = [ Path.join(__dirname, "../resources", fileName), + Path.join(__dirname, "../prod-resources", fileName), Path.join(process.resourcesPath, "resources", fileName), Path.join(process.resourcesPath, fileName), ]; diff --git a/apps/marketing/package.json b/apps/marketing/package.json index da6b82d48..1763a0010 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -10,10 +10,10 @@ "typecheck": "astro check" }, "dependencies": { - "astro": "^5.7.13" + "astro": "^6.0.4" }, "devDependencies": { - "@astrojs/check": "^0.9.4", + "@astrojs/check": "^0.9.7", "typescript": "catalog:" } } diff --git a/apps/server/integration/fixtures/providerRuntime.ts b/apps/server/integration/fixtures/providerRuntime.ts index f56a587cb..42e7ecd87 100644 --- a/apps/server/integration/fixtures/providerRuntime.ts +++ b/apps/server/integration/fixtures/providerRuntime.ts @@ -73,7 +73,7 @@ export const codexTurnToolFixture = [ turnId: TURN_ID, payload: { itemType: "command_execution", - title: "Command run", + title: "Ran command", detail: "echo integration", }, }, @@ -85,7 +85,7 @@ export const codexTurnToolFixture = [ payload: { itemType: "command_execution", status: "completed", - title: "Command run", + title: "Ran command", detail: "echo integration", }, }, diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 379c44472..6c98229e8 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -97,7 +97,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => return { status: (input) => core.status(input), statusDetails: (cwd) => core.statusDetails(cwd), - prepareCommitContext: (cwd) => core.prepareCommitContext(cwd), + prepareCommitContext: (cwd, filePaths?) => core.prepareCommitContext(cwd, filePaths), commit: (cwd, subject, body) => core.commit(cwd, subject, body), pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch), pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd), @@ -1711,6 +1711,45 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("prepareCommitContext stages only selected files when filePaths provided", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); + yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); + + const context = yield* core.prepareCommitContext(tmp, ["a.txt"]); + expect(context).not.toBeNull(); + expect(context!.stagedSummary).toContain("a.txt"); + expect(context!.stagedSummary).not.toContain("b.txt"); + + yield* core.commit(tmp, "Add only a.txt", ""); + + // b.txt should still be untracked after commit + const statusAfter = yield* git(tmp, ["status", "--porcelain"]); + expect(statusAfter).toContain("b.txt"); + expect(statusAfter).not.toContain("a.txt"); + }), + ); + + it.effect("prepareCommitContext stages everything when filePaths is undefined", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); + yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); + + const context = yield* core.prepareCommitContext(tmp); + expect(context).not.toBeNull(); + expect(context!.stagedSummary).toContain("a.txt"); + expect(context!.stagedSummary).toContain("b.txt"); + }), + ); + it.effect("pushes with upstream setup and then skips when up to date", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 50a8e6913..004de1a58 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -798,9 +798,21 @@ const makeGitCore = Effect.gen(function* () { })), ); - const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd) => + const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) => Effect.gen(function* () { - yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); + if (filePaths && filePaths.length > 0) { + yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe( + Effect.catch(() => Effect.void), + ); + yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ + "add", + "-A", + "--", + ...filePaths, + ]); + } else { + yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); + } const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [ "diff", diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 1322d372b..f60dc7c10 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -461,6 +461,7 @@ function runStackedAction( | "amp" | "kilo"; model?: string; + filePaths?: readonly string[]; }, ) { return manager.runStackedAction(input); @@ -866,6 +867,31 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("commits only selected files when filePaths is provided", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n"); + fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n"); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + filePaths: ["a.txt"], + }); + + expect(result.commit.status).toBe("created"); + + // b.txt should remain in the working tree + const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe( + Effect.map((r) => r.stdout), + ); + expect(statusStdout).toContain("b.txt"); + expect(statusStdout).not.toContain("a.txt"); + }), + ); + it.effect("creates feature branch, commits, and pushes with featureBranch option", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 22d6a3327..9846f1579 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -660,9 +660,10 @@ export const makeGitManager = Effect.gen(function* () { provider?: ProviderKind | undefined; /** Provider model to use for text generation. */ model?: string | undefined; + filePaths?: readonly string[]; }) => Effect.gen(function* () { - const context = yield* gitCore.prepareCommitContext(input.cwd); + const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); if (!context) { return null; } @@ -707,6 +708,7 @@ export const makeGitManager = Effect.gen(function* () { preResolvedSuggestion?: CommitAndBranchSuggestion, provider?: ProviderKind | undefined, model?: string | undefined, + filePaths?: readonly string[], ) => Effect.gen(function* () { const suggestion = @@ -717,6 +719,7 @@ export const makeGitManager = Effect.gen(function* () { ...(commitMessage ? { commitMessage } : {}), provider, model, + ...(filePaths ? { filePaths } : {}), })); if (!suggestion) { return { status: "skipped_no_changes" as const }; @@ -1008,12 +1011,14 @@ export const makeGitManager = Effect.gen(function* () { commitMessage?: string, provider?: ProviderKind | undefined, model?: string | undefined, + filePaths?: readonly string[], ) => Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ cwd, branch, ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), includeBranch: true, provider, model, @@ -1066,6 +1071,7 @@ export const makeGitManager = Effect.gen(function* () { input.commitMessage, input.provider, input.model, + input.filePaths, ); branchStep = result.branchStep; commitMessageForStep = result.resolvedCommitMessage; @@ -1083,6 +1089,7 @@ export const makeGitManager = Effect.gen(function* () { preResolvedCommitSuggestion, input.provider, input.model, + input.filePaths, ); const push = wantsPush diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 502ac349d..879927934 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -101,6 +101,7 @@ export interface GitCoreShape { */ readonly prepareCommitContext: ( cwd: string, + filePaths?: readonly string[], ) => Effect.Effect; /** diff --git a/apps/server/src/kiloServerManager.ts b/apps/server/src/kiloServerManager.ts index 27a387a2b..95e9aeea7 100644 --- a/apps/server/src/kiloServerManager.ts +++ b/apps/server/src/kiloServerManager.ts @@ -367,6 +367,7 @@ type KiloDiscoveredModel = { slug: string; name: string; variants?: ReadonlyArray; + connected?: boolean; }; interface KiloManagerEvents { @@ -532,6 +533,7 @@ function modelOptionsFromProvider( providerId: string, providerName: string, model: KiloModel, + connected?: boolean, ): ReadonlyArray { const variantNames = Object.keys(model.variants ?? {}) .filter((variant) => variant.length > 0) @@ -541,6 +543,7 @@ function modelOptionsFromProvider( slug: `${providerId}/${model.id}`, name: `${providerName} / ${model.name}`, ...(variantNames.length > 0 ? { variants: variantNames } : {}), + ...(connected != null ? { connected } : {}), }, ]; } @@ -549,11 +552,18 @@ function parseProviderModels( providers: ReadonlyArray< Pick | KiloConfiguredProvider >, + connectedIds?: ReadonlySet, ): ReadonlyArray { - return providers.flatMap((provider) => { + const sorted = [...providers].sort((a, b) => { + const nameA = a.name || a.id; + const nameB = b.name || b.id; + return nameA.localeCompare(nameB); + }); + return sorted.flatMap((provider) => { const providerName = provider.name || provider.id; + const isConnected = connectedIds ? connectedIds.has(provider.id) : undefined; return Object.values(provider.models).flatMap((model) => - modelOptionsFromProvider(provider.id, providerName, model), + modelOptionsFromProvider(provider.id, providerName, model, isConnected), ); }); } @@ -1121,11 +1131,11 @@ export class KiloServerManager extends EventEmitter { const payload = readProviderListResponse( await client.provider.list(options?.workspace ? { workspace: options.workspace } : {}), ); - // Show models from all configured providers, not just connected ones. - // Connection status is a runtime concern — users want to pick from - // everything they've set up. Fall back to config.providers if the - // provider.list response has no entries at all. - const listed = parseProviderModels(payload.all); + // Show all configured providers, marking which ones are connected. + // Fall back to config.providers if the provider.list response has + // no entries at all. + const connectedIds = new Set(payload.connected); + const listed = parseProviderModels(payload.all, connectedIds); if (listed.length > 0) { return listed; } diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 84dff15f3..0e79d4aa5 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -1,8 +1,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; - -import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { assertSuccess } from "@effect/vitest/utils"; +import { FileSystem, Path, Effect } from "effect"; import { isCommandAvailable, @@ -10,14 +12,21 @@ import { resolveAvailableEditors, resolveEditorLaunch, } from "./open"; -import { Effect } from "effect"; -import { assertSuccess } from "@effect/vitest/utils"; -describe("resolveEditorLaunch", () => { +it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("returns commands for command-based editors", () => // Use "linux" to avoid macOS .app fallback logic, which depends on // whether the .app bundle happens to be installed on the test host. Effect.gen(function* () { + const antigravityLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "antigravity" }, + "darwin", + ); + assert.deepEqual(antigravityLaunch, { + command: "agy", + args: ["/tmp/workspace"], + }); + const cursorLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "cursor" }, "linux", @@ -233,7 +242,7 @@ describe("resolveEditorLaunch", () => { ); }); -describe("launchDetached", () => { +it.layer(NodeServices.layer)("launchDetached", (it) => { it.effect("resolves when command can be spawned", () => Effect.gen(function* () { const result = yield* launchDetached({ @@ -255,26 +264,20 @@ describe("launchDetached", () => { ); }); -describe("isCommandAvailable", () => { - function withTempDir(run: (dir: string) => void): void { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-open-")); - try { - run(dir); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - } - - it("resolves win32 commands with PATHEXT", () => { - withTempDir((dir) => { - fs.writeFileSync(path.join(dir, "code.CMD"), "@echo off\r\n", "utf8"); +it.layer(NodeServices.layer)("isCommandAvailable", (it) => { + it.effect("resolves win32 commands with PATHEXT", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); + yield* fs.writeFileString(path.join(dir, "code.CMD"), "@echo off\r\n"); const env = { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", } satisfies NodeJS.ProcessEnv; assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); - }); - }); + }), + ); it("returns false when a command is not on PATH", () => { const env = { @@ -284,55 +287,65 @@ describe("isCommandAvailable", () => { assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false); }); - it("does not treat bare files without executable extension as available on win32", () => { - withTempDir((dir) => { - fs.writeFileSync(path.join(dir, "npm"), "echo nope\r\n", "utf8"); + it.effect("does not treat bare files without executable extension as available on win32", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); + yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n"); const env = { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", } satisfies NodeJS.ProcessEnv; assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false); - }); - }); + }), + ); - it("appends PATHEXT for commands with non-executable extensions on win32", () => { - withTempDir((dir) => { - fs.writeFileSync(path.join(dir, "my.tool.CMD"), "@echo off\r\n", "utf8"); + it.effect("appends PATHEXT for commands with non-executable extensions on win32", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); + yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n"); const env = { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", } satisfies NodeJS.ProcessEnv; assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true); - }); - }); + }), + ); - it("uses platform-specific PATH delimiter for platform overrides", () => { - withTempDir((firstDir) => { - withTempDir((secondDir) => { - fs.writeFileSync(path.join(secondDir, "code.CMD"), "@echo off\r\n", "utf8"); - const env = { - PATH: `${firstDir};${secondDir}`, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); - }); - }); - }); + it.effect("uses platform-specific PATH delimiter for platform overrides", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); + const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); + yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ"); + const env = { + PATH: `${firstDir};${secondDir}`, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + } satisfies NodeJS.ProcessEnv; + assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); + }), + ); }); -describe("resolveAvailableEditors", () => { - it("returns only editors whose launch commands are available", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-editors-")); - try { - fs.writeFileSync(path.join(dir, "cursor.CMD"), "@echo off\r\n", "utf8"); - fs.writeFileSync(path.join(dir, "explorer.EXE"), "MZ", "utf8"); +it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { + it.effect("returns installed editors for command launches", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + + yield* fs.writeFileString(path.join(dir, "cursor.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); const editors = resolveAvailableEditors("win32", { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", }); assert.deepEqual(editors, ["cursor", "file-manager"]); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); + }), + ); }); diff --git a/apps/server/src/opencodeServerManager.ts b/apps/server/src/opencodeServerManager.ts index b1b6efff9..edc9c1e8d 100644 --- a/apps/server/src/opencodeServerManager.ts +++ b/apps/server/src/opencodeServerManager.ts @@ -405,6 +405,7 @@ type OpenCodeDiscoveredModel = { slug: string; name: string; variants?: ReadonlyArray; + connected?: boolean; }; interface OpenCodeManagerEvents { @@ -565,6 +566,7 @@ function modelOptionsFromProvider( providerId: string, providerName: string, model: OpenCodeModel, + connected?: boolean, ): ReadonlyArray { const variantNames = Object.keys(model.variants ?? {}) .filter((variant) => variant.length > 0) @@ -574,6 +576,7 @@ function modelOptionsFromProvider( slug: `${providerId}/${model.id}`, name: `${providerName} / ${model.name}`, ...(variantNames.length > 0 ? { variants: variantNames } : {}), + ...(connected != null ? { connected } : {}), }, ]; } @@ -582,11 +585,18 @@ function parseProviderModels( providers: ReadonlyArray< Pick | OpenCodeConfiguredProvider >, + connectedIds?: ReadonlySet, ): ReadonlyArray { - return providers.flatMap((provider) => { + const sorted = [...providers].sort((a, b) => { + const nameA = a.name || a.id; + const nameB = b.name || b.id; + return nameA.localeCompare(nameB); + }); + return sorted.flatMap((provider) => { const providerName = provider.name || provider.id; + const isConnected = connectedIds ? connectedIds.has(provider.id) : undefined; return Object.values(provider.models).flatMap((model) => - modelOptionsFromProvider(provider.id, providerName, model), + modelOptionsFromProvider(provider.id, providerName, model, isConnected), ); }); } @@ -1183,11 +1193,11 @@ export class OpenCodeServerManager extends EventEmitter { client.provider.list(options?.workspace ? { workspace: options.workspace } : {}), ), ); - // Show models from all configured providers, not just connected ones. - // Connection status is a runtime concern — users want to pick from - // everything they've set up. Fall back to config.providers if the - // provider.list response has no entries at all. - const listed = parseProviderModels(payload.all); + // Show all configured providers, marking which ones are connected. + // Fall back to config.providers if the provider.list response has + // no entries at all. + const connectedIds = new Set(payload.connected); + const listed = parseProviderModels(payload.all, connectedIds); if (listed.length > 0) { return listed; } @@ -1273,13 +1283,6 @@ export class OpenCodeServerManager extends EventEmitter { }); const startedBaseUrl = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject( - new Error( - `Timed out waiting for OpenCode server to start after ${SERVER_START_TIMEOUT_MS}ms`, - ), - ); - }, SERVER_START_TIMEOUT_MS); let output = ""; const onChunk = (chunk: Buffer) => { @@ -1288,18 +1291,17 @@ export class OpenCodeServerManager extends EventEmitter { if (!url) { return; } - clearTimeout(timeout); + cleanup(); resolve(url); }; - child.stdout.on("data", onChunk); - child.stderr.on("data", onChunk); - child.once("error", (error) => { - clearTimeout(timeout); + const onError = (error: Error) => { + cleanup(); reject(error); - }); - child.once("exit", (code) => { - clearTimeout(timeout); + }; + + const onExit = (code: number | null) => { + cleanup(); void probeServer(baseUrl, authHeader).then((reuse) => { if (reuse) { resolve(baseUrl); @@ -1314,7 +1316,34 @@ export class OpenCodeServerManager extends EventEmitter { ), ); }); - }); + }; + + const cleanup = () => { + clearTimeout(timeout); + child.stdout.off("data", onChunk); + child.stderr.off("data", onChunk); + child.off("error", onError); + child.off("exit", onExit); + }; + + const timeout = setTimeout(() => { + cleanup(); + try { + child.kill(); + } catch { + // Process may already be dead. + } + reject( + new Error( + `Timed out waiting for OpenCode server to start after ${SERVER_START_TIMEOUT_MS}ms`, + ), + ); + }, SERVER_START_TIMEOUT_MS); + + child.stdout.on("data", onChunk); + child.stderr.on("data", onChunk); + child.once("error", onError); + child.once("exit", onExit); }); const shared = { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index c6681542f..adeb3f846 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -5,6 +5,7 @@ import { MessageId, type OrchestrationEvent, CheckpointRef, + isToolLifecycleItemType, ThreadId, TurnId, type OrchestrationThreadActivity, @@ -187,18 +188,6 @@ function requestKindFromCanonicalRequestType( } } -function isToolLifecycleItemType(itemType: string): boolean { - return ( - itemType === "command_execution" || - itemType === "file_change" || - itemType === "mcp_tool_call" || - itemType === "dynamic_tool_call" || - itemType === "collab_agent_tool_call" || - itemType === "web_search" || - itemType === "image_view" - ); -} - function runtimeEventToActivities( event: ProviderRuntimeEvent, ): ReadonlyArray { @@ -463,7 +452,7 @@ function runtimeEventToActivities( createdAt: event.createdAt, tone: "tool", kind: "tool.completed", - summary: `${event.payload.title ?? "Tool"} complete`, + summary: event.payload.title ?? "Tool", payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index d70988054..1a7e22c77 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -161,7 +161,7 @@ function itemTitle(itemType: CanonicalItemType): string | undefined { case "plan": return "Plan"; case "command_execution": - return "Command run"; + return "Ran command"; case "file_change": return "File change"; case "mcp_tool_call": diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 2e280df60..5152c0884 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -739,7 +739,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { threadId: session.threadId, turnId: asTurnId("turn-1"), toolKind: "command", - title: "Command run", + title: "Ran command", }); fanout.codex.emit({ type: "tool.completed", @@ -749,7 +749,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { threadId: session.threadId, turnId: asTurnId("turn-1"), toolKind: "command", - title: "Command run", + title: "Ran command", }); fanout.codex.emit({ type: "turn.completed", @@ -804,7 +804,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { threadId: session.threadId, turnId: asTurnId("turn-1"), toolKind: "command", - title: "Command run", + title: "Ran command", detail: "echo one", }, { diff --git a/apps/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts index ca8435336..d867ad910 100644 --- a/apps/server/src/workspaceEntries.test.ts +++ b/apps/server/src/workspaceEntries.test.ts @@ -70,6 +70,32 @@ describe("searchWorkspaceEntries", () => { assert.isTrue(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))); }); + it("supports fuzzy subsequence queries for composer path search", async () => { + const cwd = makeTempDir("t3code-workspace-fuzzy-query-"); + writeFile(cwd, "src/components/Composer.tsx"); + writeFile(cwd, "src/components/composePrompt.ts"); + writeFile(cwd, "docs/composition.md"); + + const result = await searchWorkspaceEntries({ cwd, query: "cmp", limit: 10 }); + const paths = result.entries.map((entry) => entry.path); + + assert.isAbove(result.entries.length, 0); + assert.include(paths, "src/components"); + assert.include(paths, "src/components/Composer.tsx"); + }); + + it("tracks truncation without sorting every fuzzy match", async () => { + const cwd = makeTempDir("t3code-workspace-fuzzy-limit-"); + writeFile(cwd, "src/components/Composer.tsx"); + writeFile(cwd, "src/components/composePrompt.ts"); + writeFile(cwd, "docs/composition.md"); + + const result = await searchWorkspaceEntries({ cwd, query: "cmp", limit: 1 }); + + assert.lengthOf(result.entries, 1); + assert.isTrue(result.truncated); + }); + it("excludes gitignored paths for git repositories", async () => { const cwd = makeTempDir("t3code-workspace-gitignore-"); runGit(cwd, ["init"]); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index 4af8e16a0..be20099db 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -28,10 +28,20 @@ const IGNORED_DIRECTORY_NAMES = new Set([ interface WorkspaceIndex { scannedAt: number; - entries: ProjectEntry[]; + entries: SearchableWorkspaceEntry[]; truncated: boolean; } +interface SearchableWorkspaceEntry extends ProjectEntry { + normalizedPath: string; + normalizedName: string; +} + +interface RankedWorkspaceEntry { + entry: SearchableWorkspaceEntry; + score: number; +} + const workspaceIndexCache = new Map(); const inFlightWorkspaceIndexBuilds = new Map>(); @@ -55,6 +65,15 @@ function basenameOf(input: string): string { return input.slice(separatorIndex + 1); } +function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry { + const normalizedPath = entry.path.toLowerCase(); + return { + ...entry, + normalizedPath, + normalizedName: basenameOf(normalizedPath), + }; +} + function normalizeQuery(input: string): string { return input .trim() @@ -300,20 +319,26 @@ async function buildWorkspaceIndexFromGit(cwd: string): Promise left.localeCompare(right)) - .map((directoryPath) => ({ - path: directoryPath, - kind: "directory", - parentPath: parentPathOf(directoryPath), - })); - const fileEntries: ProjectEntry[] = [...new Set(filePaths)] + .map( + (directoryPath): ProjectEntry => ({ + path: directoryPath, + kind: "directory", + parentPath: parentPathOf(directoryPath), + }), + ) + .map(toSearchableWorkspaceEntry); + const fileEntries = [...new Set(filePaths)] .toSorted((left, right) => left.localeCompare(right)) - .map((filePath) => ({ - path: filePath, - kind: "file", - parentPath: parentPathOf(filePath), - })); + .map( + (filePath): ProjectEntry => ({ + path: filePath, + kind: "file", + parentPath: parentPathOf(filePath), + }), + ) + .map(toSearchableWorkspaceEntry); const entries = [...directoryEntries, ...fileEntries]; return { @@ -331,7 +356,7 @@ async function buildWorkspaceIndex(cwd: string): Promise { const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd); let pendingDirectories: string[] = [""]; - const entries: ProjectEntry[] = []; + const entries: SearchableWorkspaceEntry[] = []; let truncated = false; while (pendingDirectories.length > 0 && !truncated) { @@ -398,11 +423,11 @@ async function buildWorkspaceIndex(cwd: string): Promise { continue; } - const entry: ProjectEntry = { + const entry = toSearchableWorkspaceEntry({ path: candidate.relativePath, kind: candidate.dirent.isDirectory() ? "directory" : "file", parentPath: parentPathOf(candidate.relativePath), - }; + }); entries.push(entry); if (candidate.dirent.isDirectory()) { diff --git a/apps/web/package.json b/apps/web/package.json index 26fc86534..b4c7f8042 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,18 +43,20 @@ }, "devDependencies": { "@effect/language-service": "catalog:", + "@rolldown/plugin-babel": "^0.2.0", "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", + "@types/babel__core": "^7.20.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", "msw": "^2.12.10", "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "catalog:", - "vite": "^8.0.0-beta.12", + "vite": "^8.0.0", "vitest": "catalog:", "vitest-browser-react": "^2.0.5" } diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 005d01106..cb28d3c76 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - getAppSettingsSnapshot, + DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, - getSlashModelOptions, + getAppSettingsSnapshot, normalizeCustomModelSlugs, resolveAppModelSelection, } from "./appSettings"; @@ -133,17 +133,9 @@ describe("resolveAppModelSelection", () => { }); }); -describe("getSlashModelOptions", () => { - it("includes saved custom model slugs for /model command suggestions", () => { - const options = getSlashModelOptions("codex", ["custom/internal-model"], "", "gpt-5.3-codex"); - - expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true); - }); - - it("filters slash-model suggestions across built-in and custom model names", () => { - const options = getSlashModelOptions("codex", ["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex"); - - expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); +describe("timestamp format defaults", () => { + it("defaults timestamp format to locale", () => { + expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); }); it("includes provider-specific custom slugs in non-codex model lists", () => { diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index d51cfbab9..7e515beea 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,8 +1,9 @@ -import { useCallback, useSyncExternalStore } from "react"; +import { useCallback } from "react"; import { Option, Schema } from "effect"; import { type ProviderKind } from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { DEFAULT_ACCENT_COLOR, isValidAccentColor, normalizeAccentColor } from "./accentColor"; +import { useLocalStorage } from "./hooks/useLocalStorage"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; @@ -27,6 +28,9 @@ export const APP_PROVIDER_LOGO_APPEARANCE_OPTIONS = [ export type AppProviderLogoAppearance = (typeof APP_PROVIDER_LOGO_APPEARANCE_OPTIONS)[number]["value"]; const AppProviderLogoAppearanceSchema = Schema.Literals(["original", "grayscale", "accent"]); +export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; +export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; +export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), copilot: new Set(getModelOptions("copilot").map((option) => option.slug)), @@ -51,12 +55,18 @@ const AppSettingsSchema = Schema.Struct({ copilotConfigDir: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), ), + defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( + Schema.withConstructorDefault(() => Option.some("local")), + ), confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), showCommandOutput: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), showFileChangeDiffs: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), + timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe( + Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)), + ), customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), @@ -103,10 +113,6 @@ export interface AppModelOption { const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); -let listeners: Array<() => void> = []; -let cachedRawSettings: string | null | undefined; -let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; - export function normalizeCustomModelSlugs( models: Iterable, provider: ProviderKind = "codex", @@ -244,6 +250,10 @@ export function getSlashModelOptions( }); } +let listeners: Array<() => void> = []; +let cachedRawSettings: string | null = null; +let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; + function emitChange(): void { for (const listener of listeners) { listener(); @@ -326,27 +336,25 @@ function subscribe(listener: () => void): () => void { } export function useAppSettings() { - const settings = useSyncExternalStore( - subscribe, - getAppSettingsSnapshot, - () => DEFAULT_APP_SETTINGS, + const [settings, setSettings] = useLocalStorage( + APP_SETTINGS_STORAGE_KEY, + DEFAULT_APP_SETTINGS, + AppSettingsSchema, ); - const updateSettings = useCallback((patch: Partial) => { - const next = normalizeAppSettings( - Schema.decodeSync(AppSettingsSchema)({ - ...getAppSettingsSnapshot(), + const updateSettings = useCallback( + (patch: Partial) => { + setSettings((prev) => ({ + ...prev, ...patch, - }), - ); - persistSettings(next); - emitChange(); - }, []); + })); + }, + [setSettings], + ); const resetSettings = useCallback(() => { - persistSettings(DEFAULT_APP_SETTINGS); - emitChange(); - }, []); + setSettings(DEFAULT_APP_SETTINGS); + }, [setSettings]); return { settings, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index b2c60b242..79c453c0f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,4 +1,5 @@ import type { ThreadId } from "@t3tools/contracts"; +import { FolderIcon, GitForkIcon } from "lucide-react"; import { useCallback } from "react"; import { newCommandId } from "../lib/utils"; @@ -11,7 +12,12 @@ import { resolveEffectiveEnvMode, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; -import { Button } from "./ui/button"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; + +const envModeItems = [ + { value: "local", label: "Local" }, + { value: "worktree", label: "New worktree" }, +] as const; interface BranchToolbarProps { threadId: ThreadId; @@ -104,23 +110,50 @@ export default function BranchToolbar({ return (
-
- {envLocked || activeWorktreePath ? ( - - {activeWorktreePath ? "Worktree" : "Local"} - - ) : ( - - )} -
+ {envLocked || activeWorktreePath ? ( + + {activeWorktreePath ? ( + <> + + Worktree + + ) : ( + <> + + Local + + )} + + ) : ( + + )} { try { return highlighter.codeToHtml(code, { lang: language, theme: themeName }); - } catch { + } catch (error) { + // Log highlighting failures for debugging while falling back to plain text + console.warn( + `Code highlighting failed for language "${language}", falling back to plain text.`, + error instanceof Error ? error.message : error, + ); // If highlighting fails for this language, render as plain text return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); } @@ -254,7 +255,9 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { event.stopPropagation(); const api = readNativeApi(); if (api) { - void api.shell.openInEditor(targetPath, preferredTerminalEditor()); + openInPreferredEditor(api, targetPath).catch((error) => { + console.warn("Unable to open file in preferred editor.", error); + }); } else { console.warn("Native API not found. Unable to open file in editor."); } @@ -262,6 +265,60 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { /> ); }, + code({ node: _node, className, children, ...props }) { + // Only transform inline code (fenced code blocks have language classes + // and are handled by the `pre` override). + if (className) { + return ( + + {children} + + ); + } + + const text = typeof children === "string" ? children : nodeToPlainText(children); + const targetPath = resolveMarkdownFileLinkTarget(text.trim(), cwd); + + if (!targetPath) { + return {children}; + } + + // Strip :line:col suffix — OS default apps don't understand them. + const pathForOpen = targetPath.replace(/:\d+(?::\d+)?$/, ""); + + return ( + { + event.preventDefault(); + event.stopPropagation(); + const api = readNativeApi(); + if (api) { + api.shell.openInEditor(pathForOpen, "file-manager").catch((error) => { + console.warn("Unable to open in file manager.", error); + }); + } + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + const api = readNativeApi(); + if (api) { + api.shell.openInEditor(pathForOpen, "file-manager").catch((error) => { + console.warn("Unable to open in file manager.", error); + }); + } + } + }} + > + {children} + + ); + }, pre({ node: _node, children, ...props }) { const codeBlock = extractCodeBlock(children); if (!codeBlock) { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d8b74c8cc..faecc7f51 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -11,6 +11,7 @@ import { type WsWelcomePayload, WS_CHANNELS, WS_METHODS, + OrchestrationSessionStatus, } from "@t3tools/contracts"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; @@ -20,6 +21,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; +import { isMacPlatform } from "../lib/utils"; import { getRouter } from "../router"; import { useStore } from "../store"; import { estimateTimelineMessageHeight } from "./timelineHeight"; @@ -152,6 +154,7 @@ function createSnapshotForTargetUser(options: { targetMessageId: MessageId; targetText: string; targetAttachmentCount?: number; + sessionStatus?: OrchestrationSessionStatus; }): OrchestrationReadModel { const messages: Array = []; @@ -221,7 +224,7 @@ function createSnapshotForTargetUser(options: { checkpoints: [], session: { threadId: THREAD_ID, - status: "ready", + status: options.sessionStatus ?? "ready", providerName: "codex", runtimeMode: "full-access", activeTurnId: null, @@ -353,7 +356,8 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } -function resolveWsRpc(tag: string): unknown { +function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { + const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; } @@ -395,6 +399,19 @@ function resolveWsRpc(tag: string): unknown { truncated: false, }; } + if (tag === WS_METHODS.terminalOpen) { + return { + threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, + terminalId: typeof body.terminalId === "string" ? body.terminalId : "default", + cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project", + status: "running", + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: NOW_ISO, + }; + } return {}; } @@ -423,7 +440,7 @@ const worker = setupWorker( client.send( JSON.stringify({ id: request.id, - result: resolveWsRpc(method), + result: resolveWsRpc(request.body), }), ); }); @@ -994,6 +1011,28 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("shows a pointer cursor for the running stop button", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-stop-button-cursor" as MessageId, + targetText: "stop button cursor target", + sessionStatus: "running", + }), + }); + + try { + const stopButton = await waitForElement( + () => document.querySelector('button[aria-label="Stop generation"]'), + "Unable to find stop generation button.", + ); + + expect(getComputedStyle(stopButton).cursor).toBe("pointer"); + } finally { + await mounted.cleanup(); + } + }); + it("keeps the new thread selected after clicking the new-thread button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -1048,6 +1087,130 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("creates a new thread from the global chat.new shortcut", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-chat-shortcut-test" as MessageId, + targetText: "chat shortcut test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "chat.new", + shortcut: { + key: "o", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "o", + shiftKey: true, + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID from the shortcut.", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("creates a fresh draft after the previous draft thread is promoted", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-promoted-draft-shortcut-test" as MessageId, + targetText: "promoted draft shortcut test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "chat.new", + shortcut: { + key: "o", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + await newThreadButton.click(); + + const promotedThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a promoted draft thread UUID.", + ); + const promotedThreadId = promotedThreadPath.slice(1) as ThreadId; + + const { syncServerReadModel } = useStore.getState(); + syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId)); + useComposerDraftStore.getState().clearDraftThread(promotedThreadId); + + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "o", + shiftKey: true, + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + const freshThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath, + "Shortcut should create a fresh draft instead of reusing the promoted thread.", + ); + expect(freshThreadPath).not.toBe(promotedThreadPath); + } finally { + await mounted.cleanup(); + } + }); + it("keeps long proposed plans lightweight until the user expands them", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index f413975aa..60f734356 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,28 +1,13 @@ -import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { Schema } from "effect"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const WORKTREE_BRANCH_PREFIX = "t3code"; -export function readLastInvokedScriptByProjectFromStorage(): Record { - const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); - if (!stored) return {}; - - try { - const parsed: unknown = JSON.parse(stored); - if (!parsed || typeof parsed !== "object") return {}; - return Object.fromEntries( - Object.entries(parsed).filter( - (entry): entry is [string, string] => - typeof entry[0] === "string" && typeof entry[1] === "string", - ), - ); - } catch { - return {}; - } -} +export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); export function buildLocalDraftThread( threadId: ThreadId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ae582e3ae..b5d91e1bb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -47,7 +47,9 @@ import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { resolveDraftThreadDefaults } from "../lib/threadDraftDefaults"; import { + clampCollapsedComposerCursor, type ComposerTrigger, + collapseExpandedComposerCursor, detectComposerTrigger, expandCollapsedComposerCursor, parseStandaloneComposerSlashCommand, @@ -87,7 +89,7 @@ import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, - MAX_THREAD_TERMINAL_COUNT, + MAX_TERMINALS_PER_GROUP, type ChatMessage, type TurnDiffSummary, } from "../types"; @@ -128,6 +130,7 @@ import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { resolveAppModelSelection, useAppSettings } from "../appSettings"; +import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -164,13 +167,14 @@ import { cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + LastInvokedScriptByProjectSchema, PullRequestDialogState, readFileAsDataUrl, - readLastInvokedScriptByProjectFromStorage, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, SendPhase, } from "./ChatView.logic"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -186,6 +190,17 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +const extendReplacementRangeForTrailingSpace = ( + text: string, + rangeEnd: number, + replacement: string, +): number => { + if (!replacement.endsWith(" ")) { + return rangeEnd; + } + return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; +}; + interface ChatViewProps { threadId: ThreadId; } @@ -198,6 +213,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); + const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, @@ -282,13 +298,17 @@ export default function ChatView({ threadId }: ChatViewProps) { const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); - const [composerCursor, setComposerCursor] = useState(() => prompt.length); + const [composerCursor, setComposerCursor] = useState(() => + collapseExpandedComposerCursor(prompt, prompt.length), + ); const [composerTrigger, setComposerTrigger] = useState(() => detectComposerTrigger(prompt, prompt.length), ); - const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< - Record - >(() => readLastInvokedScriptByProjectFromStorage()); + const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( + LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + {}, + LastInvokedScriptByProjectSchema, + ); const messagesScrollRef = useRef(null); const [messagesScrollElement, setMessagesScrollElement] = useState(null); const shouldAutoScrollRef = useRef(true); @@ -753,23 +773,47 @@ export default function ChatView({ threadId }: ChatViewProps) { pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + const lastSyncedPendingInputRef = useRef<{ + requestId: string | null; + questionId: string | null; + } | null>(null); useEffect(() => { - if (!activePendingProgress) { + const nextCustomAnswer = activePendingProgress?.customAnswer; + if (typeof nextCustomAnswer !== "string") { + lastSyncedPendingInputRef.current = null; return; } - promptRef.current = activePendingProgress.customAnswer; - setComposerCursor(activePendingProgress.customAnswer.length); + const nextRequestId = activePendingUserInput?.requestId ?? null; + const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; + const questionChanged = + lastSyncedPendingInputRef.current?.requestId !== nextRequestId || + lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; + const textChangedExternally = promptRef.current !== nextCustomAnswer; + + lastSyncedPendingInputRef.current = { + requestId: nextRequestId, + questionId: nextQuestionId, + }; + + if (!questionChanged && !textChangedExternally) { + return; + } + + promptRef.current = nextCustomAnswer; + const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); + setComposerCursor(nextCursor); setComposerTrigger( detectComposerTrigger( - activePendingProgress.customAnswer, - expandCollapsedComposerCursor( - activePendingProgress.customAnswer, - activePendingProgress.customAnswer.length, - ), + nextCustomAnswer, + expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), ), ); setComposerHighlightedItemId(null); - }, [activePendingProgress, activePendingUserInput?.requestId]); + }, [ + activePendingProgress?.customAnswer, + activePendingUserInput?.requestId, + activePendingProgress?.activeQuestion?.id, + ]); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); @@ -1123,7 +1167,7 @@ export default function ChatView({ threadId }: ChatViewProps) { replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); - return diffOpen ? rest : { ...rest, diff: "1" }; + return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; }, }); }, [diffOpen, navigate, threadId]); @@ -1133,7 +1177,16 @@ export default function ChatView({ threadId }: ChatViewProps) { (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); - const hasReachedTerminalLimit = terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT; + const activeTerminalGroup = + terminalState.terminalGroups.find( + (group) => group.id === terminalState.activeTerminalGroupId, + ) ?? + terminalState.terminalGroups.find((group) => + group.terminalIds.includes(terminalState.activeTerminalId), + ) ?? + null; + const hasReachedSplitLimit = + (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; @@ -1181,17 +1234,17 @@ export default function ChatView({ threadId }: ChatViewProps) { setTerminalOpen(!terminalState.terminalOpen); }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); const splitTerminal = useCallback(() => { - if (!activeThreadId || hasReachedTerminalLimit) return; + if (!activeThreadId || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; storeSplitTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, storeSplitTerminal, hasReachedTerminalLimit]); + }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]); const createNewTerminal = useCallback(() => { - if (!activeThreadId || hasReachedTerminalLimit) return; + if (!activeThreadId) return; const terminalId = `terminal-${randomUUID()}`; storeNewTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, storeNewTerminal, hasReachedTerminalLimit]); + }, [activeThreadId, storeNewTerminal]); const activateTerminal = useCallback( (terminalId: string) => { if (!activeThreadId) return; @@ -1258,8 +1311,7 @@ export default function ChatView({ threadId }: ChatViewProps) { DEFAULT_THREAD_TERMINAL_ID; const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; - const shouldCreateNewTerminal = - wantsNewTerminal && terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; + const shouldCreateNewTerminal = wantsNewTerminal; const targetTerminalId = shouldCreateNewTerminal ? `terminal-${randomUUID()}` : baseTerminalId; @@ -1319,6 +1371,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setThreadError, storeNewTerminal, storeSetActiveTerminal, + setLastInvokedScriptByProjectId, terminalState.activeTerminalId, terminalState.runningTerminalIds, terminalState.terminalIds, @@ -1561,21 +1614,6 @@ export default function ChatView({ threadId }: ChatViewProps) { [serverThread], ); - useEffect(() => { - try { - if (Object.keys(lastInvokedScriptByProjectId).length === 0) { - localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); - return; - } - localStorage.setItem( - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - JSON.stringify(lastInvokedScriptByProjectId), - ); - } catch { - // Ignore storage write failures (private mode, quota exceeded, etc.) - } - }, [lastInvokedScriptByProjectId]); - // Auto-scroll on new messages const messageCount = timelineMessages.length; const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { @@ -1843,7 +1881,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { promptRef.current = prompt; - setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length)); + setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); }, [prompt]); useEffect(() => { @@ -1856,7 +1894,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setSendPhase("idle"); setSendStartedAt(null); setComposerHighlightedItemId(null); - setComposerCursor(promptRef.current.length); + setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); dragDepthRef.current = 0; setIsDragOverComposer(false); @@ -2048,13 +2086,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThreadId, focusComposer, terminalState.terminalOpen]); useEffect(() => { - const isTerminalFocused = (): boolean => { - const activeElement = document.activeElement; - if (!(activeElement instanceof HTMLElement)) return false; - if (activeElement.classList.contains("xterm-helper-textarea")) return true; - return activeElement.closest(".thread-terminal-drawer .xterm") !== null; - }; - const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || event.defaultPrevented) return; const shortcutContext = { @@ -2137,6 +2168,14 @@ export default function ChatView({ threadId }: ChatViewProps) { const addComposerImages = (files: File[]) => { if (!activeThreadId || files.length === 0) return; + if (pendingUserInputs.length > 0) { + toastManager.add({ + type: "error", + title: "Attach images after answering plan questions.", + }); + return; + } + const nextImages: ComposerImageAttachment[] = []; let nextImageCount = composerImagesRef.current.length; let error: string | null = null; @@ -2541,7 +2580,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); promptRef.current = trimmed; setPrompt(trimmed); - setComposerCursor(trimmed.length); + setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length)); addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); } @@ -2659,7 +2698,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const onChangeActivePendingUserInputCustomAnswer = useCallback( - (questionId: string, value: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + ( + questionId: string, + value: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + ) => { if (!activePendingUserInput) { return; } @@ -2676,9 +2721,7 @@ export default function ChatView({ threadId }: ChatViewProps) { })); setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention - ? null - : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)), + cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), ); }, [activePendingUserInput], @@ -3063,6 +3106,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return false; } const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); + const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { @@ -3079,10 +3123,12 @@ export default function ChatView({ threadId }: ChatViewProps) { } else { setPrompt(next.text); } - setComposerCursor(next.cursor); - setComposerTrigger(detectComposerTrigger(next.text, next.cursor)); + setComposerCursor(nextCursor); + setComposerTrigger( + detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), + ); window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(next.cursor); + composerEditorRef.current?.focusAt(nextCursor); }); return true; }, @@ -3092,23 +3138,27 @@ export default function ChatView({ threadId }: ChatViewProps) { const readComposerSnapshot = useCallback((): { value: string; cursor: number; + expandedCursor: number; } => { const editorSnapshot = composerEditorRef.current?.readSnapshot(); if (editorSnapshot) { return editorSnapshot; } - return { value: promptRef.current, cursor: composerCursor }; + return { + value: promptRef.current, + cursor: composerCursor, + expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + }; }, [composerCursor]); const resolveActiveComposerTrigger = useCallback((): { - snapshot: { value: string; cursor: number }; + snapshot: { value: string; cursor: number; expandedCursor: number }; trigger: ComposerTrigger | null; } => { const snapshot = readComposerSnapshot(); - const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor); return { snapshot, - trigger: detectComposerTrigger(snapshot.value, expandedCursor), + trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), }; }, [readComposerSnapshot]); @@ -3121,13 +3171,18 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; - const expectedToken = snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd); if (item.type === "path") { + const replacement = `@${item.path} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); const applied = applyPromptReplacement( trigger.rangeStart, - trigger.rangeEnd, - `@${item.path} `, - { expectedText: expectedToken }, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, ); if (applied) { setComposerHighlightedItemId(null); @@ -3136,9 +3191,18 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (item.type === "slash-command") { if (item.command === "model") { - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", { - expectedText: expectedToken, - }); + const replacement = "/model "; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); if (applied) { setComposerHighlightedItemId(null); } @@ -3146,7 +3210,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: expectedToken, + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), }); if (applied) { setComposerHighlightedItemId(null); @@ -3155,7 +3219,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } onProviderModelSelect(item.provider, item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: expectedToken, + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), }); if (applied) { setComposerHighlightedItemId(null); @@ -3196,12 +3260,18 @@ export default function ChatView({ threadId }: ChatViewProps) { workspaceEntriesQuery.isFetching); const onPromptChange = useCallback( - (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + ( + nextPrompt: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + ) => { if (activePendingProgress?.activeQuestion && activePendingUserInput) { onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, nextPrompt, nextCursor, + expandedCursor, cursorAdjacentToMention, ); return; @@ -3210,12 +3280,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setPrompt(nextPrompt); setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention - ? null - : detectComposerTrigger( - nextPrompt, - expandCollapsedComposerCursor(nextPrompt, nextCursor), - ), + cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), ); }, [ @@ -3402,6 +3467,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} + timestampFormat={timestampFormat} workspaceRoot={activeProject?.cwd ?? undefined} />
@@ -3804,7 +3870,7 @@ export default function ChatView({ threadId }: ChatViewProps) { + )} + + {!gitStatusForActions || allFiles.length === 0 ? (

none

) : (
- {gitStatusForActions.workingTree.files.map((file) => ( - - ))} + {allFiles.map((file) => { + const isExcluded = excludedFiles.has(file.path); + return ( +
+ {isEditingFiles && ( + { + setExcludedFiles((prev) => { + const next = new Set(prev); + if (next.has(file.path)) { + next.delete(file.path); + } else { + next.add(file.path); + } + return next; + }); + }} + /> + )} + +
+ ); + })}
- +{gitStatusForActions.workingTree.insertions} + +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / - -{gitStatusForActions.workingTree.deletions} + -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
@@ -816,14 +915,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions onClick={() => { setIsCommitDialogOpen(false); setDialogCommitMessage(""); + setExcludedFiles(new Set()); + setIsEditingFiles(false); }} > Cancel - - diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 6f0b2babf..50ab6372d 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -373,6 +373,15 @@ export const AmpIcon: Icon = ({ monochrome, ...props }) => ( ); +const ANTIGRAVITY_ICON_DATA_URL = + "data:image/png;base64,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"; + +export const AntigravityIcon: Icon = (props) => ( + + + +); + export const OpenCodeIcon: Icon = ({ monochrome, ...props }) => ( diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index c465f3989..47bee930c 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,4 +1,5 @@ import { memo, useState, useCallback } from "react"; +import { type TimestampFormat } from "../appSettings"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { ScrollArea } from "./ui/scroll-area"; @@ -12,9 +13,9 @@ import { PanelRightCloseIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; -import { formatTimestamp } from "../session-logic"; import type { ActivePlanState } from "../session-logic"; import type { LatestProposedPlanState } from "../session-logic"; +import { formatTimestamp } from "../timestampFormat"; import { proposedPlanTitle, buildProposedPlanMarkdownFilename, @@ -25,6 +26,7 @@ import { import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { readNativeApi } from "~/nativeApi"; import { toastManager } from "./ui/toast"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; function stepStatusIcon(status: string): React.ReactNode { if (status === "completed") { @@ -53,6 +55,7 @@ interface PlanSidebarProps { activeProposedPlan: LatestProposedPlanState | null; markdownCwd: string | undefined; workspaceRoot: string | undefined; + timestampFormat: TimestampFormat; onClose: () => void; } @@ -61,11 +64,12 @@ const PlanSidebar = memo(function PlanSidebar({ activeProposedPlan, markdownCwd, workspaceRoot, + timestampFormat, onClose, }: PlanSidebarProps) { const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); - const [copied, setCopied] = useState(false); + const { copyToClipboard, isCopied } = useCopyToClipboard(); const planMarkdown = activeProposedPlan?.planMarkdown ?? null; const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; @@ -73,10 +77,8 @@ const PlanSidebar = memo(function PlanSidebar({ const handleCopyPlan = useCallback(() => { if (!planMarkdown) return; - void navigator.clipboard.writeText(planMarkdown); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [planMarkdown]); + copyToClipboard(planMarkdown); + }, [planMarkdown, copyToClipboard]); const handleDownload = useCallback(() => { if (!planMarkdown) return; @@ -128,7 +130,7 @@ const PlanSidebar = memo(function PlanSidebar({ {activePlan ? ( - {formatTimestamp(activePlan.createdAt)} + {formatTimestamp(activePlan.createdAt, timestampFormat)} ) : null} @@ -149,7 +151,7 @@ const PlanSidebar = memo(function PlanSidebar({ - {copied ? "Copied!" : "Copy to clipboard"} + {isCopied ? "Copied!" : "Copy to clipboard"} Download as markdown { }); }); +describe("resolveSidebarNewThreadEnvMode", () => { + it("uses the app default when the caller does not request a specific mode", () => { + expect( + resolveSidebarNewThreadEnvMode({ + defaultEnvMode: "worktree", + }), + ).toBe("worktree"); + }); + + it("preserves an explicit requested mode over the app default", () => { + expect( + resolveSidebarNewThreadEnvMode({ + requestedEnvMode: "local", + defaultEnvMode: "worktree", + }), + ).toBe("local"); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { interactionMode: "plan" as const, @@ -154,3 +175,27 @@ describe("resolveThreadStatusPill", () => { ).toMatchObject({ label: "Completed", pulse: false }); }); }); + +describe("resolveThreadRowClassName", () => { + it("uses the darker selected palette when a thread is both selected and active", () => { + const className = resolveThreadRowClassName({ isActive: true, isSelected: true }); + expect(className).toContain("bg-primary/22"); + expect(className).toContain("hover:bg-primary/26"); + expect(className).toContain("dark:bg-primary/30"); + expect(className).not.toContain("bg-accent/85"); + }); + + it("uses selected hover colors for selected threads", () => { + const className = resolveThreadRowClassName({ isActive: false, isSelected: true }); + expect(className).toContain("bg-primary/15"); + expect(className).toContain("hover:bg-primary/19"); + expect(className).toContain("dark:bg-primary/22"); + expect(className).not.toContain("hover:bg-accent"); + }); + + it("keeps the accent palette for active-only threads", () => { + const className = resolveThreadRowClassName({ isActive: true, isSelected: false }); + expect(className).toContain("bg-accent/85"); + expect(className).toContain("hover:bg-accent"); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f1afa0f64..c65ef8379 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,7 +1,9 @@ import type { Thread } from "../types"; +import { cn } from "../lib/utils"; import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; +export type SidebarNewThreadEnvMode = "local" | "worktree"; export interface ThreadStatusPill { label: @@ -37,6 +39,44 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } +export function resolveSidebarNewThreadEnvMode(input: { + requestedEnvMode?: SidebarNewThreadEnvMode; + defaultEnvMode: SidebarNewThreadEnvMode; +}): SidebarNewThreadEnvMode { + return input.requestedEnvMode ?? input.defaultEnvMode; +} + +export function resolveThreadRowClassName(input: { + isActive: boolean; + isSelected: boolean; +}): string { + const baseClassName = + "h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none focus-visible:ring-0"; + + if (input.isSelected && input.isActive) { + return cn( + baseClassName, + "bg-primary/22 text-foreground font-medium hover:bg-primary/26 hover:text-foreground dark:bg-primary/30 dark:hover:bg-primary/36", + ); + } + + if (input.isSelected) { + return cn( + baseClassName, + "bg-primary/15 text-foreground hover:bg-primary/19 hover:text-foreground dark:bg-primary/22 dark:hover:bg-primary/28", + ); + } + + if (input.isActive) { + return cn( + baseClassName, + "bg-accent/85 text-foreground font-medium hover:bg-accent hover:text-foreground dark:bg-accent/55 dark:hover:bg-accent/70", + ); + } + + return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); +} + export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; hasPendingApprovals: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 962d11e89..7e355a66b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -37,7 +37,6 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - DEFAULT_RUNTIME_MODE, DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, type ProviderKind, @@ -64,7 +63,8 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { providerGetUsageQueryOptions } from "../lib/providerReactQuery"; import { readNativeApi } from "../nativeApi"; -import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { @@ -101,8 +101,14 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; -import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic"; +import { + resolveSidebarNewThreadEnvMode, + resolveThreadRowClassName, + resolveThreadStatusPill, + shouldClearThreadSelectionOnMouseDown, +} from "./Sidebar.logic"; import { ProviderLogo } from "./ProviderLogo"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -116,13 +122,6 @@ function threadTitleMatchesSearch(thread: Thread, query: string): boolean { return thread.title.toLocaleLowerCase().includes(query); } -async function copyTextToClipboard(text: string): Promise { - if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) { - throw new Error("Clipboard API unavailable."); - } - await navigator.clipboard.writeText(text); -} - function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const minutes = Math.floor(diff / 60_000); @@ -708,11 +707,8 @@ export default function Sidebar() { const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); - const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); @@ -722,6 +718,7 @@ export default function Sidebar() { const navigate = useNavigate(); const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); const { settings: appSettings } = useAppSettings(); + const { handleNewThread } = useHandleNewThread(); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), @@ -831,80 +828,6 @@ export default function Sidebar() { }); }, []); - const handleNewThread = useCallback( - ( - projectId: ProjectId, - options?: { - branch?: string | null; - worktreePath?: string | null; - envMode?: DraftThreadEnvMode; - }, - ): Promise => { - const hasBranchOption = options?.branch !== undefined; - const hasWorktreePathOption = options?.worktreePath !== undefined; - const hasEnvModeOption = options?.envMode !== undefined; - const storedDraftThread = getDraftThreadByProjectId(projectId); - if (storedDraftThread) { - return (async () => { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(storedDraftThread.threadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); - } - setProjectDraftThreadId(projectId, storedDraftThread.threadId); - if (routeThreadId === storedDraftThread.threadId) { - return; - } - await navigate({ - to: "/$threadId", - params: { threadId: storedDraftThread.threadId }, - }); - })(); - } - clearProjectDraftThreadId(projectId); - - const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; - if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(routeThreadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); - } - setProjectDraftThreadId(projectId, routeThreadId); - return Promise.resolve(); - } - const threadId = newThreadId(); - const createdAt = new Date().toISOString(); - return (async () => { - setProjectDraftThreadId(projectId, threadId, { - createdAt, - branch: options?.branch ?? null, - worktreePath: options?.worktreePath ?? null, - envMode: options?.envMode ?? "local", - runtimeMode: DEFAULT_RUNTIME_MODE, - }); - - await navigate({ - to: "/$threadId", - params: { threadId }, - }); - })(); - }, - [ - clearProjectDraftThreadId, - getDraftThreadByProjectId, - navigate, - getDraftThread, - routeThreadId, - setDraftThreadContext, - setProjectDraftThreadId, - ], - ); - const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { const latestThread = threads @@ -959,7 +882,9 @@ export default function Sidebar() { defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, createdAt, }); - await handleNewThread(projectId).catch(() => undefined); + await handleNewThread(projectId, { + envMode: appSettings.defaultThreadEnvMode, + }).catch(() => undefined); } catch (error) { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; @@ -983,6 +908,7 @@ export default function Sidebar() { isAddingProject, projects, shouldBrowseForProjectImmediately, + appSettings.defaultThreadEnvMode, ], ); @@ -1196,6 +1122,22 @@ export default function Sidebar() { ], ); + const { copyToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Thread ID copied", + description: ctx.threadId, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy thread ID", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); const handleThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); @@ -1224,20 +1166,7 @@ export default function Sidebar() { return; } if (clicked === "copy-thread-id") { - try { - await copyTextToClipboard(threadId); - toastManager.add({ - type: "success", - title: "Thread ID copied", - description: threadId, - }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to copy thread ID", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } + copyToClipboard(threadId, { threadId }); return; } if (clicked !== "delete") return; @@ -1254,7 +1183,7 @@ export default function Sidebar() { } await deleteThread(threadId); }, - [appSettings.confirmThreadDelete, deleteThread, markThreadUnread, threads], + [appSettings.confirmThreadDelete, copyToClipboard, deleteThread, markThreadUnread, threads], ); const handleMultiSelectContextMenu = useCallback( @@ -1353,7 +1282,7 @@ export default function Sidebar() { const api = readNativeApi(); if (!api) return; const clicked = await api.contextMenu.show( - [{ id: "delete", label: "Delete", destructive: true }], + [{ id: "delete", label: "Remove project", destructive: true }], position, ); if (clicked !== "delete") return; @@ -1366,14 +1295,12 @@ export default function Sidebar() { toastManager.add({ type: "warning", title: "Project is not empty", - description: "Delete all threads in this project before deleting it.", + description: "Delete all threads in this project before removing it.", }); return; } - const confirmed = await api.dialogs.confirm( - [`Delete project "${project.name}"?`, "This action cannot be undone."].join("\n"), - ); + const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); if (!confirmed) return; try { @@ -1388,11 +1315,11 @@ export default function Sidebar() { projectId, }); } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error deleting project."; + const message = error instanceof Error ? error.message : "Unknown error removing project."; console.error("Failed to remove project", { projectId, error }); toastManager.add({ type: "error", - title: `Failed to delete "${project.name}"`, + title: `Failed to remove "${project.name}"`, description: message, }); } @@ -1481,37 +1408,6 @@ export default function Sidebar() { ); useEffect(() => { - const onWindowKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && selectedThreadIds.size > 0) { - event.preventDefault(); - clearSelection(); - return; - } - - const activeThread = routeThreadId - ? threads.find((thread) => thread.id === routeThreadId) - : undefined; - const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; - if (isChatNewLocalShortcut(event, keybindings)) { - const projectId = - activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; - if (!projectId) return; - event.preventDefault(); - void handleNewThread(projectId); - return; - } - - if (!isChatNewShortcut(event, keybindings)) return; - const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; - if (!projectId) return; - event.preventDefault(); - void handleNewThread(projectId, { - branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, - worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, - envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), - }); - }; - const onMouseDown = (event: globalThis.MouseEvent) => { if (selectedThreadIds.size === 0) return; const target = event.target instanceof HTMLElement ? event.target : null; @@ -1519,22 +1415,11 @@ export default function Sidebar() { clearSelection(); }; - window.addEventListener("keydown", onWindowKeyDown); window.addEventListener("mousedown", onMouseDown); return () => { - window.removeEventListener("keydown", onWindowKeyDown); window.removeEventListener("mousedown", onMouseDown); }; - }, [ - clearSelection, - getDraftThread, - handleNewThread, - keybindings, - projects, - routeThreadId, - selectedThreadIds.size, - threads, - ]); + }, [clearSelection, selectedThreadIds.size]); useEffect(() => { if (!isElectron) return; @@ -1696,7 +1581,7 @@ export default function Sidebar() { +
Code @@ -2003,7 +1888,11 @@ export default function Sidebar() { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - void handleNewThread(project.id); + void handleNewThread(project.id, { + envMode: resolveSidebarNewThreadEnvMode({ + defaultEnvMode: appSettings.defaultThreadEnvMode, + }), + }); }} > @@ -2050,13 +1939,10 @@ export default function Sidebar() { render={
} size="sm" isActive={isActive} - className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none hover:bg-accent hover:text-foreground focus-visible:ring-0 ${ - isSelected - ? "bg-primary/15 text-foreground dark:bg-primary/10" - : isActive - ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55" - : "text-muted-foreground" - }`} + className={resolveThreadRowClassName({ + isActive, + isSelected, + })} onClick={(event) => { handleThreadClick( event, @@ -2194,7 +2080,7 @@ export default function Sidebar() { diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index ed6d2e810..48b2c6619 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -12,17 +12,17 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { openInPreferredEditor } from "../editorPreferences"; import { extractTerminalLinks, isTerminalLinkActivation, - preferredTerminalEditor, resolvePathLinkTarget, } from "../terminal-links"; import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, - MAX_THREAD_TERMINAL_COUNT, + MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "../types"; import { @@ -273,7 +273,7 @@ function TerminalViewport({ } const target = resolvePathLinkTarget(match.text, cwd); - void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { + void openInPreferredEditor(api, target).catch((error) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open path", @@ -642,7 +642,7 @@ export default function ThreadTerminalDrawer({ const showGroupHeaders = resolvedTerminalGroups.length > 1 || resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); - const hasReachedTerminalLimit = normalizedTerminalIds.length >= MAX_THREAD_TERMINAL_COUNT; + const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; const terminalLabelById = useMemo( () => new Map( @@ -650,27 +650,24 @@ export default function ThreadTerminalDrawer({ ), [normalizedTerminalIds], ); - const splitTerminalActionLabel = hasReachedTerminalLimit - ? `Split Terminal (max ${MAX_THREAD_TERMINAL_COUNT})` + const splitTerminalActionLabel = hasReachedSplitLimit + ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` : splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"; - const newTerminalActionLabel = hasReachedTerminalLimit - ? `New Terminal (max ${MAX_THREAD_TERMINAL_COUNT})` - : newShortcutLabel - ? `New Terminal (${newShortcutLabel})` - : "New Terminal"; + const newTerminalActionLabel = newShortcutLabel + ? `New Terminal (${newShortcutLabel})` + : "New Terminal"; const closeTerminalActionLabel = closeShortcutLabel ? `Close Terminal (${closeShortcutLabel})` : "Close Terminal"; const onSplitTerminalAction = useCallback(() => { - if (hasReachedTerminalLimit) return; + if (hasReachedSplitLimit) return; onSplitTerminal(); - }, [hasReachedTerminalLimit, onSplitTerminal]); + }, [hasReachedSplitLimit, onSplitTerminal]); const onNewTerminalAction = useCallback(() => { - if (hasReachedTerminalLimit) return; onNewTerminal(); - }, [hasReachedTerminalLimit, onNewTerminal]); + }, [onNewTerminal]); useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -781,7 +778,7 @@ export default function ThreadTerminalDrawer({
@@ -876,7 +869,7 @@ export default function ThreadTerminalDrawer({
diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index 5f4ac4f1e..cf1e79891 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -1,38 +1,20 @@ -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { memo } from "react"; import { CopyIcon, CheckIcon } from "lucide-react"; import { Button } from "../ui/button"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); - const resetTimerRef = useRef(null); - - useEffect(() => { - return () => { - if (resetTimerRef.current !== null) { - window.clearTimeout(resetTimerRef.current); - } - }; - }, []); - - const handleCopy = useCallback(async () => { - try { - await navigator.clipboard.writeText(text); - setCopied(true); - if (resetTimerRef.current !== null) { - window.clearTimeout(resetTimerRef.current); - } - resetTimerRef.current = window.setTimeout(() => { - resetTimerRef.current = null; - setCopied(false); - }, 2000); - } catch { - setCopied(false); - } - }, [text]); + const { copyToClipboard, isCopied } = useCopyToClipboard(); return ( - ); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts new file mode 100644 index 000000000..dee42a858 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; + +describe("computeMessageDurationStart", () => { + it("returns message createdAt when there is no preceding user message", () => { + const result = computeMessageDurationStart([ + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:05Z", + completedAt: "2026-01-01T00:00:10Z", + }, + ]); + expect(result).toEqual(new Map([["a1", "2026-01-01T00:00:05Z"]])); + }); + + it("uses the user message createdAt for the first assistant response", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ]), + ); + }); + + it("uses the previous assistant completedAt for subsequent assistant responses", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + { + id: "a2", + role: "assistant", + createdAt: "2026-01-01T00:00:55Z", + completedAt: "2026-01-01T00:00:55Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ["a2", "2026-01-01T00:00:30Z"], + ]), + ); + }); + + it("does not advance the boundary for a streaming message without completedAt", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z" }, + { + id: "a2", + role: "assistant", + createdAt: "2026-01-01T00:00:55Z", + completedAt: "2026-01-01T00:00:55Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ["a2", "2026-01-01T00:00:00Z"], + ]), + ); + }); + + it("resets the boundary on a new user message", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + { id: "u2", role: "user", createdAt: "2026-01-01T00:01:00Z" }, + { + id: "a2", + role: "assistant", + createdAt: "2026-01-01T00:01:20Z", + completedAt: "2026-01-01T00:01:20Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ["u2", "2026-01-01T00:01:00Z"], + ["a2", "2026-01-01T00:01:00Z"], + ]), + ); + }); + + it("handles system messages without affecting the boundary", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { id: "s1", role: "system", createdAt: "2026-01-01T00:00:01Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["s1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ]), + ); + }); + + it("returns empty map for empty input", () => { + expect(computeMessageDurationStart([])).toEqual(new Map()); + }); +}); + +describe("normalizeCompactToolLabel", () => { + it("removes trailing completion wording from command labels", () => { + expect(normalizeCompactToolLabel("Ran command complete")).toBe("Ran command"); + }); + + it("removes trailing completion wording from other labels", () => { + expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file"); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts new file mode 100644 index 000000000..726d61888 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -0,0 +1,29 @@ +export interface TimelineDurationMessage { + id: string; + role: "user" | "assistant" | "system"; + createdAt: string; + completedAt?: string | undefined; +} + +export function computeMessageDurationStart( + messages: ReadonlyArray, +): Map { + const result = new Map(); + let lastBoundary: string | null = null; + + for (const message of messages) { + if (message.role === "user") { + lastBoundary = message.createdAt; + } + result.set(message.id, lastBoundary ?? message.createdAt); + if (message.role === "assistant" && message.completedAt) { + lastBoundary = message.completedAt; + } + } + + return result; +} + +export function normalizeCompactToolLabel(value: string): string { + return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 18b077b7f..de9c71955 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -5,13 +5,26 @@ import { type VirtualItem, useVirtualizer, } from "@tanstack/react-virtual"; -import { deriveTimelineEntries, formatElapsed, formatTimestamp } from "../../session-logic"; +import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; import { useAppSettings } from "../../appSettings"; import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import ChatMarkdown from "../ChatMarkdown"; -import { Undo2Icon } from "lucide-react"; +import { + BotIcon, + CheckIcon, + CircleAlertIcon, + EyeIcon, + GlobeIcon, + HammerIcon, + type LucideIcon, + SquarePenIcon, + TerminalIcon, + Undo2Icon, + WrenchIcon, + ZapIcon, +} from "lucide-react"; import { Button } from "../ui/button"; import { clamp } from "effect/Number"; import { estimateTimelineMessageHeight } from "../timelineHeight"; @@ -20,6 +33,10 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; +import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { cn } from "~/lib/utils"; +import { type TimestampFormat } from "../../appSettings"; +import { formatTimestamp } from "../../timestampFormat"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -44,6 +61,7 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; + timestampFormat: TimestampFormat; workspaceRoot: string | undefined; } @@ -67,6 +85,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, markdownCwd, resolvedTheme, + timestampFormat, workspaceRoot, }: MessagesTimelineProps) { const { settings } = useAppSettings(); @@ -100,6 +119,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const rows = useMemo(() => { const nextRows: TimelineRow[] = []; + const durationStartByMessageId = computeMessageDurationStart( + timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), + ); for (let index = 0; index < timelineEntries.length; index += 1) { const timelineEntry = timelineEntries[index]; @@ -141,6 +163,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ id: timelineEntry.id, createdAt: timelineEntry.createdAt, message: timelineEntry.message, + durationStart: + durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, showCompletionDivider: timelineEntry.message.role === "assistant" && completionDividerBeforeEntryId === timelineEntry.id, @@ -281,73 +305,34 @@ export const MessagesTimeline = memo(function MessagesTimeline({ : groupedEntries; const hiddenCount = groupedEntries.length - visibleEntries.length; const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const groupLabel = onlyToolEntries - ? groupedEntries.length === 1 - ? "Tool call" - : `Tool calls (${groupedEntries.length})` - : groupedEntries.length === 1 - ? "Work event" - : `Work log (${groupedEntries.length})`; + const showHeader = hasOverflow || !onlyToolEntries; + const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; return ( -
-
-

- {groupLabel} -

- {hasOverflow && ( - - )} -
-
+
+ {showHeader && ( +
+

+ {groupLabel} ({groupedEntries.length}) +

+ {hasOverflow && ( + + )} +
+ )} +
{visibleEntries.map((workEntry) => ( -
- -
-

- {workEntry.label} -

- {workEntry.command && settings.showCommandOutput && ( -
-                          {workEntry.command}
-                        
- )} - {workEntry.changedFiles && workEntry.changedFiles.length > 0 && ( -
- {workEntry.changedFiles.slice(0, 6).map((filePath) => ( - - {filePath} - - ))} - {workEntry.changedFiles.length > 6 && ( - - +{workEntry.changedFiles.length - 6} more - - )} -
- )} - {workEntry.detail && - settings.showCommandOutput && - (!workEntry.command || workEntry.detail !== workEntry.command) && ( -

- {workEntry.detail} -

- )} -
-
+ ))}
@@ -421,7 +406,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}

- {formatTimestamp(row.message.createdAt)} + {formatTimestamp(row.message.createdAt, timestampFormat)}

@@ -510,8 +495,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {formatMessageMeta( row.message.createdAt, row.message.streaming - ? formatElapsed(row.message.createdAt, nowIso) - : formatElapsed(row.message.createdAt, row.message.completedAt), + ? formatElapsed(row.durationStart, nowIso) + : formatElapsed(row.durationStart, row.message.completedAt), + timestampFormat, )}

@@ -608,6 +594,7 @@ type TimelineRow = id: string; createdAt: string; message: TimelineMessage; + durationStart: string; showCompletionDivider: boolean; } | { @@ -646,9 +633,41 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null { return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; } -function formatMessageMeta(createdAt: string, duration: string | null): string { - if (!duration) return formatTimestamp(createdAt); - return `${formatTimestamp(createdAt)} • ${duration}`; +function formatMessageMeta( + createdAt: string, + duration: string | null, + timestampFormat: TimestampFormat, +): string { + if (!duration) return formatTimestamp(createdAt, timestampFormat); + return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; +} + +function workToneIcon(tone: TimelineWorkEntry["tone"]): { + icon: LucideIcon; + className: string; +} { + if (tone === "error") { + return { + icon: CircleAlertIcon, + className: "text-foreground/92", + }; + } + if (tone === "thinking") { + return { + icon: BotIcon, + className: "text-foreground/92", + }; + } + if (tone === "info") { + return { + icon: CheckIcon, + className: "text-foreground/92", + }; + } + return { + icon: ZapIcon, + className: "text-foreground/92", + }; } function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { @@ -657,3 +676,121 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "thinking") return "text-muted-foreground/50"; return "text-muted-foreground/40"; } + +function workEntryPreview( + workEntry: Pick, +) { + if (workEntry.command) return workEntry.command; + if (workEntry.detail) return workEntry.detail; + if ((workEntry.changedFiles?.length ?? 0) === 0) return null; + const [firstPath] = workEntry.changedFiles ?? []; + if (!firstPath) return null; + return workEntry.changedFiles!.length === 1 + ? firstPath + : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; +} + +function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { + if (workEntry.requestKind === "command") return TerminalIcon; + if (workEntry.requestKind === "file-read") return EyeIcon; + if (workEntry.requestKind === "file-change") return SquarePenIcon; + + if (workEntry.itemType === "command_execution" || workEntry.command) { + return TerminalIcon; + } + if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { + return SquarePenIcon; + } + if (workEntry.itemType === "web_search") return GlobeIcon; + if (workEntry.itemType === "image_view") return EyeIcon; + + switch (workEntry.itemType) { + case "mcp_tool_call": + return WrenchIcon; + case "dynamic_tool_call": + case "collab_agent_tool_call": + return HammerIcon; + } + + return workToneIcon(workEntry.tone).icon; +} + +function capitalizePhrase(value: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return value; + } + return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; +} + +function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { + if (!workEntry.toolTitle) { + return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); + } + return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); +} + +const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { + workEntry: TimelineWorkEntry; + showCommandOutput?: boolean; +}) { + const { workEntry, showCommandOutput = true } = props; + const iconConfig = workToneIcon(workEntry.tone); + const EntryIcon = workEntryIcon(workEntry); + const heading = toolWorkEntryHeading(workEntry); + const rawPreview = workEntryPreview(workEntry); + // When showCommandOutput is off, suppress command/detail previews but still show changed-file previews + const preview = showCommandOutput + ? rawPreview + : !workEntry.command && !workEntry.detail + ? rawPreview + : null; + const displayText = preview ? `${heading} - ${preview}` : heading; + const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; + const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; + + return ( +
+
+ + + +
+

+ + {heading} + + {preview && - {preview}} +

+
+
+ {hasChangedFiles && !previewIsChangedFiles && ( +
+ {workEntry.changedFiles?.slice(0, 4).map((filePath) => ( + + {filePath} + + ))} + {(workEntry.changedFiles?.length ?? 0) > 4 && ( + + +{(workEntry.changedFiles?.length ?? 0) - 4} + + )} +
+ )} +
+ ); +}); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index b62bb7f41..5cb4eb9ac 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,15 +1,49 @@ -import { EDITORS, type EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; +import { usePreferredEditor } from "../../editorPreferences"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; -import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; +import { AntigravityIcon, CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -const LAST_EDITOR_KEY = "t3code:last-editor"; +const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { + const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ + { + label: "Cursor", + Icon: CursorIcon, + value: "cursor", + }, + { + label: "VS Code", + Icon: VisualStudioCode, + value: "vscode", + }, + { + label: "Zed", + Icon: Zed, + value: "zed", + }, + { + label: "Antigravity", + Icon: AntigravityIcon, + value: "antigravity", + }, + { + label: isMacPlatform(platform) + ? "Finder" + : isWindowsPlatform(platform) + ? "Explorer" + : "Files", + Icon: FolderClosedIcon, + value: "file-manager", + }, + ]; + return baseOptions.filter((option) => availableEditors.includes(option.value)); +}; export const OpenInPicker = memo(function OpenInPicker({ keybindings, @@ -20,63 +54,23 @@ export const OpenInPicker = memo(function OpenInPicker({ availableEditors: ReadonlyArray; openInCwd: string | null; }) { - const [lastEditor, setLastEditor] = useState(() => { - if (typeof window === "undefined") return EDITORS[0].id; - const stored = localStorage.getItem(LAST_EDITOR_KEY); - return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; - }); - - const platform = typeof navigator !== "undefined" ? navigator.platform : ""; - const allOptions = useMemo>( - () => [ - { - label: "Cursor", - Icon: CursorIcon, - value: "cursor", - }, - { - label: "VS Code", - Icon: VisualStudioCode, - value: "vscode", - }, - { - label: "Zed", - Icon: Zed, - value: "zed", - }, - { - label: isMacPlatform(platform) - ? "Finder" - : isWindowsPlatform(platform) - ? "Explorer" - : "Files", - Icon: FolderClosedIcon, - value: "file-manager", - }, - ], - [platform], - ); + const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); const options = useMemo( - () => allOptions.filter((option) => availableEditors.includes(option.value)), - [allOptions, availableEditors], + () => resolveOptions(navigator.platform, availableEditors), + [availableEditors], ); - - const effectiveEditor = options.some((option) => option.value === lastEditor) - ? lastEditor - : (options[0]?.value ?? null); - const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; + const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; const openInEditor = useCallback( (editorId: EditorId | null) => { const api = readNativeApi(); if (!api || !openInCwd) return; - const editor = editorId ?? effectiveEditor; + const editor = editorId ?? preferredEditor; if (!editor) return; void api.shell.openInEditor(openInCwd, editor); - localStorage.setItem(LAST_EDITOR_KEY, editor); - setLastEditor(editor); + setPreferredEditor(editor); }, - [effectiveEditor, openInCwd, setLastEditor], + [preferredEditor, openInCwd, setPreferredEditor], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -89,22 +83,22 @@ export const OpenInPicker = memo(function OpenInPicker({ const api = readNativeApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!api || !openInCwd) return; - if (!effectiveEditor) return; + if (!preferredEditor) return; e.preventDefault(); - void api.shell.openInEditor(openInCwd, effectiveEditor); + void api.shell.openInEditor(openInCwd, preferredEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [effectiveEditor, keybindings, openInCwd]); + }, [preferredEditor, keybindings, openInCwd]); return ( - ); - })} -
- -

- Active theme: {resolvedTheme} -

- -
-
-

Accent color

-

- Applies to primary actions, focus rings, info highlights, and terminal blue. -

-
- -
- {ACCENT_COLOR_PRESETS.map((preset) => { - const selected = accentColor === preset.value; +
+
+ {THEME_OPTIONS.map((option) => { + const selected = theme === option.value; return ( ); })}
-
- - - updateSettings({ accentColor: normalizeAccentColor(event.target.value) }) - } - /> - {accentColor} - {accentColor !== DEFAULT_ACCENT_COLOR ? ( - + ); + })} +
+ +
+ + + updateSettings({ accentColor: normalizeAccentColor(event.target.value) }) + } + /> + {accentColor} + {accentColor !== DEFAULT_ACCENT_COLOR ? ( + + ) : null} +
+ + + + {settings.providerLogoAppearance !== defaults.providerLogoAppearance ? ( +
+ +
) : null}
-