From 6488ae4bc930fa367684f22d530cc87c60ccbfde Mon Sep 17 00:00:00 2001 From: hobostay Date: Thu, 12 Mar 2026 11:56:37 +0800 Subject: [PATCH 01/15] fix: add error logging for code highlighting failures Logs Shiki highlighting failures to help debug language-specific issues while still falling back to plain text rendering. --- apps/web/src/components/ChatMarkdown.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index f4298fc22..7a7cd45c7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,4 +1,8 @@ -import { + // 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, + ); getSharedHighlighter, type DiffsHighlighter, type SupportedLanguages, @@ -213,7 +217,7 @@ function SuspenseShikiCodeBlock({ const highlightedHtml = useMemo(() => { try { return highlighter.codeToHtml(code, { lang: language, theme: themeName }); - } catch { + } catch (error) { // If highlighting fails for this language, render as plain text return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); } From 3d15dca5a157df0c62740d66021ea1d0943a822d Mon Sep 17 00:00:00 2001 From: 0x1f99d Date: Fri, 13 Mar 2026 03:10:07 +1100 Subject: [PATCH 02/15] ci(github): add pull request size labels (#901) --- .github/workflows/pr-size.yml | 166 +++++++++++++++++++++++++++++++++ .github/workflows/pr-vouch.yml | 58 ++++++++---- CONTRIBUTING.md | 2 +- 3 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/pr-size.yml diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml new file mode 100644 index 000000000..7a338a76e --- /dev/null +++ b/.github/workflows/pr-size.yml @@ -0,0 +1,166 @@ +name: PR Size + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + label: + name: Label PR size + runs-on: ubuntu-24.04 + concurrency: + group: pr-size-${{ github.event.pull_request.number }} + cancel-in-progress: true + steps: + - name: Sync PR size label + uses: actions/github-script@v7 + 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 = [ + { + name: "size:XS", + color: "0e8a16", + description: "0-9 changed lines (additions + deletions).", + }, + { + name: "size:S", + color: "5ebd3e", + description: "10-29 changed lines (additions + deletions).", + }, + { + name: "size:M", + color: "fbca04", + description: "30-99 changed lines (additions + deletions).", + }, + { + name: "size:L", + color: "fe7d37", + description: "100-499 changed lines (additions + deletions).", + }, + { + name: "size:XL", + color: "d93f0b", + description: "500-999 changed lines (additions + deletions).", + }, + { + name: "size:XXL", + color: "b60205", + description: "1,000+ changed lines (additions + deletions).", + }, + ]; + + 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"; + }; + + for (const label of managedLabels) { + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + }); + + if ( + existing.color !== label.color || + (existing.description ?? "") !== label.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } + } + } + + const nextLabelName = resolveSizeLabel(changedLines); + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + + for (const label of currentLabels) { + if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { + continue; + } + + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: label.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + if (!currentLabels.some((label) => label.name === nextLabelName)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [nextLabelName], + }); + } + + core.info(`PR #${issueNumber}: ${changedLines} changed lines -> ${nextLabelName}`); diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml index 976a9a097..350a71b6b 100644 --- a/.github/workflows/pr-vouch.yml +++ b/.github/workflows/pr-vouch.yml @@ -111,7 +111,7 @@ jobs: }, ]; - const managedLabelNames = managedLabels.map((label) => label.name); + const managedLabelNames = new Set(managedLabels.map((label) => label.name)); for (const label of managedLabels) { try { @@ -138,13 +138,19 @@ jobs: throw error; } - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } } } @@ -159,17 +165,35 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, + per_page: 100, }); - const preservedLabels = currentLabels - .map((label) => label.name) - .filter((name) => !managedLabelNames.includes(name)); + for (const label of currentLabels) { + if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { + continue; + } - await github.rest.issues.setLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [...preservedLabels, nextLabelName], - }); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: label.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + if (!currentLabels.some((label) => label.name === nextLabelName)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [nextLabelName], + }); + } core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2021ecdcb..8b734a99b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ You can still open an issue or PR, but please do so knowing there is a high chan If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control. -PRs are automatically labeled with a `vouch:*` trust status. +PRs are automatically labeled with a `vouch:*` trust status and a `size:*` diff size based on changed lines. If you are an external contributor, expect `vouch:unvouched` until we explicitly add you to [.github/VOUCHED.td](.github/VOUCHED.td). From 5a3bd1d83970a12c907d1bbc83c06d2b8ace97d8 Mon Sep 17 00:00:00 2001 From: Magnus Buvarp Date: Thu, 12 Mar 2026 18:42:17 +0100 Subject: [PATCH 03/15] fix(web): resolve preferred editor from available editors & introduce `useLocalStorage` helper (#662) Co-authored-by: Julius Marminge --- apps/web/src/appSettings.test.ts | 15 -- apps/web/src/appSettings.ts | 128 ++-------------- apps/web/src/components/ChatMarkdown.tsx | 4 +- apps/web/src/components/ChatView.logic.ts | 21 +-- apps/web/src/components/ChatView.tsx | 27 +--- apps/web/src/components/DiffPanel.tsx | 5 +- apps/web/src/components/GitActionsControl.tsx | 5 +- .../src/components/ThreadTerminalDrawer.tsx | 4 +- apps/web/src/components/chat/OpenInPicker.tsx | 99 ++++++------ apps/web/src/components/ui/sidebar.tsx | 8 +- apps/web/src/editorPreferences.ts | 35 +++++ apps/web/src/hooks/useLocalStorage.ts | 143 ++++++++++++++++++ apps/web/src/routes/__root.tsx | 12 +- apps/web/src/routes/_chat.settings.tsx | 14 +- apps/web/src/terminal-links.ts | 22 --- 15 files changed, 283 insertions(+), 259 deletions(-) create mode 100644 apps/web/src/editorPreferences.ts create mode 100644 apps/web/src/hooks/useLocalStorage.ts diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 213e4cd3d..e7e9a67e1 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { getAppModelOptions, - getSlashModelOptions, normalizeCustomModelSlugs, resolveAppModelSelection, } from "./appSettings"; @@ -58,17 +57,3 @@ describe("resolveAppModelSelection", () => { expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); }); }); - -describe("getSlashModelOptions", () => { - it("includes saved custom model slugs for /model command suggestions", () => { - const options = getSlashModelOptions("codex", ["custom/internal-model"], "", "gpt-5.3-codex"); - - expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true); - }); - - it("filters slash-model suggestions across built-in and custom model names", () => { - const options = getSlashModelOptions("codex", ["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex"); - - expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); - }); -}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 5ed218fb2..e5018e0bf 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,7 +1,8 @@ -import { useCallback, useSyncExternalStore } from "react"; +import { useCallback } from "react"; import { Option, Schema } from "effect"; import { type ProviderKind } from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { useLocalStorage } from "./hooks/useLocalStorage"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; @@ -34,10 +35,6 @@ export interface AppModelOption { const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); -let listeners: Array<() => void> = []; -let cachedRawSettings: string | null | undefined; -let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; - export function normalizeCustomModelSlugs( models: Iterable, provider: ProviderKind = "codex", @@ -67,13 +64,6 @@ export function normalizeCustomModelSlugs( return normalizedModels; } -function normalizeAppSettings(settings: AppSettings): AppSettings { - return { - ...settings, - customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), - }; -} - export function getAppModelOptions( provider: ProviderKind, customModels: readonly string[], @@ -143,112 +133,26 @@ export function resolveAppModelSelection( ); } -export function getSlashModelOptions( - provider: ProviderKind, - customModels: readonly string[], - query: string, - selectedModel?: string | null, -): AppModelOption[] { - const normalizedQuery = query.trim().toLowerCase(); - const options = getAppModelOptions(provider, customModels, selectedModel); - if (!normalizedQuery) { - return options; - } - - return options.filter((option) => { - const searchSlug = option.slug.toLowerCase(); - const searchName = option.name.toLowerCase(); - return searchSlug.includes(normalizedQuery) || searchName.includes(normalizedQuery); - }); -} - -function emitChange(): void { - for (const listener of listeners) { - listener(); - } -} - -function parsePersistedSettings(value: string | null): AppSettings { - if (!value) { - return DEFAULT_APP_SETTINGS; - } - - try { - return normalizeAppSettings(Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value)); - } catch { - return DEFAULT_APP_SETTINGS; - } -} - -export function getAppSettingsSnapshot(): AppSettings { - if (typeof window === "undefined") { - return DEFAULT_APP_SETTINGS; - } - - const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY); - if (raw === cachedRawSettings) { - return cachedSnapshot; - } - - cachedRawSettings = raw; - cachedSnapshot = parsePersistedSettings(raw); - return cachedSnapshot; -} - -function persistSettings(next: AppSettings): void { - if (typeof window === "undefined") return; - - const raw = JSON.stringify(next); - try { - if (raw !== cachedRawSettings) { - window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw); - } - } catch { - // Best-effort persistence only. - } - - cachedRawSettings = raw; - cachedSnapshot = next; -} - -function subscribe(listener: () => void): () => void { - listeners.push(listener); - - const onStorage = (event: StorageEvent) => { - if (event.key === APP_SETTINGS_STORAGE_KEY) { - emitChange(); - } - }; - - window.addEventListener("storage", onStorage); - return () => { - listeners = listeners.filter((entry) => entry !== listener); - window.removeEventListener("storage", onStorage); - }; -} - export function useAppSettings() { - const settings = useSyncExternalStore( - subscribe, - getAppSettingsSnapshot, - () => DEFAULT_APP_SETTINGS, + const [settings, setSettings] = useLocalStorage( + APP_SETTINGS_STORAGE_KEY, + DEFAULT_APP_SETTINGS, + AppSettingsSchema, ); - const updateSettings = useCallback((patch: Partial) => { - const next = normalizeAppSettings( - Schema.decodeSync(AppSettingsSchema)({ - ...getAppSettingsSnapshot(), + const updateSettings = useCallback( + (patch: Partial) => { + setSettings((prev) => ({ + ...prev, ...patch, - }), - ); - persistSettings(next); - emitChange(); - }, []); + })); + }, + [setSettings], + ); const resetSettings = useCallback(() => { - persistSettings(DEFAULT_APP_SETTINGS); - emitChange(); - }, []); + setSettings(DEFAULT_APP_SETTINGS); + }, [setSettings]); return { settings, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 7a7cd45c7..72357c8c9 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -24,13 +24,13 @@ import React, { import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; -import { preferredTerminalEditor } from "../terminal-links"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -258,7 +258,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { event.stopPropagation(); const api = readNativeApi(); if (api) { - void api.shell.openInEditor(targetPath, preferredTerminalEditor()); + void openInPreferredEditor(api, targetPath); } else { console.warn("Native API not found. Unable to open file in editor."); } diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 4cd64af9f..59e290431 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,29 +1,14 @@ -import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; import { getAppModelOptions } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { Schema } from "effect"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const WORKTREE_BRANCH_PREFIX = "t3code"; -export function readLastInvokedScriptByProjectFromStorage(): Record { - const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); - if (!stored) return {}; - - try { - const parsed: unknown = JSON.parse(stored); - if (!parsed || typeof parsed !== "object") return {}; - return Object.fromEntries( - Object.entries(parsed).filter( - (entry): entry is [string, string] => - typeof entry[0] === "string" && typeof entry[1] === "string", - ), - ); - } catch { - return {}; - } -} +export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); export function buildLocalDraftThread( threadId: ThreadId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d81e024f3..76a5610ce 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -148,13 +148,14 @@ import { collectUserMessageBlobPreviewUrls, getCustomModelOptionsByProvider, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + LastInvokedScriptByProjectSchema, PullRequestDialogState, readFileAsDataUrl, - readLastInvokedScriptByProjectFromStorage, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, SendPhase, } from "./ChatView.logic"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -267,9 +268,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const [composerTrigger, setComposerTrigger] = useState(() => detectComposerTrigger(prompt, prompt.length), ); - const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< - Record - >(() => readLastInvokedScriptByProjectFromStorage()); + const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( + LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + {}, + LastInvokedScriptByProjectSchema, + ); const messagesScrollRef = useRef(null); const [messagesScrollElement, setMessagesScrollElement] = useState(null); const shouldAutoScrollRef = useRef(true); @@ -1196,6 +1199,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setThreadError, storeNewTerminal, storeSetActiveTerminal, + setLastInvokedScriptByProjectId, terminalState.activeTerminalId, terminalState.runningTerminalIds, terminalState.terminalIds, @@ -1438,21 +1442,6 @@ export default function ChatView({ threadId }: ChatViewProps) { [serverThread], ); - useEffect(() => { - try { - if (Object.keys(lastInvokedScriptByProjectId).length === 0) { - localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); - return; - } - localStorage.setItem( - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - JSON.stringify(lastInvokedScriptByProjectId), - ); - } catch { - // Ignore storage write failures (private mode, quota exceeded, etc.) - } - }, [lastInvokedScriptByProjectId]); - // Auto-scroll on new messages const messageCount = timelineMessages.length; const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 1293f51f7..b0f1b91c3 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -12,11 +12,12 @@ import { useRef, useState, } from "react"; +import { openInPreferredEditor } from "../editorPreferences"; import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readNativeApi } from "../nativeApi"; -import { preferredTerminalEditor, resolvePathLinkTarget } from "../terminal-links"; +import { resolvePathLinkTarget } from "../terminal-links"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; @@ -311,7 +312,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const api = readNativeApi(); if (!api) return; const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; - void api.shell.openInEditor(targetPath, preferredTerminalEditor()).catch((error) => { + void openInPreferredEditor(api, targetPath).catch((error) => { console.warn("Failed to open diff file in editor.", error); }); }, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 379fb5a84..ea1865bc8 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -31,6 +31,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; import { toastManager } from "~/components/ui/toast"; +import { openInPreferredEditor } from "~/editorPreferences"; import { gitBranchesQueryOptions, gitInitMutationOptions, @@ -40,7 +41,7 @@ import { gitStatusQueryOptions, invalidateGitQueries, } from "~/lib/gitReactQuery"; -import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; +import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; interface GitActionsControlProps { @@ -581,7 +582,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions return; } const target = resolvePathLinkTarget(filePath, gitCwd); - void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { + void openInPreferredEditor(api, target).catch((error) => { toastManager.add({ type: "error", title: "Unable to open file", diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index cce2ed133..7861212e4 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -12,10 +12,10 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { openInPreferredEditor } from "../editorPreferences"; import { extractTerminalLinks, isTerminalLinkActivation, - preferredTerminalEditor, resolvePathLinkTarget, } from "../terminal-links"; import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; @@ -236,7 +236,7 @@ function TerminalViewport({ } const target = resolvePathLinkTarget(match.text, cwd); - void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { + void openInPreferredEditor(api, target).catch((error) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open path", diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 701204c1b..2e09da03c 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,6 +1,7 @@ -import { EDITORS, type EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; +import { usePreferredEditor } from "../../editorPreferences"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; @@ -9,7 +10,35 @@ import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -const LAST_EDITOR_KEY = "t3code:last-editor"; +const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { + const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ + { + label: "Cursor", + Icon: CursorIcon, + value: "cursor", + }, + { + label: "VS Code", + Icon: VisualStudioCode, + value: "vscode", + }, + { + label: "Zed", + Icon: Zed, + value: "zed", + }, + { + label: isMacPlatform(platform) + ? "Finder" + : isWindowsPlatform(platform) + ? "Explorer" + : "Files", + Icon: FolderClosedIcon, + value: "file-manager", + }, + ]; + return baseOptions.filter((option) => availableEditors.includes(option.value)); +}; export const OpenInPicker = memo(function OpenInPicker({ keybindings, @@ -20,61 +49,23 @@ export const OpenInPicker = memo(function OpenInPicker({ availableEditors: ReadonlyArray; openInCwd: string | null; }) { - const [lastEditor, setLastEditor] = useState(() => { - const stored = localStorage.getItem(LAST_EDITOR_KEY); - return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; - }); - - const allOptions = useMemo>( - () => [ - { - label: "Cursor", - Icon: CursorIcon, - value: "cursor", - }, - { - label: "VS Code", - Icon: VisualStudioCode, - value: "vscode", - }, - { - label: "Zed", - Icon: Zed, - value: "zed", - }, - { - label: isMacPlatform(navigator.platform) - ? "Finder" - : isWindowsPlatform(navigator.platform) - ? "Explorer" - : "Files", - Icon: FolderClosedIcon, - value: "file-manager", - }, - ], - [], - ); + const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); const options = useMemo( - () => allOptions.filter((option) => availableEditors.includes(option.value)), - [allOptions, availableEditors], + () => resolveOptions(navigator.platform, availableEditors), + [availableEditors], ); - - const effectiveEditor = options.some((option) => option.value === lastEditor) - ? lastEditor - : (options[0]?.value ?? null); - const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; + const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; const openInEditor = useCallback( (editorId: EditorId | null) => { const api = readNativeApi(); if (!api || !openInCwd) return; - const editor = editorId ?? effectiveEditor; + const editor = editorId ?? preferredEditor; if (!editor) return; void api.shell.openInEditor(openInCwd, editor); - localStorage.setItem(LAST_EDITOR_KEY, editor); - setLastEditor(editor); + setPreferredEditor(editor); }, - [effectiveEditor, openInCwd, setLastEditor], + [preferredEditor, openInCwd, setPreferredEditor], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -87,22 +78,22 @@ export const OpenInPicker = memo(function OpenInPicker({ const api = readNativeApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!api || !openInCwd) return; - if (!effectiveEditor) return; + if (!preferredEditor) return; e.preventDefault(); - void api.shell.openInEditor(openInCwd, effectiveEditor); + void api.shell.openInEditor(openInCwd, preferredEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [effectiveEditor, keybindings, openInCwd]); + }, [preferredEditor, keybindings, openInCwd]); return ( + )} + + {!gitStatusForActions || allFiles.length === 0 ? (

none

) : (
- {gitStatusForActions.workingTree.files.map((file) => ( - - ))} + {allFiles.map((file) => { + const isExcluded = excludedFiles.has(file.path); + return ( +
+ {isEditingFiles && ( + { + setExcludedFiles((prev) => { + const next = new Set(prev); + if (next.has(file.path)) { + next.delete(file.path); + } else { + next.add(file.path); + } + return next; + }); + }} + /> + )} + +
+ ); + })}
- +{gitStatusForActions.workingTree.insertions} + +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / - -{gitStatusForActions.workingTree.deletions} + -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
@@ -806,14 +902,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions onClick={() => { setIsCommitDialogOpen(false); setDialogCommitMessage(""); + setExcludedFiles(new Set()); + setIsEditingFiles(false); }} > Cancel - - diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index fe0c4705b..9b5fe7731 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -119,10 +119,12 @@ export function gitRunStackedActionMutationOptions(input: { action, commitMessage, featureBranch, + filePaths, }: { action: GitStackedAction; commitMessage?: string; featureBranch?: boolean; + filePaths?: string[]; }) => { const api = ensureNativeApi(); if (!input.cwd) throw new Error("Git action is unavailable."); @@ -131,6 +133,7 @@ export function gitRunStackedActionMutationOptions(input: { action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + ...(filePaths ? { filePaths } : {}), }); }, onSettled: async () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 34ab11b16..081b4d0d8 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -61,6 +61,9 @@ export const GitRunStackedActionInput = Schema.Struct({ action: GitStackedAction, commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), featureBranch: Schema.optional(Schema.Boolean), + filePaths: Schema.optional( + Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), + ), }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; From 9c04c9ce0f526c428339b6dab3400a61d08e3fb1 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 14:10:58 -0400 Subject: [PATCH 06/15] add Ymit24 to vouched list (#959) --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index aff0fe55e..72f270dca 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -28,3 +28,4 @@ github:realAhmedRoach github:shiroyasha9 github:Yash-Singh1 github:eggfriedrice24 +github:Ymit24 \ No newline at end of file From 059441f0d0ece12c0fd322300e239493c7a004be Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 13 Mar 2026 07:12:18 +1300 Subject: [PATCH 07/15] fix: Fix response duration for agent to no longer always be 1ms (#866) --- .../chat/MessagesTimeline.logic.test.ts | 135 ++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 25 ++++ .../src/components/chat/MessagesTimeline.tsx | 11 +- 3 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/chat/MessagesTimeline.logic.test.ts create mode 100644 apps/web/src/components/chat/MessagesTimeline.logic.ts diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts new file mode 100644 index 000000000..7074f4601 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { computeMessageDurationStart } from "./MessagesTimeline.logic"; + +describe("computeMessageDurationStart", () => { + it("returns message createdAt when there is no preceding user message", () => { + const result = computeMessageDurationStart([ + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:05Z", + completedAt: "2026-01-01T00:00:10Z", + }, + ]); + expect(result).toEqual(new Map([["a1", "2026-01-01T00:00:05Z"]])); + }); + + it("uses the user message createdAt for the first assistant response", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ]), + ); + }); + + it("uses the previous assistant completedAt for subsequent assistant responses", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + { + id: "a2", + role: "assistant", + createdAt: "2026-01-01T00:00:55Z", + completedAt: "2026-01-01T00:00:55Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ["a2", "2026-01-01T00:00:30Z"], + ]), + ); + }); + + it("does not advance the boundary for a streaming message without completedAt", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z" }, + { + id: "a2", + role: "assistant", + createdAt: "2026-01-01T00:00:55Z", + completedAt: "2026-01-01T00:00:55Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ["a2", "2026-01-01T00:00:00Z"], + ]), + ); + }); + + it("resets the boundary on a new user message", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + { id: "u2", role: "user", createdAt: "2026-01-01T00:01:00Z" }, + { + id: "a2", + role: "assistant", + createdAt: "2026-01-01T00:01:20Z", + completedAt: "2026-01-01T00:01:20Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ["u2", "2026-01-01T00:01:00Z"], + ["a2", "2026-01-01T00:01:00Z"], + ]), + ); + }); + + it("handles system messages without affecting the boundary", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { id: "s1", role: "system", createdAt: "2026-01-01T00:00:01Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["s1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ]), + ); + }); + + it("returns empty map for empty input", () => { + expect(computeMessageDurationStart([])).toEqual(new Map()); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts new file mode 100644 index 000000000..45408468c --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -0,0 +1,25 @@ +export interface TimelineDurationMessage { + id: string; + role: "user" | "assistant" | "system"; + createdAt: string; + completedAt?: string | undefined; +} + +export function computeMessageDurationStart( + messages: ReadonlyArray, +): Map { + const result = new Map(); + let lastBoundary: string | null = null; + + for (const message of messages) { + if (message.role === "user") { + lastBoundary = message.createdAt; + } + result.set(message.id, lastBoundary ?? message.createdAt); + if (message.role === "assistant" && message.completedAt) { + lastBoundary = message.completedAt; + } + } + + return result; +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 3bfef9d87..7a89e762e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -19,6 +19,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; +import { computeMessageDurationStart } from "./MessagesTimeline.logic"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -98,6 +99,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const rows = useMemo(() => { const nextRows: TimelineRow[] = []; + const durationStartByMessageId = computeMessageDurationStart( + timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), + ); for (let index = 0; index < timelineEntries.length; index += 1) { const timelineEntry = timelineEntries[index]; @@ -139,6 +143,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ id: timelineEntry.id, createdAt: timelineEntry.createdAt, message: timelineEntry.message, + durationStart: + durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, showCompletionDivider: timelineEntry.message.role === "assistant" && completionDividerBeforeEntryId === timelineEntry.id, @@ -507,8 +513,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {formatMessageMeta( row.message.createdAt, row.message.streaming - ? formatElapsed(row.message.createdAt, nowIso) - : formatElapsed(row.message.createdAt, row.message.completedAt), + ? formatElapsed(row.durationStart, nowIso) + : formatElapsed(row.durationStart, row.message.completedAt), )}

@@ -605,6 +611,7 @@ type TimelineRow = id: string; createdAt: string; message: TimelineMessage; + durationStart: string; showCompletionDivider: boolean; } | { From f3dce627c72929f4a7bdc70e3fb0ff32b1d61a30 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 14:13:37 -0400 Subject: [PATCH 08/15] fix: fix logo aligment regression on macOS (#960) --- apps/web/src/components/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5bb0b84f7..37fb45d01 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1227,7 +1227,7 @@ export default function Sidebar() { +
Code From f2202eb1057f0a2cdbd65c044f26bcafd68306c0 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 14:24:10 -0400 Subject: [PATCH 09/15] fix: improve business logic in prompt editor and fix cursor bugs in Plan mode (#867) Co-authored-by: Julius Marminge --- apps/web/src/components/ChatView.tsx | 41 +++++++++++++++---- .../src/components/ComposerPromptEditor.tsx | 29 ++++++++----- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4fb0bfb86..22717291d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -633,23 +633,46 @@ export default function ChatView({ threadId }: ChatViewProps) { pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + const lastSyncedPendingInputRef = useRef<{ + requestId: string | null; + questionId: string | null; + } | null>(null); useEffect(() => { - if (!activePendingProgress) { + const nextCustomAnswer = activePendingProgress?.customAnswer; + if (typeof nextCustomAnswer !== "string") { + lastSyncedPendingInputRef.current = null; return; } - promptRef.current = activePendingProgress.customAnswer; - setComposerCursor(activePendingProgress.customAnswer.length); + const nextRequestId = activePendingUserInput?.requestId ?? null; + const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; + const questionChanged = + lastSyncedPendingInputRef.current?.requestId !== nextRequestId || + lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; + const textChangedExternally = promptRef.current !== nextCustomAnswer; + + lastSyncedPendingInputRef.current = { + requestId: nextRequestId, + questionId: nextQuestionId, + }; + + if (!questionChanged && !textChangedExternally) { + return; + } + + promptRef.current = nextCustomAnswer; + setComposerCursor(nextCustomAnswer.length); setComposerTrigger( detectComposerTrigger( - activePendingProgress.customAnswer, - expandCollapsedComposerCursor( - activePendingProgress.customAnswer, - activePendingProgress.customAnswer.length, - ), + nextCustomAnswer, + expandCollapsedComposerCursor(nextCustomAnswer, nextCustomAnswer.length), ), ); setComposerHighlightedItemId(null); - }, [activePendingProgress, activePendingUserInput?.requestId]); + }, [ + activePendingProgress?.customAnswer, + activePendingUserInput?.requestId, + activePendingProgress?.activeQuestion?.id, + ]); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 96efc0fbf..f9321de65 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -629,6 +629,7 @@ function ComposerPromptEditorInner({ const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); const snapshotRef = useRef({ value, cursor: clampCursor(value, cursor) }); + const isApplyingControlledUpdateRef = useRef(false); useEffect(() => { onChangeRef.current = onChange; @@ -645,21 +646,26 @@ function ComposerPromptEditorInner({ return; } - if (previousSnapshot.value !== value) { - editor.update(() => { - $setComposerEditorPrompt(value); - }); - } - snapshotRef.current = { value, cursor: normalizedCursor }; const rootElement = editor.getRootElement(); - if (!rootElement || document.activeElement !== rootElement) { + const isFocused = Boolean(rootElement && document.activeElement === rootElement); + if (previousSnapshot.value === value && !isFocused) { return; } + isApplyingControlledUpdateRef.current = true; editor.update(() => { - $setSelectionAtComposerOffset(normalizedCursor); + const valueChanged = previousSnapshot.value !== value; + if (previousSnapshot.value !== value) { + $setComposerEditorPrompt(value); + } + if (valueChanged || isFocused) { + $setSelectionAtComposerOffset(normalizedCursor); + } + }); + queueMicrotask(() => { + isApplyingControlledUpdateRef.current = false; }); }, [cursor, editor, value]); @@ -705,9 +711,7 @@ function ComposerPromptEditorInner({ focus: () => { focusAt(snapshotRef.current.cursor); }, - focusAt: (nextCursor: number) => { - focusAt(nextCursor); - }, + focusAt, focusAtEnd: () => { focusAt(snapshotRef.current.value.length); }, @@ -728,6 +732,9 @@ function ComposerPromptEditorInner({ if (previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor) { return; } + if (isApplyingControlledUpdateRef.current) { + return; + } snapshotRef.current = { value: nextValue, cursor: nextCursor, From 662a5ec71ac9965405e9262eb351f3af62db473e Mon Sep 17 00:00:00 2001 From: Donald Silveira Date: Thu, 12 Mar 2026 15:48:23 -0300 Subject: [PATCH 10/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 37fb45d01..9d911e2f0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -895,7 +895,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; @@ -908,14 +908,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 { @@ -930,11 +928,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 7ddd971582cc78c153943981bec7ee632e5a80e0 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 | 130 +------------------ apps/web/src/hooks/useHandleNewThread.ts | 113 ++++++++++++++++ apps/web/src/lib/terminalFocus.ts | 6 + apps/web/src/routes/_chat.tsx | 79 +++++++++++ 6 files changed, 221 insertions(+), 133 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 d8b74c8cc..084e835ac 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 22717291d..f047f384e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -117,6 +117,7 @@ import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { resolveAppModelSelection, useAppSettings } from "../appSettings"; +import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -1937,13 +1938,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 9d911e2f0..baf17fa99 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,14 +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, useComposerDraftStore } from "../composerDraftStore"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { @@ -262,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, ); @@ -276,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), @@ -384,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 ?? "local", - runtimeMode: DEFAULT_RUNTIME_MODE, - }); - - await navigate({ - to: "/$threadId", - params: { threadId }, - }); - })(); - }, - [ - clearProjectDraftThreadId, - getDraftThreadByProjectId, - navigate, - getDraftThread, - routeThreadId, - setDraftThreadContext, - setProjectDraftThreadId, - ], - ); - const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { const latestThread = threads @@ -1021,37 +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" : "local"), - }); - }; - const onMouseDown = (event: globalThis.MouseEvent) => { if (selectedThreadIds.size === 0) return; const target = event.target instanceof HTMLElement ? event.target : null; @@ -1059,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 000000000..e00fd6af2 --- /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, 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 ?? "local", + 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 000000000..d24c9572a --- /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 0d7f1724b..8e3145d99 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,10 +1,88 @@ +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 { 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" : "local"), + }); + }; + + 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 +104,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 | 45 ++++--- apps/web/src/terminalStateStore.test.ts | 49 +++++++ apps/web/src/terminalStateStore.ts | 19 +-- apps/web/src/types.ts | 2 +- 10 files changed, 306 insertions(+), 73 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 084e835ac..cb7dea0ce 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 f047f384e..327789db6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -76,7 +76,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"; @@ -1037,7 +1037,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; @@ -1085,17 +1094,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; @@ -1162,8 +1171,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 634d91e9d..f35f87826 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 f1afa0f64..8acbed63a 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 baf17fa99..5ffd6de92 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 7861212e4..8e480715f 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 e00fd6af2..35f92d98e 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,32 +1,29 @@ import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; import { useCallback } from "react"; -import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; +import { + type DraftThreadEnvMode, + type DraftThreadState, + 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 +34,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 +70,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 +103,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 1f48b1693..e7e240cf2 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 cf1ea8446..b2cea6d56 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 d5fff1299..c071fb3f6 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 e66a5ee09733202465a123ca425a95d67dfa0719 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 12:01:27 -0700 Subject: [PATCH 13/15] fix pr size workflow --- .github/workflows/pr-size.yml | 118 +++++++++++++++++++++++----------- 1 file changed, 80 insertions(+), 38 deletions(-) diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index 7a338a76e..8e50efded 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -3,29 +3,29 @@ 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 - issues: write - pull-requests: read 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 managedLabels = [ { name: "size:XS", @@ -59,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 { @@ -125,6 +117,56 @@ jobs: } } } + 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); From 36140060568243777ad69d7c54f366a7e8d3f4b4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 12:01:55 -0700 Subject: [PATCH 14/15] remove triggers --- .github/workflows/pr-size.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index 8e50efded..52818435d 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -3,12 +3,6 @@ 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 From f8bf4487366a9c6b4dc57e2cd4cab57935c489eb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 12:33:42 -0700 Subject: [PATCH 15/15] fix --- apps/web/src/components/ChatMarkdown.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 72357c8c9..aaf8367dc 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,12 +1,3 @@ - // 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, - ); - getSharedHighlighter, - type DiffsHighlighter, - type SupportedLanguages, -} from "@pierre/diffs"; import { CheckIcon, CopyIcon } from "lucide-react"; import React, { Children, @@ -31,6 +22,7 @@ import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; +import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -218,6 +210,11 @@ function SuspenseShikiCodeBlock({ try { return highlighter.codeToHtml(code, { lang: language, theme: themeName }); } 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 }); }