diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12784bcd1c..5b9a5ee3af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest 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,7 +86,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/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index 71fe2e2600..8e50efded3 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -1,36 +1,31 @@ name: PR Size on: - pull_request: + pull_request_target: types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] + workflow_dispatch: + push: + branches: + - main + paths: + - .github/workflows/pr-size.yml permissions: contents: read - issues: write - pull-requests: write jobs: - label: - name: Label PR size + prepare-config: + name: Prepare PR size config runs-on: ubuntu-24.04 - concurrency: - group: pr-size-${{ github.event.pull_request.number }} - cancel-in-progress: true + outputs: + labels_json: ${{ steps.config.outputs.labels_json }} steps: - - name: Sync PR size label + - id: config + name: Build PR size label config uses: actions/github-script@v7 with: + result-encoding: string 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 isIntegrationPermissionError = (error) => - error?.status === 403 && - (error?.response?.data?.message ?? error?.message ?? "").includes( - "Resource not accessible by integration", - ); - const managedLabels = [ { name: "size:XS", @@ -64,31 +59,23 @@ jobs: }, ]; - 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"; - }; + 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@v7 + 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 { @@ -111,13 +98,6 @@ jobs: }); } } catch (error) { - if (isIntegrationPermissionError(error)) { - core.warning( - `Skipping PR size label definition sync: ${error.message}`, - ); - return; - } - if (error.status !== 404) { throw error; } @@ -131,19 +111,62 @@ jobs: description: label.description, }); } catch (createError) { - if (isIntegrationPermissionError(createError)) { - core.warning( - `Skipping PR size label definition sync: ${createError.message}`, - ); - return; - } - if (createError.status !== 422) { throw createError; } } } } + label: + name: Label PR size + needs: prepare-config + 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@v7 + 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); @@ -167,13 +190,6 @@ jobs: name: label.name, }); } catch (removeError) { - if (isIntegrationPermissionError(removeError)) { - core.warning( - `Skipping PR size label sync for #${issueNumber}: ${removeError.message}`, - ); - return; - } - if (removeError.status !== 404) { throw removeError; } @@ -181,23 +197,12 @@ jobs: } if (!currentLabels.some((label) => label.name === nextLabelName)) { - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [nextLabelName], - }); - } catch (addError) { - if (isIntegrationPermissionError(addError)) { - core.warning( - `Skipping PR size label sync for #${issueNumber}: ${addError.message}`, - ); - return; - } - - throw addError; - } + 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 abf1235498..cbc9e4fc54 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") { @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 807e0f7639..8331ba5084 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,7 +217,7 @@ 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/* @@ -229,7 +229,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} @@ -239,7 +239,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 registry-url: https://registry.npmjs.org @@ -262,17 +262,17 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} - 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 @@ -309,7 +309,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: main fetch-depth: 0 @@ -320,7 +320,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/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index cf5dc26696..12d4753509 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -80,12 +80,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 443492ada7..1631046a6e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -619,6 +619,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/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts index ca8435336f..d867ad910d 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 dbce5c427d..684b005e83 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() @@ -62,20 +81,120 @@ function normalizeQuery(input: string): string { .toLowerCase(); } -function scoreEntry(entry: ProjectEntry, query: string): number { +function scoreSubsequenceMatch(value: string, query: string): number | null { + if (!query) return 0; + + let queryIndex = 0; + let firstMatchIndex = -1; + let previousMatchIndex = -1; + let gapPenalty = 0; + + for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) { + if (value[valueIndex] !== query[queryIndex]) { + continue; + } + + if (firstMatchIndex === -1) { + firstMatchIndex = valueIndex; + } + if (previousMatchIndex !== -1) { + gapPenalty += valueIndex - previousMatchIndex - 1; + } + + previousMatchIndex = valueIndex; + queryIndex += 1; + if (queryIndex === query.length) { + const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length; + const lengthPenalty = Math.min(64, value.length - query.length); + return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty; + } + } + + return null; +} + +function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null { if (!query) { return entry.kind === "directory" ? 0 : 1; } - const normalizedPath = entry.path.toLowerCase(); - const normalizedName = basenameOf(normalizedPath); + const { normalizedPath, normalizedName } = entry; if (normalizedName === query) return 0; if (normalizedPath === query) return 1; if (normalizedName.startsWith(query)) return 2; if (normalizedPath.startsWith(query)) return 3; if (normalizedPath.includes(`/${query}`)) return 4; - return 5; + if (normalizedName.includes(query)) return 5; + if (normalizedPath.includes(query)) return 6; + + const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query); + if (nameFuzzyScore !== null) { + return 100 + nameFuzzyScore; + } + + const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query); + if (pathFuzzyScore !== null) { + return 200 + pathFuzzyScore; + } + + return null; +} + +function compareRankedWorkspaceEntries( + left: RankedWorkspaceEntry, + right: RankedWorkspaceEntry, +): number { + const scoreDelta = left.score - right.score; + if (scoreDelta !== 0) return scoreDelta; + return left.entry.path.localeCompare(right.entry.path); +} + +function findInsertionIndex( + rankedEntries: RankedWorkspaceEntry[], + candidate: RankedWorkspaceEntry, +): number { + let low = 0; + let high = rankedEntries.length; + + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + const current = rankedEntries[middle]; + if (!current) { + break; + } + + if (compareRankedWorkspaceEntries(candidate, current) < 0) { + high = middle; + } else { + low = middle + 1; + } + } + + return low; +} + +function insertRankedEntry( + rankedEntries: RankedWorkspaceEntry[], + candidate: RankedWorkspaceEntry, + limit: number, +): void { + if (limit <= 0) { + return; + } + + const insertionIndex = findInsertionIndex(rankedEntries, candidate); + if (rankedEntries.length < limit) { + rankedEntries.splice(insertionIndex, 0, candidate); + return; + } + + if (insertionIndex >= limit) { + return; + } + + rankedEntries.splice(insertionIndex, 0, candidate); + rankedEntries.pop(); } function isPathInIgnoredDirectory(relativePath: string): boolean { @@ -253,20 +372,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 { @@ -284,7 +409,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) { @@ -351,11 +476,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()) { @@ -419,18 +544,22 @@ export async function searchWorkspaceEntries( ): Promise { const index = await getWorkspaceIndex(input.cwd); const normalizedQuery = normalizeQuery(input.query); - const candidates = normalizedQuery - ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) - : index.entries; - - const ranked = candidates.toSorted((left, right) => { - const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery); - if (scoreDelta !== 0) return scoreDelta; - return left.path.localeCompare(right.path); - }); + const limit = Math.max(0, Math.floor(input.limit)); + const rankedEntries: RankedWorkspaceEntry[] = []; + let matchedEntryCount = 0; + + for (const entry of index.entries) { + const score = scoreEntry(entry, normalizedQuery); + if (score === null) { + continue; + } + + matchedEntryCount += 1; + insertRankedEntry(rankedEntries, { entry, score }, limit); + } return { - entries: ranked.slice(0, input.limit), - truncated: index.truncated || ranked.length > input.limit, + entries: rankedEntries.map((candidate) => candidate.entry), + truncated: index.truncated || matchedEntryCount > limit, }; } diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3f8876ec14..9663d158eb 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,8 +1,4 @@ -import { - getSharedHighlighter, - type DiffsHighlighter, - type SupportedLanguages, -} from "@pierre/diffs"; +import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; import { CheckIcon, CopyIcon } from "lucide-react"; import React, { Children, @@ -213,7 +209,12 @@ function SuspenseShikiCodeBlock({ const highlightedHtml = useMemo(() => { 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 }); } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d8b74c8cc9..faecc7f51b 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.tsx b/apps/web/src/components/ChatView.tsx index c70f90fb2b..3c8b5c9324 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -77,7 +77,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"; @@ -118,6 +118,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, DEFAULT_ENV_MODE, @@ -1040,7 +1041,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; @@ -1088,17 +1098,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; @@ -1165,8 +1175,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; @@ -1941,13 +1950,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 = { @@ -3708,7 +3710,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ) : phase === "running" ? (