From fa2fd2f1e5f5d498e38d1f621aaed23334fa055c Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 16:03:55 -0400 Subject: [PATCH 01/15] fix: don't restart the app on normal exists in dev runner (#986) Co-authored-by: Julius Marminge --- apps/desktop/scripts/dev-electron.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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(); } }); From 2ddea600ba74ee2ed5521b73be4886fecc2e7910 Mon Sep 17 00:00:00 2001 From: Kiyotaka Date: Thu, 12 Mar 2026 15:00:08 -0500 Subject: [PATCH 02/15] feat: add fuzzy workspace entry search (#256) Co-authored-by: Julius Marminge --- apps/server/src/workspaceEntries.test.ts | 26 +++ apps/server/src/workspaceEntries.ts | 191 +++++++++++++++++++---- 2 files changed, 186 insertions(+), 31 deletions(-) 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, }; } From a0d22d629ba51c36897436d97e74a663ff2f5122 Mon Sep 17 00:00:00 2001 From: Qiaochu Hu <110hqc@gmail.com> Date: Fri, 13 Mar 2026 03:32:00 +0800 Subject: [PATCH 03/15] fix: clean up timeout in PlanSidebar to prevent memory leaks (#949) Co-authored-by: hobostay --- apps/web/src/components/PlanSidebar.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index c465f39890..2d898b009e 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useCallback } from "react"; +import { memo, useState, useCallback, useRef, useEffect } from "react"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { ScrollArea } from "./ui/scroll-area"; @@ -66,6 +66,7 @@ const PlanSidebar = memo(function PlanSidebar({ const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); const [copied, setCopied] = useState(false); + const copiedTimerRef = useRef | null>(null); const planMarkdown = activeProposedPlan?.planMarkdown ?? null; const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; @@ -74,8 +75,15 @@ const PlanSidebar = memo(function PlanSidebar({ const handleCopyPlan = useCallback(() => { if (!planMarkdown) return; void navigator.clipboard.writeText(planMarkdown); + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + } + setCopied(true); - setTimeout(() => setCopied(false), 2000); + copiedTimerRef.current = setTimeout(() => { + setCopied(false); + copiedTimerRef.current = null; + }, 2000); }, [planMarkdown]); const handleDownload = useCallback(() => { @@ -120,6 +128,14 @@ const PlanSidebar = memo(function PlanSidebar({ {/* Header */}
+ // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (copiedTimerRef.current \!= null) { + clearTimeout(copiedTimerRef.current); + } + }; + }, []); Date: Thu, 12 Mar 2026 15:09:41 -0400 Subject: [PATCH 04/15] fix: Linux icon now shows up (#807) --- apps/desktop/src/main.ts | 1 + scripts/build-desktop-artifact.ts | 3 +++ 2 files changed, 4 insertions(+) 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/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index aebf11d5ca..0b875721fd 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -614,6 +614,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* assertPlatformBuildResources(options.platform, stageResourcesDir, options.verbose); + // electron-builder is filtering out stageResourcesDir directory in the AppImage for production + yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources")); + const stagePackageJson: StagePackageJson = { name: "t3-code-desktop", version: appVersion, From d48fbae1853217ee27efb17104c3698cde8ba142 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 12:40:08 -0700 Subject: [PATCH 05/15] fix syntax errors from bad merge --- apps/web/src/components/PlanSidebar.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 2d898b009e..735900eacb 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -86,6 +86,15 @@ const PlanSidebar = memo(function PlanSidebar({ }, 2000); }, [planMarkdown]); + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + } + }; + }, []); + const handleDownload = useCallback(() => { if (!planMarkdown) return; const filename = buildProposedPlanMarkdownFilename(planMarkdown); @@ -128,14 +137,6 @@ const PlanSidebar = memo(function PlanSidebar({ {/* Header */}
- // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (copiedTimerRef.current \!= null) { - clearTimeout(copiedTimerRef.current); - } - }; - }, []); Date: Fri, 13 Mar 2026 03:31:19 +0800 Subject: [PATCH 06/15] fix: add logging for WebSocket errors (#948) Co-authored-by: hobostay --- apps/web/src/wsTransport.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 2882098c47..4f22a22f1e 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -186,10 +186,10 @@ export class WsTransport { this.scheduleReconnect(); }); - ws.addEventListener("error", () => { - // close will follow + ws.addEventListener("error", (event) => { + // Log WebSocket errors for debugging (close event will follow) + console.warn("WebSocket connection error", { type: event.type, url: this.url }); }); - } private handleMessage(raw: unknown) { const result = decodeWsResponse(raw); From f3ff80622e78b8d9ec27decdb10134687b5d1cc5 Mon Sep 17 00:00:00 2001 From: Qiaochu Hu <110hqc@gmail.com> Date: Fri, 13 Mar 2026 03:35:21 +0800 Subject: [PATCH 07/15] fix: add error logging for code highlighting failures (#951) --- apps/web/src/components/ChatMarkdown.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 }); } From fe4ebbd194b837efb512fd12cea4c37638c00cf1 Mon Sep 17 00:00:00 2001 From: Adam Naji <110662505+Bashamega@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:29:07 +0200 Subject: [PATCH 08/15] chore: update actions/checkout and actions/github-script (#956) Co-authored-by: Julius Marminge --- .github/workflows/ci.yml | 12 +-- .github/workflows/pr-size.yml | 165 ++++++++++++++++----------------- .github/workflows/pr-vouch.yml | 4 +- .github/workflows/release.yml | 24 ++--- 4 files changed, 102 insertions(+), 103 deletions(-) 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..798b4115fc 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -1,36 +1,25 @@ name: PR Size on: - pull_request: + pull_request_target: types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] 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 - uses: actions/github-script@v7 + - id: config + name: Build PR size label config + uses: actions/github-script@v8 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 +53,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@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 { @@ -111,13 +92,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 +105,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@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); @@ -167,13 +184,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 +191,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 From 20dab06d795db913ff4de59dd389e0a3bdd2911c Mon Sep 17 00:00:00 2001 From: Donald Silveira Date: Thu, 12 Mar 2026 15:48:23 -0300 Subject: [PATCH 09/15] update project removal copy (#981) --- apps/web/src/components/Sidebar.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 95288b8131..23d185cabe 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -899,7 +899,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; @@ -912,14 +912,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 { @@ -934,11 +932,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, }); } From 2d357404db77c947beab330addfc30dd2d7cc5b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 12:01:27 -0700 Subject: [PATCH 10/15] fix pr size workflow --- .github/workflows/pr-size.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index 798b4115fc..8e50efded3 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -3,6 +3,12 @@ name: PR Size on: 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 @@ -16,7 +22,7 @@ jobs: steps: - id: config name: Build PR size label config - uses: actions/github-script@v8 + uses: actions/github-script@v7 with: result-encoding: string script: | @@ -64,7 +70,7 @@ jobs: issues: write steps: - name: Ensure PR size labels exist - uses: actions/github-script@v8 + uses: actions/github-script@v7 env: PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} with: @@ -125,7 +131,7 @@ jobs: cancel-in-progress: true steps: - name: Sync PR size label - uses: actions/github-script@v8 + uses: actions/github-script@v7 env: PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} with: From 1cfe5bbaacc6e2f7c22e5c90cbe9421c1b6999fe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 11:19:55 -0700 Subject: [PATCH 11/15] Fix new-thread shortcuts when terminal is focused - move chat-wide key handling into `_chat` route-level shortcut handler - extract reusable `useHandleNewThread` hook and `isTerminalFocused` helper - update browser WS fixture to support `terminalOpen` RPC shape --- apps/web/src/components/ChatView.browser.tsx | 18 ++- apps/web/src/components/ChatView.tsx | 8 +- apps/web/src/components/Sidebar.tsx | 136 +------------------ apps/web/src/hooks/useHandleNewThread.ts | 113 +++++++++++++++ apps/web/src/lib/terminalFocus.ts | 6 + apps/web/src/routes/_chat.tsx | 80 +++++++++++ 6 files changed, 222 insertions(+), 139 deletions(-) create mode 100644 apps/web/src/hooks/useHandleNewThread.ts create mode 100644 apps/web/src/lib/terminalFocus.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d8b74c8cc9..084e835ac8 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -353,7 +353,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 +396,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 +437,7 @@ const worker = setupWorker( client.send( JSON.stringify({ id: request.id, - result: resolveWsRpc(method), + result: resolveWsRpc(request.body), }), ); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c70f90fb2b..328b99c28f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -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, @@ -1941,13 +1942,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 = { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 23d185cabe..baf17fa996 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -27,7 +27,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, ProjectId, @@ -40,18 +39,15 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; -import { isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils"; +import { isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; -import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; +import { shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; -import { - type DraftThreadEnvMode, - DEFAULT_ENV_MODE, - useComposerDraftStore, -} from "../composerDraftStore"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { @@ -266,11 +262,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, ); @@ -280,6 +273,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), @@ -388,80 +382,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 ?? DEFAULT_ENV_MODE, - 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 @@ -1025,39 +945,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" : DEFAULT_ENV_MODE), - }); - }; - const onMouseDown = (event: globalThis.MouseEvent) => { if (selectedThreadIds.size === 0) return; const target = event.target instanceof HTMLElement ? event.target : null; @@ -1065,22 +952,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; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts new file mode 100644 index 0000000000..b0808a6644 --- /dev/null +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -0,0 +1,113 @@ +import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { type DraftThreadEnvMode, DEFAULT_ENV_MODE, useComposerDraftStore } from "../composerDraftStore"; +import { newThreadId } from "../lib/utils"; +import { useStore } from "../store"; + +export function useHandleNewThread() { + const projects = useStore((store) => store.projects); + const threads = useStore((store) => store.threads); + const getDraftThreadByProjectId = useComposerDraftStore( + (store) => store.getDraftThreadByProjectId, + ); + const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); + const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const clearProjectDraftThreadId = useComposerDraftStore( + (store) => store.clearProjectDraftThreadId, + ); + const navigate = useNavigate(); + const routeThreadId = useParams({ + strict: false, + select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), + }); + + const activeThread = routeThreadId + ? threads.find((thread) => thread.id === routeThreadId) + : undefined; + const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; + + 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); + + 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 ?? DEFAULT_ENV_MODE, + runtimeMode: DEFAULT_RUNTIME_MODE, + }); + + await navigate({ + to: "/$threadId", + params: { threadId }, + }); + })(); + }, + [ + activeDraftThread, + clearProjectDraftThreadId, + getDraftThreadByProjectId, + navigate, + routeThreadId, + setDraftThreadContext, + setProjectDraftThreadId, + ], + ); + + return { + activeDraftThread, + activeThread, + handleNewThread, + projects, + routeThreadId, + }; +} diff --git a/apps/web/src/lib/terminalFocus.ts b/apps/web/src/lib/terminalFocus.ts new file mode 100644 index 0000000000..d24c9572a3 --- /dev/null +++ b/apps/web/src/lib/terminalFocus.ts @@ -0,0 +1,6 @@ +export function 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; +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 0d7f1724b2..a2d55095b5 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,10 +1,89 @@ +import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { useQuery } from "@tanstack/react-query"; import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; +import { DEFAULT_ENV_MODE } from "../composerDraftStore"; import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; import ThreadSidebar from "../components/Sidebar"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { resolveShortcutCommand } from "../keybindings"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { useThreadSelectionStore } from "../threadSelectionStore"; import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; +const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; + +function ChatRouteGlobalShortcuts() { + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); + const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } = + useHandleNewThread(); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; + const terminalOpen = useTerminalStateStore((state) => + routeThreadId + ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen + : false, + ); + + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + + if (event.key === "Escape" && selectedThreadIdsSize > 0) { + event.preventDefault(); + clearSelection(); + return; + } + + const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; + if (!projectId) return; + + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + }, + }); + + if (command === "chat.newLocal") { + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(projectId); + return; + } + + if (command !== "chat.new") return; + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(projectId, { + branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, + worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, + envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : DEFAULT_ENV_MODE), + }); + }; + + window.addEventListener("keydown", onWindowKeyDown); + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + }; + }, [ + activeDraftThread, + activeThread, + clearSelection, + handleNewThread, + keybindings, + projects, + selectedThreadIdsSize, + terminalOpen, + ]); + + return null; +} + function ChatRouteLayout() { const navigate = useNavigate(); @@ -26,6 +105,7 @@ function ChatRouteLayout() { return ( + Date: Thu, 12 Mar 2026 11:41:13 -0700 Subject: [PATCH 12/15] Fix mod+N new thread flow and terminal split limits - ensure `chat.new` creates a fresh draft after a promoted draft thread - enforce terminal cap per split group (4) while allowing additional terminal groups - refine sidebar row selected/active styling via shared class-name logic and tests --- apps/web/src/components/ChatView.browser.tsx | 125 ++++++++++++++++++ apps/web/src/components/ChatView.tsx | 24 ++-- apps/web/src/components/Sidebar.logic.test.ts | 25 ++++ apps/web/src/components/Sidebar.logic.ts | 32 +++++ apps/web/src/components/Sidebar.tsx | 19 +-- .../src/components/ThreadTerminalDrawer.tsx | 39 ++---- apps/web/src/hooks/useHandleNewThread.ts | 46 ++++--- apps/web/src/terminalStateStore.test.ts | 49 +++++++ apps/web/src/terminalStateStore.ts | 19 +-- apps/web/src/types.ts | 2 +- 10 files changed, 307 insertions(+), 73 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 084e835ac8..cb7dea0ce2 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -20,6 +20,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"; @@ -1062,6 +1063,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 328b99c28f..c3fe9df98e 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"; @@ -1041,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; @@ -1089,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; @@ -1166,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; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 634d91e9dc..f35f878269 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { hasUnseenCompletion, + resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; @@ -154,3 +155,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 f1afa0f646..8acbed63a9 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,5 @@ 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]"; @@ -37,6 +38,37 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } +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 baf17fa996..5ffd6de92f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -83,7 +83,11 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; -import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic"; +import { + resolveThreadRowClassName, + resolveThreadStatusPill, + shouldClearThreadSelectionOnMouseDown, +} from "./Sidebar.logic"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -1397,13 +1401,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, @@ -1541,7 +1542,7 @@ export default function Sidebar() { diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 7861212e48..8e480715f5 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -22,7 +22,7 @@ import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keyb import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, - MAX_THREAD_TERMINAL_COUNT, + MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "../types"; import { readNativeApi } from "~/nativeApi"; @@ -605,7 +605,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( @@ -613,27 +613,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; @@ -744,7 +741,7 @@ export default function ThreadTerminalDrawer({
@@ -839,7 +832,7 @@ export default function ThreadTerminalDrawer({
diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index b0808a6644..91c7c9e4c9 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,32 +1,30 @@ import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; import { useCallback } from "react"; -import { type DraftThreadEnvMode, DEFAULT_ENV_MODE, useComposerDraftStore } from "../composerDraftStore"; +import { + type DraftThreadEnvMode, + type DraftThreadState, + DEFAULT_ENV_MODE, + useComposerDraftStore, +} from "../composerDraftStore"; import { newThreadId } from "../lib/utils"; import { useStore } from "../store"; export function useHandleNewThread() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); - const getDraftThreadByProjectId = useComposerDraftStore( - (store) => store.getDraftThreadByProjectId, - ); - const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const clearProjectDraftThreadId = useComposerDraftStore( - (store) => store.clearProjectDraftThreadId, - ); const navigate = useNavigate(); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); + const activeDraftThread = useComposerDraftStore((store) => + routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, + ); const activeThread = routeThreadId ? threads.find((thread) => thread.id === routeThreadId) : undefined; - const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; const handleNewThread = useCallback( ( @@ -37,10 +35,20 @@ export function useHandleNewThread() { envMode?: DraftThreadEnvMode; }, ): Promise => { + const { + clearProjectDraftThreadId, + getDraftThread, + getDraftThreadByProjectId, + setDraftThreadContext, + setProjectDraftThreadId, + } = useComposerDraftStore.getState(); const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; const storedDraftThread = getDraftThreadByProjectId(projectId); + const latestActiveDraftThread: DraftThreadState | null = routeThreadId + ? getDraftThread(routeThreadId) + : null; if (storedDraftThread) { return (async () => { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { @@ -63,7 +71,11 @@ export function useHandleNewThread() { clearProjectDraftThreadId(projectId); - if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { + if ( + latestActiveDraftThread && + routeThreadId && + latestActiveDraftThread.projectId === projectId + ) { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { setDraftThreadContext(routeThreadId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), @@ -92,15 +104,7 @@ export function useHandleNewThread() { }); })(); }, - [ - activeDraftThread, - clearProjectDraftThreadId, - getDraftThreadByProjectId, - navigate, - routeThreadId, - setDraftThreadContext, - setProjectDraftThreadId, - ], + [navigate, routeThreadId], ); return { diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index 1f48b1693f..e7e240cf25 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -46,6 +46,28 @@ describe("terminalStateStore actions", () => { ]); }); + it("caps splits at four terminals per group", () => { + const store = useTerminalStateStore.getState(); + store.splitTerminal(THREAD_ID, "terminal-2"); + store.splitTerminal(THREAD_ID, "terminal-3"); + store.splitTerminal(THREAD_ID, "terminal-4"); + store.splitTerminal(THREAD_ID, "terminal-5"); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + expect(terminalState.terminalIds).toEqual([ + "default", + "terminal-2", + "terminal-3", + "terminal-4", + ]); + expect(terminalState.terminalGroups).toEqual([ + { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] }, + ]); + }); + it("creates new terminals in a separate group", () => { useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2"); @@ -62,6 +84,33 @@ describe("terminalStateStore actions", () => { ]); }); + it("allows unlimited groups while keeping each group capped at four terminals", () => { + const store = useTerminalStateStore.getState(); + store.splitTerminal(THREAD_ID, "terminal-2"); + store.splitTerminal(THREAD_ID, "terminal-3"); + store.splitTerminal(THREAD_ID, "terminal-4"); + store.newTerminal(THREAD_ID, "terminal-5"); + store.newTerminal(THREAD_ID, "terminal-6"); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + expect(terminalState.terminalIds).toEqual([ + "default", + "terminal-2", + "terminal-3", + "terminal-4", + "terminal-5", + "terminal-6", + ]); + expect(terminalState.terminalGroups).toEqual([ + { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] }, + { id: "group-terminal-5", terminalIds: ["terminal-5"] }, + { id: "group-terminal-6", terminalIds: ["terminal-6"] }, + ]); + }); + it("tracks and clears terminal subprocess activity", () => { const store = useTerminalStateStore.getState(); store.splitTerminal(THREAD_ID, "terminal-2"); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index cf1ea8446c..b2cea6d560 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -11,7 +11,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, - MAX_THREAD_TERMINAL_COUNT, + MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "./types"; @@ -28,10 +28,7 @@ interface ThreadTerminalState { const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; function normalizeTerminalIds(terminalIds: string[]): string[] { - const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice( - 0, - MAX_THREAD_TERMINAL_COUNT, - ); + const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; } @@ -243,10 +240,6 @@ function upsertTerminalIntoGroups( } const isNewTerminal = !normalized.terminalIds.includes(terminalId); - if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { - return normalized; - } - const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds; @@ -297,6 +290,14 @@ function upsertTerminalIntoGroups( return normalized; } + if ( + isNewTerminal && + !destinationGroup.terminalIds.includes(terminalId) && + destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP + ) { + return normalized; + } + if (!destinationGroup.terminalIds.includes(terminalId)) { const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); if (anchorIndex >= 0) { diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index d5fff12991..c071fb3f60 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -20,7 +20,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default"; export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; export const DEFAULT_THREAD_TERMINAL_ID = "default"; -export const MAX_THREAD_TERMINAL_COUNT = 4; +export const MAX_TERMINALS_PER_GROUP = 4; export type ProjectScript = ContractProjectScript; export interface ThreadTerminalGroup { From 1350991b86617df3651ed0d655e64e22826c185a Mon Sep 17 00:00:00 2001 From: 0x1f99d Date: Fri, 13 Mar 2026 06:15:01 +1100 Subject: [PATCH 13/15] fix(web): add pointer cursor to running stop-generation button (#900) Co-authored-by: Julius Marminge --- apps/web/src/components/ChatView.browser.tsx | 26 +++++++++++++++++++- apps/web/src/components/ChatView.tsx | 2 +- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index cb7dea0ce2..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"; @@ -153,6 +154,7 @@ function createSnapshotForTargetUser(options: { targetMessageId: MessageId; targetText: string; targetAttachmentCount?: number; + sessionStatus?: OrchestrationSessionStatus; }): OrchestrationReadModel { const messages: Array = []; @@ -222,7 +224,7 @@ function createSnapshotForTargetUser(options: { checkpoints: [], session: { threadId: THREAD_ID, - status: "ready", + status: options.sessionStatus ?? "ready", providerName: "codex", runtimeMode: "full-access", activeTurnId: null, @@ -1009,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, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c3fe9df98e..3c8b5c9324 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3710,7 +3710,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ) : phase === "running" ? (