From ef8ce025914051258d9739f462030e14f9cf575c Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 01/14] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From 19c2e85ec7f6b322dd761124538aa4f7e8821a17 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 02/14] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From b4853df4426f73edd95059cb9efb6086349b2dec Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 03/14] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From 0c47553e2f118b4b594ee001d25cd78d9574723a Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 04/14] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From fcfa398c0a1531cd2c5674b376912aadd5dda378 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:55:06 +0200 Subject: [PATCH 05/14] =?UTF-8?q?feat(web):=20configurable=20fonts=20and?= =?UTF-8?q?=20sizes=20under=20Settings=20=E2=86=92=20Appearance=20(#5103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Fable 5 Co-authored-by: Julius Marminge --- apps/desktop/src/preview/BrowserSession.ts | 4 + .../settings/DesktopClientSettings.test.ts | 9 + apps/web/index.html | 9 +- apps/web/package.json | 2 - apps/web/src/appearanceFonts.test.ts | 71 +++ apps/web/src/appearanceFonts.ts | 352 ++++++++++++ apps/web/src/components/BranchToolbar.tsx | 102 +++- .../BranchToolbarBranchSelector.tsx | 7 +- .../BranchToolbarEnvModeSelector.tsx | 9 +- .../BranchToolbarEnvironmentSelector.tsx | 14 +- .../src/components/ComposerPromptEditor.tsx | 8 +- apps/web/src/components/SidebarV2.tsx | 4 +- .../src/components/ThreadTerminalDrawer.tsx | 32 +- apps/web/src/components/composerInlineChip.ts | 17 +- .../components/settings/FontFamilyPicker.tsx | 254 +++++++++ .../settings/SettingsFontPreviews.tsx | 232 ++++++++ .../components/settings/SettingsPanels.tsx | 534 +++++++++++++++++- .../components/settings/settingsLayout.tsx | 5 + .../src/components/settings/settingsSearch.ts | 25 + apps/web/src/index.css | 118 ++-- apps/web/src/main.tsx | 3 - apps/web/src/routes/__root.tsx | 34 ++ apps/web/src/terminal/ghostty/surface.test.ts | 36 ++ apps/web/src/terminal/ghostty/surface.ts | 87 ++- packages/contracts/src/settings.ts | 72 +++ pnpm-lock.yaml | 16 - 26 files changed, 1934 insertions(+), 122 deletions(-) create mode 100644 apps/web/src/appearanceFonts.test.ts create mode 100644 apps/web/src/appearanceFonts.ts create mode 100644 apps/web/src/components/settings/FontFamilyPicker.tsx create mode 100644 apps/web/src/components/settings/SettingsFontPreviews.tsx diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index aa0b0743e933..e11d25bbed77 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -23,6 +23,10 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-sanitized-write", "notifications", "geolocation", + // Deliberately NOT local-fonts: preview sessions run untrusted web content, + // and silently granting it would hand every page the user's installed-font + // fingerprint (and font file bytes via FontData.blob()). The app's own font + // picker runs in the main window session, which is unaffected by this list. ]); export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8d76ea83a33e..53ef74f21911 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,15 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", favorites: [], + fontFamilyCode: "", + fontFamilyComposer: "", + fontFamilySans: "", + fontFamilyTerminal: "", + fontSizeCode: 13, + fontSizeInterface: 16, + fontSizePrompt: 14, + fontSizeTerminal: 12, + fontSmoothing: true, glassOpacity: 80, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, diff --git a/apps/web/index.html b/apps/web/index.html index eccee92878b3..021bcb4156ce 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -47,14 +47,7 @@ body { background: #ffffff; color: #262626; - font-family: - "DM Sans Variable", - "DM Sans", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - system-ui, - sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; } html.dark body { diff --git a/apps/web/package.json b/apps/web/package.json index 764f05561e7c..fcc535a384c8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,8 +21,6 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", - "@fontsource-variable/dm-sans": "^5.2.8", - "@fontsource/jetbrains-mono": "^5.2.8", "@formkit/auto-animate": "^0.9.0", "@legendapp/list": "3.2.0", "@lexical/react": "^0.41.0", diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts new file mode 100644 index 000000000000..8467c13c2cef --- /dev/null +++ b/apps/web/src/appearanceFonts.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + clampCodeFontSize, + clampInterfaceFontSize, + clampPromptFontSize, + DEFAULT_CODE_FONT_STACK, + DEFAULT_SANS_FONT_STACK, + appearanceFontStack, + cssFontFamilies, + resolveDefaultFamilyLabel, +} from "./appearanceFonts"; + +describe("cssFontFamilies", () => { + it("returns null for effectively empty input", () => { + expect(cssFontFamilies("")).toBeNull(); + expect(cssFontFamilies(" ")).toBeNull(); + expect(cssFontFamilies(" , , ")).toBeNull(); + }); + + it("quotes names with spaces and keeps single idents bare", () => { + expect(cssFontFamilies("Fira Code")).toBe('"Fira Code"'); + expect(cssFontFamilies("monospace")).toBe("monospace"); + expect(cssFontFamilies('"Comic Mono"')).toBe('"Comic Mono"'); + }); + + it("normalizes comma-separated lists and strips embedded quotes", () => { + expect(cssFontFamilies(" Fira Code , Menlo ")).toBe('"Fira Code", Menlo'); + expect(cssFontFamilies('Bad"Name')).toBe('"BadName"'); + }); + + it("quotes names that are not single CSS idents", () => { + expect(cssFontFamilies("3270 Nerd Font")).toBe('"3270 Nerd Font"'); + expect(cssFontFamilies("M+ 1m")).toBe('"M+ 1m"'); + }); +}); + +describe("resolveDefaultFamilyLabel", () => { + it("skips generic keywords and returns null for a stack of only generics", () => { + expect(resolveDefaultFamilyLabel("system-ui, sans-serif")).toBeNull(); + expect(resolveDefaultFamilyLabel("ui-monospace, monospace")).toBeNull(); + }); +}); + +describe("appearanceFontStack", () => { + it("prepends the custom family to the default stack", () => { + expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( + `"Fira Code", ${DEFAULT_CODE_FONT_STACK}`, + ); + }); + + it("falls back to the default stack when unset", () => { + expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK); + }); +}); + +describe("font size clamping", () => { + it("keeps sizes inside the ranges the UI can absorb", () => { + expect(clampInterfaceFontSize(16)).toBe(16); + expect(clampInterfaceFontSize(2)).toBe(12); + expect(clampInterfaceFontSize(96)).toBe(20); + expect(clampPromptFontSize(40)).toBe(20); + expect(clampCodeFontSize(1)).toBe(10); + }); + + it("rounds fractional values and falls back for unusable input", () => { + expect(clampCodeFontSize(13.4)).toBe(13); + expect(clampInterfaceFontSize(Number.NaN)).toBe(16); + expect(clampPromptFontSize(Number.POSITIVE_INFINITY)).toBe(14); + }); +}); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts new file mode 100644 index 000000000000..3fb6c821a1b1 --- /dev/null +++ b/apps/web/src/appearanceFonts.ts @@ -0,0 +1,352 @@ +/** + * Font preferences from Settings → Appearance, applied as CSS custom + * properties. The default stacks mirror the `--font-sans` / `--font-mono` + * definitions in `index.css`; a custom family is always prepended to the + * matching default stack so glyph coverage never regresses. + */ + +import { + DEFAULT_CODE_FONT_SIZE, + DEFAULT_INTERFACE_FONT_SIZE, + DEFAULT_PROMPT_FONT_SIZE, + MAX_CODE_FONT_SIZE, + MAX_INTERFACE_FONT_SIZE, + MAX_PROMPT_FONT_SIZE, + MIN_CODE_FONT_SIZE, + MIN_INTERFACE_FONT_SIZE, + MIN_PROMPT_FONT_SIZE, +} from "@t3tools/contracts"; + +export const DEFAULT_SANS_FONT_STACK = + '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; + +// Concrete names first: some engines alias `ui-monospace` to the +// proportional system UI font, which would break every code surface. +export const DEFAULT_CODE_FONT_STACK = + '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; + +function quoteFontFamilyName(name: string): string { + const bare = name.trim(); + if (bare.length === 0) return ""; + // Already quoted, or a single ident that needs no quoting. + if (/^(['"]).*\1$/.test(bare)) return bare; + if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; + return `"${bare.replaceAll('"', "")}"`; +} + +/** + * Normalize a user-entered family (single name or comma-separated list) into a + * safe CSS font-family list, or null when the input is effectively empty. + */ +export function cssFontFamilies(input: string): string | null { + const families = input + .split(",") + .map(quoteFontFamilyName) + .filter((name) => name.length > 0); + return families.length > 0 ? families.join(", ") : null; +} + +/** The full stack a preference resolves to: custom families before the default. */ +export function appearanceFontStack(custom: string, defaultStack: string): string { + const families = cssFontFamilies(custom); + return families === null ? defaultStack : `${families}, ${defaultStack}`; +} + +export interface AppearanceFontPreferences { + readonly sans: string; + readonly code: string; + readonly composer: string; + readonly sizeInterface: number; + readonly sizePrompt: number; + readonly sizeCode: number; + /** Grayscale `antialiased` rendering; false keeps the heavier platform default. */ + readonly smoothing: boolean; +} + +/** + * Apply the preferences to the root element. Unset families remove the + * override so the stylesheet defaults (and theme changes) stay in charge. + * + * Sizes are always written: the interface size drives the root font size (and + * with it every rem-based dimension), while the prompt and code sizes stay in + * absolute pixels so they do not scale twice. + */ +export function applyAppearanceFontVariables( + root: HTMLElement, + preferences: AppearanceFontPreferences, +): void { + const families: ReadonlyArray = [ + ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK], + ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK], + // The composer falls back to whatever the sans preference resolves to. + ["--font-composer", preferences.composer, "var(--font-sans)"], + ]; + for (const [variable, custom, fallback] of families) { + const list = cssFontFamilies(custom); + if (list === null) { + root.style.removeProperty(variable); + } else { + root.style.setProperty(variable, `${list}, ${fallback}`); + } + } + + root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`; + root.style.setProperty("--font-size-prompt", `${clampPromptFontSize(preferences.sizePrompt)}px`); + const code = clampCodeFontSize(preferences.sizeCode); + root.style.setProperty("--font-size-code", `${code}px`); + // The @pierre/diffs surfaces read their own hook for code text. + root.style.setProperty("--diffs-font-size", `${code}px`); + + // Inherited from the root; only macOS engines honor the property, so no + // platform gate is needed here. Smoothing on means grayscale `antialiased` + // (thinner strokes); off restores the platform default, which macOS renders + // with heavier stem darkening. + if (preferences.smoothing) { + root.style.setProperty("-webkit-font-smoothing", "antialiased"); + } else { + root.style.removeProperty("-webkit-font-smoothing"); + } +} + +function clampFontSize(value: number, minimum: number, maximum: number, fallback: number): number { + if (!Number.isFinite(value)) return fallback; + return Math.min(maximum, Math.max(minimum, Math.round(value))); +} + +export function clampInterfaceFontSize(value: number): number { + return clampFontSize( + value, + MIN_INTERFACE_FONT_SIZE, + MAX_INTERFACE_FONT_SIZE, + DEFAULT_INTERFACE_FONT_SIZE, + ); +} + +export function clampPromptFontSize(value: number): number { + return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE); +} + +export function clampCodeFontSize(value: number): number { + return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE); +} + +const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord"; +let fontProbeContext: CanvasRenderingContext2D | null | undefined; + +function probeWidth(fontList: string): number | null { + if (fontProbeContext === undefined) { + fontProbeContext = document.createElement("canvas").getContext("2d"); + } + if (fontProbeContext === null) return null; + fontProbeContext.font = `16px ${fontList}`; + return fontProbeContext.measureText(FONT_PROBE_TEXT).width; +} + +/** + * Canvas metric probing instead of document.fonts.check(): check() reports + * true for families that are not installed at all (nothing needs loading), so + * it cannot filter the dropdown. A family exists when falling back to at + * least one generic changes the measured advance. + */ +export function isFontFamilyAvailable(family: string): boolean { + const families = cssFontFamilies(family); + if (families === null) return false; + if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true; + try { + for (const generic of ["monospace", "serif", "sans-serif"]) { + const baseline = probeWidth(generic); + const candidate = probeWidth(`${families}, ${generic}`); + if (baseline === null || candidate === null) return false; + if (candidate !== baseline) return true; + } + return false; + } catch { + return false; + } +} + +/** + * Whether a family renders every character on the same advance. Cell-grid + * surfaces (the terminal) require this: a proportional face draws its text + * narrower than the lattice the cursor and selection are placed on, which + * reads as ragged gaps and a cursor stranded to the right of the text. + * + * Unmeasurable environments answer true, so a missing canvas never blocks a + * legitimate font. + */ +export function isMonospaceFamily(family: string): boolean { + const families = cssFontFamilies(family); + if (families === null) return true; + try { + if (fontProbeContext === undefined) { + fontProbeContext = document.createElement("canvas").getContext("2d"); + } + if (fontProbeContext === null) return true; + // Fall back to a generic mono so an absent face measures as monospace and + // is left for the normal fallback chain to resolve. + fontProbeContext.font = `32px ${families}, monospace`; + const narrow = fontProbeContext.measureText("i").width; + const wide = fontProbeContext.measureText("M").width; + if (!Number.isFinite(narrow) || !Number.isFinite(wide) || wide === 0) return true; + return Math.abs(wide - narrow) < 0.5; + } catch { + return true; + } +} + +// Nameable faces the platform generics commonly map to, likeliest first. +// Pixel-comparing a generic against these names the actual face; Apple's own +// UI fonts are deliberately not CSS-nameable, so a miss on an Apple platform +// identifies San Francisco itself. +const SANS_GENERIC_CANDIDATES = [ + "Segoe UI", + "Roboto", + "Noto Sans", + "Ubuntu", + "Cantarell", + "DejaVu Sans", + "Liberation Sans", + "Helvetica Neue", + "Arial", +] as const; +const MONO_GENERIC_CANDIDATES = [ + "Menlo", + "Consolas", + "Cascadia Mono", + "DejaVu Sans Mono", + "Ubuntu Mono", + "Liberation Mono", + "Noto Sans Mono", + "Roboto Mono", + "Monaco", + "Courier New", +] as const; + +const GENERIC_PROBE_TEXT = "RagIl10O@ fjord quiz"; + +/** + * Advance width of the probe text laid out by the DOM - not canvas, whose + * generic-family mapping diverges from real rendering (this engine draws + * `ui-monospace` as the proportional UI font on canvas but not in CSS). + * Identical widths at this size mean the same face for practical purposes. + */ +function measureDomProbeWidth(fontFamily: string): number | null { + try { + const body = document.body; + if (!body) return null; + const span = document.createElement("span"); + span.style.cssText = + "position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre;font-size:100px;"; + span.style.fontFamily = fontFamily; + span.textContent = GENERIC_PROBE_TEXT; + body.appendChild(span); + const width = span.getBoundingClientRect().width; + span.remove(); + return width > 0 ? width : null; + } catch { + return null; + } +} + +function widthsMatch(left: number, right: number): boolean { + return Math.abs(left - right) < 0.01; +} + +/** + * Name the concrete face a generic keyword renders as, by measuring the + * generic against nameable candidates. Null when the face cannot be + * identified (and the platform gives no definitional answer). + */ +function resolveGenericFamilyLabel(generic: string): string | null { + const lower = generic.toLowerCase(); + if (lower === "serif") return null; + const monoLike = lower === "ui-monospace" || lower === "monospace"; + const genericWidth = measureDomProbeWidth(generic); + if (genericWidth === null) return null; + for (const candidate of monoLike ? MONO_GENERIC_CANDIDATES : SANS_GENERIC_CANDIDATES) { + if (!isFontFamilyAvailable(candidate)) continue; + const candidateWidth = measureDomProbeWidth(`"${candidate}"`); + if (candidateWidth !== null && widthsMatch(genericWidth, candidateWidth)) { + return candidate; + } + } + // No nameable face matched; on Apple platforms that means one of the San + // Francisco faces, which CSS cannot name. Comparing against -apple-system + // tells the UI face apart from SF Mono. + if (/mac|iphone|ipad|ipod/i.test(navigator.platform)) { + const systemWidth = measureDomProbeWidth("-apple-system"); + if (systemWidth !== null && widthsMatch(genericWidth, systemWidth)) return "SF Pro"; + return monoLike ? "SF Mono" : "SF Pro"; + } + return null; +} + +/** + * The first family of a default stack that will actually render - what the + * "Default" choice means on this machine. Concrete names are probed for + * availability; generic keywords are resolved to the face they draw with + * where identifiable. Null when nothing can be named. + */ +export function resolveDefaultFamilyLabel(stack: string): string | null { + for (const raw of stack.split(",")) { + const family = raw.trim().replace(/^(['"])(.*)\1$/, "$2"); + if (family.length === 0) continue; + if ( + /^(system-ui|sans-serif|serif|monospace|ui-monospace|-apple-system|BlinkMacSystemFont)$/i.test( + family, + ) + ) { + const resolved = resolveGenericFamilyLabel(family); + if (resolved !== null) return resolved; + continue; + } + if (isFontFamilyAvailable(family)) return family; + } + return null; +} + +export interface InstalledFontFamiliesResult { + readonly families: readonly string[]; + /** + * "unsupported" - the engine has no Local Font Access API (Safari, + * Firefox); "denied" - the API exists but the user declined the permission + * prompt. Both fall back to the curated catalog. + */ + readonly status: "granted" | "denied" | "unsupported"; +} + +let installedFamiliesCache: InstalledFontFamiliesResult | null = null; + +/** + * Every installed family via the Local Font Access API (Chromium and + * Electron). Call from a user gesture: the first call raises the browser's + * local-fonts permission prompt. A denial is not cached, so reopening the + * picker can ask again after the user changes the site setting. + */ +export async function queryInstalledFontFamilies(): Promise { + if (installedFamiliesCache !== null) return installedFamiliesCache; + const query = ( + window as Window & { + queryLocalFonts?: () => Promise>; + } + ).queryLocalFonts; + if (typeof query !== "function") { + installedFamiliesCache = { families: [], status: "unsupported" }; + return installedFamiliesCache; + } + try { + const fonts = await query.call(window); + const families = [...new Set(fonts.map((font) => font.family))] + // Dot-prefixed families are macOS-internal UI faces; selecting one is + // never intended and most refuse to render for web content anyway. + .filter((family) => !family.startsWith(".")) + .sort((left, right) => left.localeCompare(right)); + // A denied permission check resolves with an empty list instead of + // throwing; no machine has zero fonts, so treat empty as denied. + if (families.length === 0) return { families: [], status: "denied" }; + installedFamiliesCache = { families, status: "granted" }; + return installedFamiliesCache; + } catch { + return { families: [], status: "denied" }; + } +} diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index f7d1856da039..a3f043c65368 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -9,7 +9,7 @@ import { HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; @@ -214,6 +214,98 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ); }); +/** + * Collapse the strip's labels to icons only when the text no longer fits. + * + * Hidden labels stay measurable (they collapse to invisible absolute boxes, + * which keep their natural width), so the required width can be recomputed in + * either state on every pass - no remembered widths that could go stale or + * latch the strip compact. A small hysteresis keeps the boundary from + * flapping between states. + */ +const COMPACT_EXPAND_HYSTERESIS_PX = 16; + +function useLabelsOverflow(element: HTMLDivElement | null): boolean { + const [overflows, setOverflows] = useState(false); + // A render-synced mirror instead of useEffectEvent: the compiler memoizes + // the event callback, which left observers reading the first render's null + // element forever. + const stateRef = useRef({ element, overflows }); + stateRef.current = { element, overflows }; + + const measure = useCallback(() => { + const { element: current, overflows: compact } = stateRef.current; + if (!current) return; + const available = current.clientWidth; + if (available === 0) return; + // flex-1 stretches the groups to fill the strip, so their own boxes always + // measure "full". Sum the laid-out content instead, skipping hidden form + // artifacts and absolutely-positioned nodes (the compact-hidden labels). + const contentWidth = (parent: Element): number => { + const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; + let width = 0; + let counted = 0; + for (const child of parent.children) { + if (!(child instanceof HTMLElement)) continue; + if (child.offsetWidth <= 1) continue; + const position = getComputedStyle(child).position; + if (position === "absolute" || position === "fixed") continue; + width += child.offsetWidth; + counted += 1; + } + return width + gap * Math.max(0, counted - 1); + }; + const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0; + let needed = 0; + let groups = 0; + for (const child of current.children) { + if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; + needed += contentWidth(child); + groups += 1; + } + needed += stripGap * Math.max(0, groups - 1); + for (const label of current.querySelectorAll("[data-composer-label]")) { + // The clipping can happen below the marker (SelectValue truncates + // internally), where the outer span's scrollWidth matches its clipped + // box. The text's real width is the largest scrollWidth in the subtree. + let textWidth = label.scrollWidth; + for (const inner of label.querySelectorAll("*")) { + textWidth = Math.max(textWidth, inner.scrollWidth); + } + if (compact) { + // Compact: the label is squeezed to zero width but keeps reporting + // the full width it would need when expanded. + needed += textWidth; + } else { + // Expanded: the label is in flow; only the clipped remainder is + // missing from the content sum. + needed += Math.max(0, textWidth - label.clientWidth); + } + } + setOverflows(compact ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX : needed > available); + }, []); + + // Label widths can change without the strip box moving (font family or + // size preferences), so re-measure on every render as well as on resize + // and font loads. + useEffect(() => { + measure(); + }); + + useEffect(() => { + if (!element) return; + const observer = new ResizeObserver(measure); + observer.observe(element); + document.fonts.addEventListener("loadingdone", measure); + return () => { + observer.disconnect(); + document.fonts.removeEventListener("loadingdone", measure); + }; + }, [element, measure]); + + return overflows; +} + export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, @@ -300,11 +392,17 @@ export const BranchToolbar = memo(function BranchToolbar({ canPickEnvironment: showEnvironmentPicker, }); const isMobile = useIsMobile(); + const [stripElement, setStripElement] = useState(null); + const labelsOverflow = useLabelsOverflow(stripElement); if (!hasActiveThread || !activeProject) return null; return ( -
+
{isMobile ? ( - {triggerLabel} + + {triggerLabel} + diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index d300139d3cf5..ca778daad31c 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -82,7 +82,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {effectiveEnvMode === "worktree" ? ( @@ -92,7 +92,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ) : ( )} - + + + diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index e4ed54758ff4..2cf99547752a 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -49,7 +49,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} - {activeEnvironment?.label ?? "Run on"} + + {activeEnvironment?.label ?? "Run on"} + ); } @@ -72,7 +77,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} - + + + diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 169126788ae8..f64bdedaa59c 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1747,12 +1747,14 @@ function ComposerPromptEditorInner({ return ( -
+
Appearance + // can drive it; keep everything else here. + "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", className, )} data-testid="composer-editor" @@ -1763,7 +1765,7 @@ function ComposerPromptEditorInner({ } placeholder={ terminalContexts.length > 0 ? null : ( -
+
{placeholder}
) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index b39350cec107..1c8e0c353b14 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -762,7 +762,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" onClick={handlePrClick} className={cn( - "shrink-0 font-mono text-xs hover:underline", + // Sidebar chrome follows the interface font; tabular digits keep the + // number from reflowing as PR states stream in. + "shrink-0 text-xs tabular-nums hover:underline", variant === "slim" && variantAction === "unsettle" ? props.isActive ? "text-muted-foreground/70" diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 5c7f6a774ee4..914e04b647dd 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -56,6 +56,7 @@ import { type ThreadTerminalGroup, } from "../types"; import { readLocalApi } from "~/localApi"; +import { useClientSettings } from "../hooks/useSettings"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; @@ -131,7 +132,13 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri return value ?? fallback; } -function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { +/** The surface treats an omitted family or size as "use the built-in default". */ +function terminalFontOptions(family: string, size: number): { family?: string; size: number } { + const trimmed = family.trim(); + return trimmed.length > 0 ? { family: trimmed, size } : { size }; +} + +export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { const isDark = document.documentElement.classList.contains("dark"); const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; @@ -305,6 +312,13 @@ export function TerminalViewport({ onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); + // The terminal inherits the monospace (code) preference unless it has an + // override of its own, so one font choice drives every mono surface. + const terminalFontFamily = useClientSettings( + (settings) => settings.fontFamilyTerminal.trim() || settings.fontFamilyCode, + ); + const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal); + const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); const terminalSession = useAttachedTerminalSession({ environmentId, terminal: { @@ -367,6 +381,13 @@ export function TerminalViewport({ keybindingsRef.current = keybindings; }, [keybindings]); + useEffect(() => { + const current = terminalFontRef.current; + if (current.family === terminalFontFamily && current.size === terminalFontSize) return; + terminalFontRef.current = { family: terminalFontFamily, size: terminalFontSize }; + void terminalRef.current?.setFont(terminalFontOptions(terminalFontFamily, terminalFontSize)); + }, [terminalFontFamily, terminalFontSize]); + useEffect(() => { const mount = containerRef.current; if (!mount) return; @@ -378,8 +399,10 @@ export function TerminalViewport({ let setupCleanups: Array<() => void> = []; const setup = async (): Promise<(() => void) | null> => { + const setupFont = terminalFontRef.current; const terminalOptions: GhosttyTerminalSurfaceOptions = { theme: terminalThemeFromApp(mount), + font: terminalFontOptions(setupFont.family, setupFont.size), onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), @@ -397,6 +420,13 @@ export function TerminalViewport({ terminal.setTheme(terminalThemeFromApp(mount)); setupTerminal = terminal; terminalRef.current = terminal; + // Client settings hydrate asynchronously; a font preference that landed + // while the surface was loading found terminalRef null, so its setFont + // was dropped. Re-apply whatever is current once the terminal exists. + const currentFont = terminalFontRef.current; + if (currentFont.family !== setupFont.family || currentFont.size !== setupFont.size) { + void terminal.setFont(terminalFontOptions(currentFont.family, currentFont.size)); + } const latestSession = latestSessionRef.current; previousSessionRef.current = latestSession; if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer); diff --git a/apps/web/src/components/composerInlineChip.ts b/apps/web/src/components/composerInlineChip.ts index b60b1678943f..f08f9285da94 100644 --- a/apps/web/src/components/composerInlineChip.ts +++ b/apps/web/src/components/composerInlineChip.ts @@ -1,20 +1,23 @@ +// Chip metrics are in em so the pills scale with the text they sit in (the +// composer honors the prompt font-size preference). The chat variant pins the +// original 12px, where every em value resolves to the same pixels as before. const INLINE_CHIP_CLASS_NAME = - "inline-flex max-w-full items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle"; + "inline-flex max-w-full items-center gap-[0.33em] rounded-[0.5em] border border-border/70 bg-accent/40 px-[0.5em] py-[0.08em] font-medium leading-[1.1] text-foreground align-middle"; -export const CHAT_INLINE_CHIP_CLASS_NAME = INLINE_CHIP_CLASS_NAME; +export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px]`; -export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} select-none`; +export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[0.86em] select-none`; -export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-85"; +export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85"; export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = - "inline-flex max-w-full select-none items-center gap-1 rounded-md border border-fuchsia-500/25 bg-fuchsia-500/12 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; + "inline-flex max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] py-[0.08em] font-medium text-[0.86em] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; -export const SKILL_CHIP_ICON_SVG = ``; +export const SKILL_CHIP_ICON_SVG = ``; export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME = - "ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; + "ml-[0.17em] inline-flex size-[1.17em] shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx new file mode 100644 index 000000000000..aaa5d8c605a8 --- /dev/null +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -0,0 +1,254 @@ +import { LegendList, type LegendListRef } from "@legendapp/list/react"; +import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxListVirtualized, + ComboboxPopup, + ComboboxTrigger, +} from "../ui/combobox"; + +const DEFAULT_FONT_VALUE = "__default__"; + +function supportsFontEnumeration(): boolean { + return ( + typeof window !== "undefined" && + typeof (window as { queryLocalFonts?: unknown }).queryLocalFonts === "function" + ); +} + +type FontEnumerationState = + | { readonly status: "unknown" } + | { readonly status: "granted"; readonly families: readonly string[] } + | { readonly status: "unavailable" }; + +// Shared across every row: once one picker learns the fonts (or learns the +// permission is blocked), the others follow without re-querying — and the +// rows can swap to the plain-input control together. +let enumerationState: FontEnumerationState = supportsFontEnumeration() + ? { status: "unknown" } + : { status: "unavailable" }; +const enumerationListeners = new Set<() => void>(); + +function subscribeToEnumeration(listener: () => void): () => void { + enumerationListeners.add(listener); + return () => enumerationListeners.delete(listener); +} + +function readEnumerationState(): FontEnumerationState { + return enumerationState; +} + +let enumerationLoad: Promise | null = null; + +/** Query installed fonts; call from a user gesture (the permission prompt needs one). */ +export function discoverInstalledFonts(): void { + if (enumerationState.status !== "unknown" || enumerationLoad !== null) return; + enumerationLoad = queryInstalledFontFamilies().then((result) => { + enumerationState = + result.status === "granted" + ? { status: "granted", families: result.families } + : { status: "unavailable" }; + enumerationLoad = null; + for (const listener of enumerationListeners) listener(); + }); +} + +let grantedProbeStarted = false; + +/** + * Discover eagerly when the permission is already granted, so the picker + * renders without waiting for a focus. Electron's default permission handler + * approves silently (it has no prompt UI), and a browser that granted once + * reports "granted" on later visits — in both, no user gesture is needed. + * "prompt" and "denied" states change nothing: the focus-driven flow stays, + * because raising the browser prompt still requires a gesture. + */ +function probeAlreadyGrantedPermission(): void { + if (grantedProbeStarted || enumerationState.status !== "unknown") return; + grantedProbeStarted = true; + const permissions = typeof navigator !== "undefined" ? navigator.permissions : undefined; + if (typeof permissions?.query !== "function") return; + permissions.query({ name: "local-fonts" as PermissionName }).then( + (status) => { + if (status.state === "granted") discoverInstalledFonts(); + }, + () => { + // The engine does not recognize the permission name; keep the + // focus-driven flow. + }, + ); +} + +/** + * Whether the engine can list installed fonts (Local Font Access API — + * Chromium and Electron). "unknown" until discovery resolves the permission; + * rows render a plain family-name input until the state is known granted, + * then upgrade to the picker. Where the permission is already granted, + * discovery starts at mount and the picker appears without a focus. + */ +export function useFontEnumeration(): FontEnumerationState { + useEffect(probeAlreadyGrantedPermission, []); + return useSyncExternalStore(subscribeToEnumeration, readEnumerationState); +} + +/** + * A searchable picker over every installed family, the way native editors + * list system fonts. The trigger always names the font in use: the committed + * family, or what the default stack resolves to on this machine. + */ +export function FontFamilyPicker({ + ariaLabel, + defaultFamily, + selectedFamily, + requireMonospace = false, + initialOpen = false, + onSelect, +}: { + ariaLabel: string; + /** What an unset preference renders as, e.g. "Menlo". */ + defaultFamily: string; + /** Committed family name; empty string means the default is in use. */ + selectedFamily: string; + requireMonospace?: boolean; + /** Open the popup on mount — set when the control upgrades under focus. */ + initialOpen?: boolean; + onSelect: (family: string) => void; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + // Open after mount rather than mounting open: a popup that first renders in + // its open state never receives Base UI's entrance style baseline, so the + // exit transition on close has no style delta, never fires transitionend, + // and the popup lingers on screen forever. + useEffect(() => { + if (initialOpen) setOpen(true); + // The prop is only meaningful at mount - the control just swapped in + // under an active focus - so later changes are deliberately ignored. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const listRef = useRef(null); + const enumeration = useFontEnumeration(); + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (nextOpen) setQuery(""); + }; + + const families = useMemo(() => { + if (enumeration.status !== "granted") return []; + return requireMonospace ? enumeration.families.filter(isMonospaceFamily) : enumeration.families; + }, [enumeration, requireMonospace]); + + const items = useMemo(() => { + const trimmedQuery = query.trim().toLowerCase(); + const result: string[] = []; + if (trimmedQuery.length === 0) result.push(DEFAULT_FONT_VALUE); + result.push( + ...families.filter( + (family) => trimmedQuery.length === 0 || family.toLowerCase().includes(trimmedQuery), + ), + ); + return result; + }, [query, families]); + + const selectedValue = selectedFamily.length === 0 ? DEFAULT_FONT_VALUE : selectedFamily; + + const handlePick = (value: string) => { + setOpen(false); + onSelect(value === DEFAULT_FONT_VALUE ? "" : value); + }; + + const renderItem = (item: string, index: number) => { + const isDefault = item === DEFAULT_FONT_VALUE; + const family = isDefault ? defaultFamily : item; + return ( + +
+ + {family} + + + {isDefault ? ( + default + ) : null} + {item === selectedValue ? ( + + ) : null} + +
+
+ ); + }; + + return ( + { + if (typeof next === "string") handlePick(next); + }} + onItemHighlighted={(_value, eventDetails) => { + // Keyboard highlights must pull the virtualized row into view, or + // arrow keys walk past the rendered window and navigate blind. + if (!open || eventDetails.index < 0 || eventDetails.reason !== "keyboard") return; + void listRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false }); + }} + > + + + {selectedFamily.length === 0 ? defaultFamily : selectedFamily} + + + + +
+
+
+
+
+ No fonts found. +
+ + + ref={listRef} + data={items} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderItem(item, index)} + estimatedItemSize={30} + drawDistance={360} + style={{ height: Math.min(items.length * 30, 288) }} + /> + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx new file mode 100644 index 000000000000..7190ec69313e --- /dev/null +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -0,0 +1,232 @@ +import { preloadPatchFile } from "@pierre/diffs/ssr"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; +import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; +import { useTheme } from "../../hooks/useTheme"; +import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering"; +import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; + +// The font previews are the real surfaces, not lookalikes: the composer's +// Lexical editor, the diff panel's file diff, and the Ghostty canvas +// renderer. Each already consumes the appearance font tokens (or, for the +// terminal, the settings passed down as props), so what the row shows is +// exactly what the app renders. + +const EMPTY_TERMINAL_CONTEXTS: ReadonlyArray = []; +const EMPTY_SKILLS: ReadonlyArray = []; + +// Serialized the way the composer stores inline tokens: the $skill and the +// markdown-style file links render as chips, so the preview shows prompt +// text and pills exactly as the real composer draws them. +const PROMPT_PREVIEW_TEXT = + "Use $frontend-design to fix the flaky test in " + + "[surface.test.ts](apps/web/src/terminal/ghostty/surface.test.ts) and align the header with " + + "[SettingsPanels.tsx](apps/web/src/components/settings/SettingsPanels.tsx) before shipping."; + +function noop() {} + +/** A live composer editor: type in it to feel the family and size. */ +export function PromptFontPreview() { + const editorRef = useRef(null); + const [prompt, setPrompt] = useState(PROMPT_PREVIEW_TEXT); + const [cursor, setCursor] = useState(PROMPT_PREVIEW_TEXT.length); + const onChange = useCallback((nextValue: string, nextCursor: number) => { + setPrompt(nextValue); + setCursor(nextCursor); + }, []); + return ( +
+ +
+ ); +} + +const DIFF_PREVIEW_PATCH = [ + "diff --git a/src/formatUser.ts b/src/formatUser.ts", + "--- a/src/formatUser.ts", + "+++ b/src/formatUser.ts", + "@@ -1,3 +1,3 @@", + " export function formatUser(user: User) {", + "- return user.name.toUpperCase();", + "+ return `${user.name} <${user.email}>`; // 0O 1lI", + " }", + "", +].join("\n"); + +// Rendered once per theme through the SSR pipeline, which always awaits the +// shared highlighter before producing HTML. The interactive FileDiff's mount +// lifecycle can race that highlighter when the typography views remount it +// (toggling Advanced) and lock in an unhighlighted frame; a static preview +// needs none of that lifecycle, so it uses the deterministic renderer and +// injects the finished HTML into a shadow root, exactly as FileDiff would. +const diffPreviewHtmlByTheme = new Map>(); + +function loadDiffPreviewHtml(theme: DiffThemeName): Promise { + let promise = diffPreviewHtmlByTheme.get(theme); + if (promise === undefined) { + promise = preloadPatchFile({ + patch: DIFF_PREVIEW_PATCH, + options: { diffStyle: "unified", theme }, + }).then((results) => results.map((result) => result.prerenderedHTML)); + diffPreviewHtmlByTheme.set(theme, promise); + } + return promise; +} + +function StaticDiffHtml({ html }: { html: string }) { + const hostRef = useRef(null); + useEffect(() => { + const host = hostRef.current; + if (host === null) return; + const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); + shadow.innerHTML = html; + }, [html]); + return
; +} + +/** The diff panel's file diff, statically rendered by its real pipeline. */ +export function CodeFontPreview() { + const { resolvedTheme } = useTheme(); + const themeName = resolveDiffThemeName(resolvedTheme); + const [htmlByFile, setHtmlByFile] = useState(null); + useEffect(() => { + let cancelled = false; + void loadDiffPreviewHtml(themeName).then((html) => { + if (!cancelled) setHtmlByFile(html); + }); + return () => { + cancelled = true; + }; + }, [themeName]); + if (htmlByFile === null) return null; + return ( +
+ {htmlByFile.map((html, index) => ( + + ))} +
+ ); +} + +const TERMINAL_PROMPT = "\x1b[2m$\x1b[0m "; +const TERMINAL_PREVIEW_TRANSCRIPT = + `${TERMINAL_PROMPT}npm run dev\r\n` + + "\x1b[32m✓\x1b[0m Ready in 430ms\r\n" + + "\x1b[2mLocal:\x1b[0m \x1b[36mhttp://localhost:3000\x1b[0m\r\n" + + TERMINAL_PROMPT; + +/** The surface treats an omitted family or size as "use the built-in default". */ +function previewTerminalFont(family: string, size: number): { family?: string; size: number } { + const trimmed = family.trim(); + return trimmed.length > 0 ? { family: trimmed, size } : { size }; +} + +/** + * The real Ghostty canvas renderer against a local echo loop instead of a + * PTY: keys print, Enter starts a new prompt line, Backspace erases. That + * exercises the same glyph atlas, cell metrics, and monospace gate the + * terminal drawer uses. + */ +export function TerminalFontPreview({ family, size }: { family: string; size: number }) { + const mountRef = useRef(null); + const surfaceRef = useRef(null); + const fontRef = useRef({ family, size }); + const { resolvedTheme } = useTheme(); + + useEffect(() => { + const current = fontRef.current; + if (current.family === family && current.size === size) return; + fontRef.current = { family, size }; + void surfaceRef.current?.setFont(previewTerminalFont(family, size)); + }, [family, size]); + + useEffect(() => { + const mount = mountRef.current; + const surface = surfaceRef.current; + if (!mount || !surface) return; + surface.setTheme(terminalThemeFromApp(mount)); + }, [resolvedTheme]); + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return; + let cancelled = false; + // Column of the caret on the current input line, so Backspace stops at + // the prompt instead of eating it. + let lineLength = 0; + + const echo = (data: string) => { + const surface = surfaceRef.current; + if (!surface) return; + if (data === "\r") { + surface.write(`\r\n${TERMINAL_PROMPT}`); + lineLength = 0; + return; + } + if (data === "\x7f" || data === "\b") { + if (lineLength > 0) { + surface.write("\b \b"); + lineLength -= 1; + } + return; + } + // Arrow keys and other escape reports have no cursor to move here. + if (data.startsWith("\x1b")) return; + const printable = [...data] + .filter((character) => character >= " " && character !== "\x7f") + .join(""); + if (printable.length === 0) return; + surface.write(printable); + lineLength += printable.length; + }; + + void GhosttyTerminalSurface.create(mount, { + theme: terminalThemeFromApp(mount), + font: previewTerminalFont(fontRef.current.family, fontRef.current.size), + onData: echo, + onResize: noop, + onSelectionChange: noop, + onCopy: (text) => void navigator.clipboard?.writeText(text).catch(noop), + // Tab keeps walking the settings page instead of feeding the echo loop. + beforeKey: (event) => event.key !== "Tab", + onLinkActivate: noop, + }).then((surface) => { + if (cancelled) { + surface.dispose(); + return; + } + surfaceRef.current = surface; + // The theme and font may both have changed while the WASM surface loaded. + surface.setTheme(terminalThemeFromApp(mount)); + const font = fontRef.current; + void surface.setFont(previewTerminalFont(font.family, font.size)); + surface.write(TERMINAL_PREVIEW_TRANSCRIPT); + }); + + return () => { + cancelled = true; + surfaceRef.current?.dispose(); + surfaceRef.current = null; + }; + }, []); + + return ( +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 31ac4bba66ee..c08e41267969 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -8,8 +8,8 @@ import { SettingsIcon, } from "lucide-react"; import { Link } from "@tanstack/react-router"; -import type { CSSProperties } from "react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { defaultInstanceIdForDriver, @@ -34,8 +34,16 @@ import { DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, DEFAULT_UNIFIED_SETTINGS, type EnvironmentIdentificationMode, + MAX_CODE_FONT_SIZE, MAX_GLASS_OPACITY, + MAX_INTERFACE_FONT_SIZE, + MAX_PROMPT_FONT_SIZE, + MAX_TERMINAL_FONT_SIZE, + MIN_CODE_FONT_SIZE, MIN_GLASS_OPACITY, + MIN_INTERFACE_FONT_SIZE, + MIN_PROMPT_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, } from "@t3tools/contracts/settings"; import { getBackgroundActivityBaseProfile, @@ -47,6 +55,7 @@ import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { canCheckForUpdate, @@ -64,6 +73,7 @@ import { import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; @@ -77,6 +87,7 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; +import { isMacPlatform } from "../../lib/utils"; import { primaryServerObservabilityAtom, primaryServerProvidersAtom, @@ -97,6 +108,17 @@ import { DialogTitle, } from "../ui/dialog"; import { DraftInput } from "../ui/draft-input"; +import { Input } from "../ui/input"; +import { + DEFAULT_CODE_FONT_STACK, + DEFAULT_SANS_FONT_STACK, + isFontFamilyAvailable, + isMonospaceFamily, + resolveDefaultFamilyLabel, +} from "../../appearanceFonts"; +import { DEFAULT_TERMINAL_FONT_FAMILY } from "~/terminal/ghostty/surface"; +import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; +import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; import { NumberField, NumberFieldDecrement, @@ -135,6 +157,7 @@ import { SettingsRow, SettingsSection, useRelativeTimeTick, + useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; @@ -586,6 +609,16 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Project Grouping"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), + ...(settings.fontFamilySans !== DEFAULT_UNIFIED_SETTINGS.fontFamilySans + ? ["Interface font"] + : []), + ...(settings.fontFamilyComposer !== DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer + ? ["Prompt font"] + : []), + ...(settings.fontFamilyCode !== DEFAULT_UNIFIED_SETTINGS.fontFamilyCode ? ["Code font"] : []), + ...(settings.fontFamilyTerminal !== DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal + ? ["Terminal font"] + : []), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), @@ -629,6 +662,14 @@ export function useSettingsRestore(onRestored?: () => void) { settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, + settings.fontFamilyCode, + settings.fontFamilyComposer, + settings.fontFamilySans, + settings.fontFamilyTerminal, + settings.fontSizeCode, + settings.fontSizeInterface, + settings.fontSizePrompt, + settings.fontSizeTerminal, settings.glassOpacity, settings.enableAssistantStreaming, settings.enableProviderUpdateChecks, @@ -672,6 +713,10 @@ export function useSettingsRestore(onRestored?: () => void) { confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, + fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, + fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, + fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, }); onRestored?.(); }, [changedSettingLabels, onRestored, setTheme, updateSettings]); @@ -958,8 +1003,8 @@ export function AppearanceSettingsPanel() { const glassOpacityRatio = (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY); const glassOpacitySliderStyle = { - "--glass-slider-progress": `${glassOpacityRatio * 100}%`, - "--glass-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`, + "--settings-slider-progress": `${glassOpacityRatio * 100}%`, + "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`, } as CSSProperties; return ( @@ -1021,7 +1066,7 @@ export function AppearanceSettingsPanel() { ) : null} + - - updateSettings({ - wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, - }) - } - /> - ) : null - } - control={ - updateSettings({ wordWrap: Boolean(checked) })} - aria-label="Wrap code, tables, diffs, and file previews by default" + + + ); +} + +function useFontDefaultFamilies() { + const settings = usePrimarySettings(); + // An unset preference shows the font it resolves to on this machine; the + // default stacks are the platform's own faces, so the name is probed, not + // hardcoded. + const defaults = useMemo( + () => ({ + sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default", + code: resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace", + }), + [], + ); + return { + sans: defaults.sans, + code: defaults.code, + // The composer inherits whatever the interface preference resolves to; + // the terminal inherits the monospace preference the same way. + interfaceFamily: settings.fontFamilySans.trim() || defaults.sans, + monoFamily: settings.fontFamilyCode.trim() || defaults.code, + }; +} + +function InterfaceFontRow({ preview }: { preview?: ReactNode }) { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const defaults = useFontDefaultFamilies(); + return ( + updateSettings({ fontFamilySans })} + size={{ + label: "Interface font size", + min: MIN_INTERFACE_FONT_SIZE, + max: MAX_INTERFACE_FONT_SIZE, + value: settings.fontSizeInterface, + onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }), + }} + {...(preview !== undefined ? { preview } : {})} + /> + ); +} + +function PromptFontRow() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const defaults = useFontDefaultFamilies(); + return ( + updateSettings({ fontFamilyComposer })} + size={{ + label: "Prompt font size", + min: MIN_PROMPT_FONT_SIZE, + max: MAX_PROMPT_FONT_SIZE, + value: settings.fontSizePrompt, + onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }), + }} + preview={} + /> + ); +} + +function CodeFontRow({ + title, + description = "Code blocks, diffs, and file previews.", + preview, +}: { + title?: string; + description?: string; + preview?: ReactNode; +}) { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const defaults = useFontDefaultFamilies(); + return ( + updateSettings({ fontFamilyCode })} + requireMonospace + size={{ + label: "Code font size", + min: MIN_CODE_FONT_SIZE, + max: MAX_CODE_FONT_SIZE, + value: settings.fontSizeCode, + onChange: (fontSizeCode) => updateSettings({ fontSizeCode }), + }} + preview={preview ?? } + /> + ); +} + +function TerminalFontRow() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const defaults = useFontDefaultFamilies(); + return ( + updateSettings({ fontFamilyTerminal })} + requireMonospace + size={{ + label: "Terminal font size", + min: MIN_TERMINAL_FONT_SIZE, + max: MAX_TERMINAL_FONT_SIZE, + value: settings.fontSizeTerminal, + onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }), + }} + preview={ + + } + /> + ); +} + +function FontSmoothingRow() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + if (!isMacPlatform(navigator.platform)) return null; + return ( + + updateSettings({ fontSmoothing: DEFAULT_UNIFIED_SETTINGS.fontSmoothing }) + } + /> + ) : null + } + control={ + updateSettings({ fontSmoothing: Boolean(checked) })} + aria-label="Font smoothing" + /> + } + /> + ); +} + +function WordWrapRow() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + return ( + updateSettings({ wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap })} + /> + ) : null + } + control={ + updateSettings({ wordWrap: Boolean(checked) })} + aria-label="Wrap code, tables, diffs, and file previews by default" + /> + } + /> + ); +} + +function FontSettingsGroup() { + return ( + <> + + + + + + + ); +} + +/** + * The two-font view: one sans, one monospace. The prompt follows the + * interface font and the terminal follows the monospace font, so the demos + * under each row show every surface the choice reaches. + */ +function SimpleFontRows() { + const settings = usePrimarySettings(); + return ( + <> + } /> + + + + + } + /> + + ); +} + +// Font smoothing only renders on macOS, so a search jump to it elsewhere +// must not flip the section - the target would never mount to be scrolled to. +const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet = new Set([ + "prompt-font", + "terminal-font", + ...(typeof navigator !== "undefined" && isMacPlatform(navigator.platform) + ? ["font-smoothing"] + : []), +]); + +const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced"; + +/** + * The two-font view by default - one sans, one monospace, each cascading to + * every surface it reaches - with an Advanced switch in the section header + * that reveals the per-surface override rows. The choice persists locally, + * and a settings-search jump to an override row flips Advanced on so the + * target exists to scroll to. + */ +function TypographySection() { + const [advanced, setAdvanced] = useLocalStorage(TYPOGRAPHY_ADVANCED_KEY, false, Schema.Boolean); + const searchTargetId = useSettingsSearchTargetId(); + // Flip Advanced on once per search jump so the hidden target can mount and + // scroll; tracking the handled id lets the user turn it back off without + // the still-set target immediately re-expanding the section. + const lastExpandedTargetRef = useRef(null); + useEffect(() => { + if (searchTargetId === null || !ADVANCED_TYPOGRAPHY_TARGET_IDS.has(searchTargetId)) return; + if (lastExpandedTargetRef.current === searchTargetId) return; + lastExpandedTargetRef.current = searchTargetId; + setAdvanced(true); + }, [searchTargetId, setAdvanced]); + return ( + + Advanced + setAdvanced(Boolean(checked))} + aria-label="Show advanced typography settings" + /> + + } + > + {advanced ? : } + + + ); +} + +function FontFamilySettingsRow({ + id, + title, + description, + defaultFamily, + preview, + value, + onValueChange, + requireMonospace = false, + size, +}: { + id?: string; + title: string; + description: string; + /** What an unset preference renders as, e.g. "Menlo". */ + defaultFamily: string; + preview?: ReactNode; + value: string; + onValueChange: (value: string) => void; + requireMonospace?: boolean; + size: { label: string; min: number; max: number; value: number; onChange: (v: number) => void }; +}) { + const trimmed = value.trim(); + // The fallback input edits a draft; the preference only commits once typing + // pauses and the text probes as an available font (or is an explicit + // clear), so the current font holds and nothing reflows mid-word. + const [draft, setDraft] = useState(value); + const [draftSettled, setDraftSettled] = useState(true); + const commitTimerRef = useRef(null); + const lastValueRef = useRef(value); + if (lastValueRef.current !== value) { + // The committed value changed externally (hydration, reset, picker + // selection); adopt it and drop any pending commit of a stale draft. + lastValueRef.current = value; + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + } + setDraft(value); + setDraftSettled(true); + } + useEffect( + () => () => { + if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current); + }, + [], + ); + const acceptsFamily = (candidate: string) => + isFontFamilyAvailable(candidate) && (!requireMonospace || isMonospaceFamily(candidate)); + const commitDraft = (next: string) => { + setDraftSettled(true); + // A rejected name stays in the field, flagged: the terminal would silently + // fall back to its default, so the row must not claim it took the value. + if (next.trim().length === 0 || acceptsFamily(next)) { + onValueChange(next); + } + }; + const flushDraft = () => { + if (commitTimerRef.current === null) return; + window.clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + commitDraft(draft); + }; + const draftTrimmed = draft.trim(); + // Flag an unknown name only once typing pauses, and never for an empty + // field - that is the starting state, not a rejected entry. + const draftPending = draftSettled && draftTrimmed.length > 0 && draftTrimmed !== trimmed; + const resetAction = + trimmed.length > 0 ? ( + onValueChange("")} + /> + ) : null; + const fontEnumeration = useFontEnumeration(); + // Everyone starts on the plain input; focusing it is the user gesture that + // runs font discovery. Where the engine can enumerate, the control then + // upgrades to the picker - popped open when the swap happens under focus, + // so the interaction continues without a second click. + const inputFocusedRef = useRef(false); + const familyControl = + fontEnumeration.status === "granted" ? ( + + ) : ( + { + inputFocusedRef.current = true; + discoverInstalledFonts(); + }} + onBlur={() => { + inputFocusedRef.current = false; + flushDraft(); + }} + onChange={(event) => { + const next = event.currentTarget.value; + setDraft(next); + setDraftSettled(false); + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); } - /> - - + commitTimerRef.current = window.setTimeout(() => { + commitTimerRef.current = null; + commitDraft(next); + }, 400); + }} + onKeyDown={(event) => { + if (event.key === "Enter") flushDraft(); + if (event.key === "Escape") { + // Discard uncommitted typing without closing the settings page, + // which is what an unhandled Escape does. + event.preventDefault(); + event.stopPropagation(); + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + } + setDraft(value); + setDraftSettled(true); + } + }} + placeholder={defaultFamily} + spellCheck={false} + value={draft} + /> + ); + const control = ( +
+
{familyControl}
+ +
+ ); + return ( + + {preview} + ); } diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 238fddbc48a4..84fb95a47417 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -62,6 +62,11 @@ function scrollAndFocusSettingsTarget(target: HTMLElement): void { }); } +/** The row id a settings-search jump is currently trying to reach, if any. */ +export function useSettingsSearchTargetId(): string | null { + return useContext(SettingsSearchTargetContext).targetId; +} + function useSettingsSearchTarget(id: string | undefined) { const { targetId, onTargetHandled } = useContext(SettingsSearchTargetContext); const isSearchTarget = id !== undefined && id === targetId; diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4ead6eff4d79..1ba231a58350 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -55,6 +55,31 @@ export const SETTINGS_SEARCH_ITEMS = [ // The setting is stage-dependent, so its parent section is the stable destination. targetId: "appearance", }, + { + id: "interface-font", + title: "Interface font", + to: "/settings/appearance", + }, + { + id: "prompt-font", + title: "Prompt font", + to: "/settings/appearance", + }, + { + id: "code-font", + title: "Code font", + to: "/settings/appearance", + }, + { + id: "terminal-font", + title: "Terminal font", + to: "/settings/appearance", + }, + { + id: "font-smoothing", + title: "Font smoothing", + to: "/settings/appearance", + }, { id: "word-wrap", title: "Word wrap", diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 18890cc7d094..54b3b5c0ce6f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -130,6 +130,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil ); } +/* The font tokens are declared outside the inline theme so utilities reference + the variables and Settings -> Appearance can override them at runtime. The + default stacks are mirrored in `appearanceFonts.ts`. */ +@theme { + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: + ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; +} + @theme inline { --color-zinc-25: oklch(99.2% 0 0); --animate-skeleton: skeleton 2s -1s infinite linear; @@ -138,11 +147,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --animate-status-pulse: status-pulse 2s infinite; --animate-status-ping: status-ping 2s infinite; --animate-sidebar-working-text: sidebar-working-text 3.4s infinite; - --font-sans: - "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, - sans-serif; - --font-mono: - "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; --color-warning-foreground: var(--warning-foreground); --color-warning: var(--warning); --color-success-foreground: var(--success-foreground); @@ -679,11 +683,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil background: color-mix(in srgb, var(--background) 64%, transparent); } - .glass-opacity-slider { - --glass-slider-progress: 0%; - --glass-slider-fill-offset: 0.5rem; - --glass-slider-fill-position: calc( - var(--glass-slider-progress) + var(--glass-slider-fill-offset) + .settings-slider { + --settings-slider-progress: 0%; + --settings-slider-fill-offset: 0.5rem; + --settings-slider-fill-position: calc( + var(--settings-slider-progress) + var(--settings-slider-fill-offset) ); appearance: none; height: 1.5rem; @@ -691,32 +695,32 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil cursor: pointer; } - .glass-opacity-slider::-webkit-slider-runnable-track { + .settings-slider::-webkit-slider-runnable-track { height: 0.375rem; border-radius: 9999px; background: linear-gradient( to right, - var(--primary) 0 var(--glass-slider-fill-position), - color-mix(in srgb, var(--muted-foreground) 22%, transparent) var(--glass-slider-fill-position) - 100% + var(--primary) 0 var(--settings-slider-fill-position), + color-mix(in srgb, var(--muted-foreground) 22%, transparent) + var(--settings-slider-fill-position) 100% ); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent); } - .glass-opacity-slider::-moz-range-track { + .settings-slider::-moz-range-track { height: 0.375rem; border-radius: 9999px; background: color-mix(in srgb, var(--muted-foreground) 22%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent); } - .glass-opacity-slider::-moz-range-progress { + .settings-slider::-moz-range-progress { height: 0.375rem; border-radius: 9999px; background: var(--primary); } - .glass-opacity-slider::-webkit-slider-thumb { + .settings-slider::-webkit-slider-thumb { appearance: none; width: 1rem; height: 1rem; @@ -730,7 +734,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil box-shadow 120ms ease; } - .glass-opacity-slider::-moz-range-thumb { + .settings-slider::-moz-range-thumb { width: 1rem; height: 1rem; border: 2px solid var(--primary); @@ -742,54 +746,54 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil box-shadow 120ms ease; } - .glass-opacity-slider:hover::-webkit-slider-thumb { + .settings-slider:hover::-webkit-slider-thumb { transform: scale(1.08); box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent); } - .glass-opacity-slider:hover::-moz-range-thumb { + .settings-slider:hover::-moz-range-thumb { transform: scale(1.08); box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent); } - .glass-opacity-slider:active::-webkit-slider-thumb { + .settings-slider:active::-webkit-slider-thumb { transform: scale(0.94); } - .glass-opacity-slider:active::-moz-range-thumb { + .settings-slider:active::-moz-range-thumb { transform: scale(0.94); } - .glass-opacity-slider:focus-visible { + .settings-slider:focus-visible { outline: none; } - .glass-opacity-slider:focus-visible::-webkit-slider-thumb { + .settings-slider:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px var(--background), 0 0 0 5px var(--ring); } - .glass-opacity-slider:focus-visible::-moz-range-thumb { + .settings-slider:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px var(--background), 0 0 0 5px var(--ring); } @media (forced-colors: active) { - .glass-opacity-slider { + .settings-slider { appearance: auto; accent-color: Highlight; } - .glass-opacity-slider::-webkit-slider-runnable-track, - .glass-opacity-slider::-webkit-slider-thumb { + .settings-slider::-webkit-slider-runnable-track, + .settings-slider::-webkit-slider-thumb { all: revert; } - .glass-opacity-slider::-moz-range-track, - .glass-opacity-slider::-moz-range-progress, - .glass-opacity-slider::-moz-range-thumb { + .settings-slider::-moz-range-track, + .settings-slider::-moz-range-progress, + .settings-slider::-moz-range-thumb { all: revert; } } @@ -997,14 +1001,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } body { - font-family: - "DM Sans Variable", - "DM Sans", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - system-ui, - sans-serif; + /* Reference the theme token (not a literal stack) so the Settings -> + Appearance runtime override of --font-sans reaches all interface text. */ + font-family: var(--font-sans); margin: 0; padding: 0; } @@ -1055,8 +1054,27 @@ body { pre, code { - font-family: - "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; + font-family: var(--font-mono); +} + +/* Code blocks in chat carry the size preference. Scoped to chat markdown + rather than every pre: a global rule would beat text-size utilities and + inherited sizes on unrelated pre surfaces (terminal previews, approvals). + Inline code stays relative to its sentence so it never towers over prose; + diffs and file previews take the size through --diffs-font-size. */ +.chat-markdown .chat-markdown-shiki .shiki, +.chat-markdown pre code { + font-size: var(--font-size-code, inherit); +} + +/* @pierre/diffs surfaces (diffs, file previews, annotatable code, search + lines) render inside shadow roots but consult these hooks with their own + literal stacks as fallback. Custom properties inherit across the shadow + boundary, so defining them once here routes every code surface through the + appearance font tokens. */ +:root { + --diffs-font-family: var(--font-mono); + --diffs-header-font-family: var(--font-sans); } /* Window drag region (frameless titlebar) */ @@ -1090,6 +1108,24 @@ code { background: var(--app-scrollbar-thumb-hover); } +/* Settings -> Appearance can point the composer at its own face (for example a + mono font); default follows the sans stack. Applied on the surface wrapper so + the editor and its placeholder inherit together. */ +.composer-editor-surface { + font-family: var(--font-composer, var(--font-sans)); + font-size: var(--font-size-prompt, 0.875rem); +} + +/* Touch browsers zoom the page when a focused field is under 16px, so keep + the floor there regardless of the preference. Gated on a coarse pointer: + the zoom quirk does not exist on desktop, where a narrow window must not + silently override a smaller chosen prompt size. */ +@media (max-width: 39.999rem) and (pointer: coarse) { + .composer-editor-surface { + font-size: max(var(--font-size-prompt, 1rem), 16px); + } +} + .t3-ghostty-canvas { cursor: text; } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index a655ba4eaa22..843bd9efbea4 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,9 +5,6 @@ import { passkeys } from "@clerk/electron/passkeys"; import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; -import "@fontsource-variable/dm-sans/index.css"; -import "@fontsource/jetbrains-mono/400.css"; -import "@fontsource/jetbrains-mono/500.css"; import "./index.css"; import { isElectron } from "./env"; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114de..bbad8a303c8b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -27,6 +27,7 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; +import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { useClientSettings } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, @@ -128,6 +129,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} @@ -152,6 +154,38 @@ function GlassAppearanceSync() { return null; } +function FontAppearanceSync() { + const fontFamilySans = useClientSettings((settings) => settings.fontFamilySans); + const fontFamilyCode = useClientSettings((settings) => settings.fontFamilyCode); + const fontFamilyComposer = useClientSettings((settings) => settings.fontFamilyComposer); + const fontSizeInterface = useClientSettings((settings) => settings.fontSizeInterface); + const fontSizePrompt = useClientSettings((settings) => settings.fontSizePrompt); + const fontSizeCode = useClientSettings((settings) => settings.fontSizeCode); + const fontSmoothing = useClientSettings((settings) => settings.fontSmoothing); + + useEffect(() => { + applyAppearanceFontVariables(document.documentElement, { + sans: fontFamilySans, + code: fontFamilyCode, + composer: fontFamilyComposer, + sizeInterface: fontSizeInterface, + sizePrompt: fontSizePrompt, + sizeCode: fontSizeCode, + smoothing: fontSmoothing, + }); + }, [ + fontFamilyCode, + fontFamilyComposer, + fontFamilySans, + fontSizeCode, + fontSizeInterface, + fontSizePrompt, + fontSmoothing, + ]); + + return null; +} + function DocumentTitleSync() { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 9c1fde901680..7f94a4c95c8d 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -19,6 +19,7 @@ import { terminalLinkAtPosition, terminalContentOriginY, terminalFontFamily, + fittedTerminalFontSize, terminalFontSize, terminalWheelArrowData, terminalWheelDeltaRows, @@ -256,6 +257,41 @@ describe("terminal font resolution", () => { expect(custom.endsWith("monospace")).toBe(true); }); + it("ignores proportional families the cell grid cannot lay out", () => { + // jsdom has no canvas metrics, so the probe answers "monospace" and the + // family is kept; the guard is exercised in the browser instead. Assert the + // shape stays intact so a rejected face still yields a usable stack. + const stack = terminalFontFamily("Helvetica Neue"); + expect(stack.endsWith("monospace")).toBe(true); + }); + + it("quotes families the canvas font shorthand would otherwise reject", () => { + expect(terminalFontFamily("3270 Nerd Font").startsWith('"3270 Nerd Font", ')).toBe(true); + expect(terminalFontFamily("M+ 1m").startsWith('"M+ 1m", ')).toBe(true); + expect(terminalFontFamily("Cascadia Code, Menlo").startsWith('"Cascadia Code", Menlo, ')).toBe( + true, + ); + expect(terminalFontFamily(" , ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); + }); + + it("slides the rendered size down until a full-width grid fits the canvas", () => { + // SF Mono-like advance: 0.6em per cell. + const cellWidthAt = (size: number) => size * 0.6; + // A wide drawer keeps the preference untouched. + expect(fittedTerminalFontSize(cellWidthAt, 20, 1140)).toBe(20); + // A split pane at the same preference shrinks until 80 columns fit. + const fitted = fittedTerminalFontSize(cellWidthAt, 20, 570); + expect(fitted).toBeLessThan(20); + expect(Math.floor((570 - 8) / cellWidthAt(fitted))).toBeGreaterThanOrEqual(80); + // A tiny pane stops at the legibility floor instead of vanishing. + expect(fittedTerminalFontSize(cellWidthAt, 20, 220)).toBe(8); + // A preference below the floor is honored as-is. + expect(fittedTerminalFontSize(cellWidthAt, 6, 220)).toBe(6); + // Unmeasured layouts leave the preference alone. + expect(fittedTerminalFontSize(cellWidthAt, 14, 0)).toBe(14); + expect(fittedTerminalFontSize(() => 0, 14, 600)).toBe(14); + }); + it("clamps requested font sizes to the supported range", () => { expect(terminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE); expect(terminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 9b6a1f875503..b460d38d2df7 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -13,6 +13,7 @@ import { type GhosttyCellMetrics, } from "./renderer"; import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; +import { isMonospaceFamily } from "../../appearanceFonts"; export const DEFAULT_TERMINAL_FONT_SIZE = 12; const MIN_TERMINAL_FONT_SIZE = 6; @@ -25,10 +26,11 @@ const TERMINAL_GLYPH_FALLBACKS = '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", ' + '"JetBrainsMono NF", "FiraCode Nerd Font", "Hack Nerd Font", "MesloLGS NF", ' + '"CaskaydiaCove Nerd Font", "PowerlineSymbols", monospace'; -// SF Mono where the platform has it (macOS), otherwise the bundled JetBrains -// Mono webfont, so the default rendering is identical everywhere else. +// The platform's own monospace faces; concrete names only, because an +// unknown keyword (like ui-monospace) makes canvas font shorthand parsing +// reject the whole string. export const DEFAULT_TERMINAL_FONT_FAMILY = - '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS; + '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", ' + TERMINAL_GLYPH_FALLBACKS; const CONTENT_PADDING = 4; const MIN_SCROLLBAR_THUMB_HEIGHT = 18; /** Half a blink cycle: the visible and hidden phases are equally long. */ @@ -61,13 +63,69 @@ function ensureTerminalSymbolsFont(): Promise { return symbolsFontLoad; } +function quoteTerminalFontFamilies(list: string): string { + return list + .split(",") + .map((name) => { + const bare = name.trim(); + if (bare.length === 0) return ""; + if (/^(['"]).*\1$/.test(bare)) return bare; + if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; + return `"${bare.replaceAll('"', "")}"`; + }) + .filter((name) => name.length > 0) + .join(", "); +} + export function terminalFontFamily(family?: string): string { - const custom = family?.trim(); - if (!custom) return DEFAULT_TERMINAL_FONT_FAMILY; + // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes + // the whole canvas font string invalid and the assignment silently no-ops. + const custom = family === undefined ? "" : quoteTerminalFontFamilies(family); + if (custom.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY; + // The grid places the cursor and selection on one cell advance, so a + // proportional face would draw its text narrower than its own cells. Refuse + // it here rather than render a ragged grid with a stranded cursor. + if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY; // A custom face keeps the glyph fallbacks so prompt symbols stay covered. return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; } +/** + * Grids narrower than a classic 80-column terminal wrap command output hard, + * so the rendered font size follows the canvas width: the preference is the + * ceiling, and the size slides down (to a legibility floor) until a full-width + * grid fits. A widening pane slides it back up toward the preference. + */ +const MIN_TERMINAL_FIT_COLUMNS = 80; +const MIN_TERMINAL_FIT_FONT_SIZE = 8; + +export function fittedTerminalFontSize( + cellWidthAt: (size: number) => number, + requested: number, + mountWidth: number, +): number { + const available = mountWidth - CONTENT_PADDING * 2; + if (available <= 0) return requested; + const floor = Math.min(requested, MIN_TERMINAL_FIT_FONT_SIZE); + const fits = (cellWidth: number) => + cellWidth > 0 && Math.floor(available / cellWidth) >= MIN_TERMINAL_FIT_COLUMNS; + let cellWidth = cellWidthAt(requested); + if (cellWidth <= 0 || fits(cellWidth)) return requested; + // The advance scales linearly with size for monospace faces: jump close to + // the fitting size, then settle the remaining rounding one step at a time. + const targetCellWidth = available / MIN_TERMINAL_FIT_COLUMNS; + let size = Math.max( + floor, + Math.min(requested, Math.floor((requested * targetCellWidth) / cellWidth)), + ); + while (size > floor) { + cellWidth = cellWidthAt(size); + if (cellWidth <= 0 || fits(cellWidth)) break; + size -= 1; + } + return size; +} + export function terminalFontSize(size?: number): number { if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE; return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size))); @@ -350,6 +408,7 @@ export class GhosttyTerminalSurface { private metrics: GhosttyCellMetrics; private fontFamily: string; private fontSize: number; + private requestedFontSize: number; private fontEpoch = 0; private readonly resizeObserver: ResizeObserver; private readonly scrollbarThumb: HTMLDivElement; @@ -425,6 +484,7 @@ export class GhosttyTerminalSurface { this.theme = options.theme; this.fontFamily = terminalFontFamily(options.font?.family); this.fontSize = terminalFontSize(options.font?.size); + this.requestedFontSize = this.fontSize; this.resizeObserver = new ResizeObserver(() => this.fit()); this.installEvents(); this.watchDevicePixelRatio(); @@ -544,6 +604,7 @@ export class GhosttyTerminalSurface { } if (this.disposed || epoch !== this.fontEpoch) return; this.fontFamily = fontFamily; + this.requestedFontSize = fontSize; this.fontSize = fontSize; this.applyFontMetrics(); } @@ -588,6 +649,22 @@ export class GhosttyTerminalSurface { const width = this.mount.clientWidth; const height = this.mount.clientHeight; if (width <= 0 || height <= 0) return false; + const fitted = fittedTerminalFontSize( + (size) => measureGhosttyCell(this.context, size, this.fontFamily).width, + this.requestedFontSize, + width, + ); + if (fitted !== this.fontSize) { + this.fontSize = fitted; + this.metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); + // The grid-change branch below resizes the core, but only when the + // column count moved; the cell geometry always did, so sync it here. + this.core.resize(this.cols, this.rows, this.metrics.width, this.metrics.height); + this.inputLeft = -1; + this.inputTop = -1; + this.forceFullRender = true; + this.scrollbarDirty = true; + } const ratio = window.devicePixelRatio || 1; const pixelWidth = Math.max(1, Math.round(width * ratio)); const pixelHeight = Math.max(1, Math.round(height * ratio)); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7e39bc860b2b..9a020ce1bbda 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -62,10 +62,54 @@ export const GlassOpacity = Schema.Int.check( ); export type GlassOpacity = typeof GlassOpacity.Type; export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; +/** + * Font size preferences, in CSS pixels. The ranges are deliberately narrow: + * the interface size scales every rem-based dimension in the app, so the + * bounds keep layouts intact rather than offering unusable extremes. + */ +export const MIN_INTERFACE_FONT_SIZE = 12; +export const MAX_INTERFACE_FONT_SIZE = 20; +export const InterfaceFontSize = Schema.Int.check( + Schema.isBetween({ minimum: MIN_INTERFACE_FONT_SIZE, maximum: MAX_INTERFACE_FONT_SIZE }), +); +export type InterfaceFontSize = typeof InterfaceFontSize.Type; +export const DEFAULT_INTERFACE_FONT_SIZE: InterfaceFontSize = 16; + +export const MIN_PROMPT_FONT_SIZE = 12; +export const MAX_PROMPT_FONT_SIZE = 20; +export const PromptFontSize = Schema.Int.check( + Schema.isBetween({ minimum: MIN_PROMPT_FONT_SIZE, maximum: MAX_PROMPT_FONT_SIZE }), +); +export type PromptFontSize = typeof PromptFontSize.Type; +export const DEFAULT_PROMPT_FONT_SIZE: PromptFontSize = 14; + +export const MIN_CODE_FONT_SIZE = 10; +export const MAX_CODE_FONT_SIZE = 18; +export const CodeFontSize = Schema.Int.check( + Schema.isBetween({ minimum: MIN_CODE_FONT_SIZE, maximum: MAX_CODE_FONT_SIZE }), +); +export type CodeFontSize = typeof CodeFontSize.Type; +export const DEFAULT_CODE_FONT_SIZE: CodeFontSize = 13; + +export const MIN_TERMINAL_FONT_SIZE = 8; +export const MAX_TERMINAL_FONT_SIZE = 20; +export const TerminalFontSize = Schema.Int.check( + Schema.isBetween({ minimum: MIN_TERMINAL_FONT_SIZE, maximum: MAX_TERMINAL_FONT_SIZE }), +); +export type TerminalFontSize = typeof TerminalFontSize.Type; +export const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12; + export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]); export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; +/** + * A user-chosen font family (a single name or a comma-separated list). Empty + * means "use the app default"; clients compose their own fallback stacks. + */ +export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)); +export type FontFamilyPreference = typeof FontFamilyPreference.Type; + export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -80,6 +124,25 @@ export const ClientSettingsSchema = Schema.Struct({ glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), + fontSizeInterface: InterfaceFontSize.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_INTERFACE_FONT_SIZE)), + ), + fontSizePrompt: PromptFontSize.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROMPT_FONT_SIZE)), + ), + fontSizeCode: CodeFontSize.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_CODE_FONT_SIZE)), + ), + fontSizeTerminal: TerminalFontSize.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_FONT_SIZE)), + ), + fontFamilyCode: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + fontFamilyComposer: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + fontFamilySans: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + fontFamilyTerminal: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes); + // disabling restores the platform's heavier default. No effect off macOS. + fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -887,6 +950,15 @@ export const ClientSettingsPatch = Schema.Struct({ diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), + fontSizeInterface: Schema.optionalKey(InterfaceFontSize), + fontSizePrompt: Schema.optionalKey(PromptFontSize), + fontSizeCode: Schema.optionalKey(CodeFontSize), + fontSizeTerminal: Schema.optionalKey(TerminalFontSize), + fontFamilyCode: Schema.optionalKey(FontFamilyPreference), + fontFamilyComposer: Schema.optionalKey(FontFamilyPreference), + fontFamilySans: Schema.optionalKey(FontFamilyPreference), + fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference), + fontSmoothing: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8afec64bcc4..f8f4ef7bf33b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -550,12 +550,6 @@ importers: '@effect/atom-react': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.6)(scheduler@0.27.0) - '@fontsource-variable/dm-sans': - specifier: ^5.2.8 - version: 5.2.8 - '@fontsource/jetbrains-mono': - specifier: ^5.2.8 - version: 5.2.8 '@formkit/auto-animate': specifier: ^0.9.0 version: 0.9.0 @@ -2794,12 +2788,6 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - '@fontsource-variable/dm-sans@5.2.8': - resolution: {integrity: sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g==} - - '@fontsource/jetbrains-mono@5.2.8': - resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==} - '@formkit/auto-animate@0.9.0': resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} @@ -13010,10 +12998,6 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@fontsource-variable/dm-sans@5.2.8': {} - - '@fontsource/jetbrains-mono@5.2.8': {} - '@formkit/auto-animate@0.9.0': {} '@github/copilot-darwin-arm64@1.0.2': From 9757351f07386ab4a64cdd8b4f0fcc729470601b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 4 Aug 2026 16:35:51 -0700 Subject: [PATCH 06/14] feat(sidebar-v2): thread pinning for sidebar v2 (#5312) Co-authored-by: Claude Fable 5 --- apps/mobile/src/components/AppSymbol.tsx | 4 + .../src/features/home/HomeRouteScreen.tsx | 4 + apps/mobile/src/features/home/HomeScreen.tsx | 30 ++ .../src/features/home/useThreadListActions.ts | 69 +++++ .../threads/ThreadNavigationSidebar.tsx | 19 ++ .../features/threads/thread-list-v2-items.tsx | 42 ++- .../src/features/threads/threadListV2.test.ts | 51 ++++ .../src/features/threads/threadListV2.ts | 28 +- .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProjectionPipeline.ts | 31 +++ .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 10 + apps/server/src/orchestration/Schemas.ts | 4 + .../src/orchestration/decider.pinned.test.ts | 226 +++++++++++++++ apps/server/src/orchestration/decider.ts | 119 +++++++- .../orchestration/projector.pinned.test.ts | 77 ++++++ apps/server/src/orchestration/projector.ts | 24 ++ .../Layers/ProjectionRepositories.test.ts | 5 + .../persistence/Layers/ProjectionThreads.ts | 5 + apps/server/src/persistence/Migrations.ts | 4 + .../Migrations/038_ProjectionThreadsPinned.ts | 16 ++ .../persistence/Services/ProjectionThreads.ts | 1 + apps/web/src/components/SidebarV2.tsx | 260 +++++++++++++----- apps/web/src/hooks/useThreadActions.ts | 64 +++++ apps/web/src/state/entities.ts | 9 + .../client-runtime/src/operations/commands.ts | 22 ++ .../src/state/threadCommands.ts | 18 ++ .../client-runtime/src/state/threadDetail.ts | 1 + .../src/state/threadReducer.test.ts | 49 ++++ .../client-runtime/src/state/threadReducer.ts | 20 ++ packages/contracts/src/environment.ts | 3 + packages/contracts/src/orchestration.ts | 44 +++ 32 files changed, 1182 insertions(+), 80 deletions(-) create mode 100644 apps/server/src/orchestration/decider.pinned.test.ts create mode 100644 apps/server/src/orchestration/projector.pinned.test.ts create mode 100644 apps/server/src/persistence/Migrations/038_ProjectionThreadsPinned.ts diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index d4c7088fe507..b1e83607d8e7 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -50,6 +50,8 @@ import { IconMinus, IconNetwork, IconPalette, + IconPin, + IconPinnedOff, IconPlayerPlay, IconPlayerStopFilled, IconPlus, @@ -121,6 +123,8 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { magnifyingglass: IconSearch, paintbrush: IconPalette, "person.crop.circle": IconUserCircle, + pin: IconPin, + "pin.slash": IconPinnedOff, play: IconPlayerPlay, plus: IconPlus, "qrcode.viewfinder": IconQrcode, diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 0aefde991002..16a3efde6dd3 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -42,6 +42,8 @@ export function HomeRouteScreen() { settleThread, snoozeThread, unsnoozeThread, + pinThread, + unpinThread, unsettleThread, } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); @@ -155,6 +157,8 @@ export function HomeRouteScreen() { onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} + onPinThread={pinThread} + onUnpinThread={unpinThread} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} onOpenEnvironments={() => diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 1fae00983360..887047366cd3 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -111,6 +111,8 @@ interface HomeScreenProps { ) => Promise; readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; + readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; + readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -516,6 +518,18 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onUnsnoozeThread], ); + const handlePinThread = useCallback( + (thread: EnvironmentThreadShell) => { + void props.onPinThread(thread); + }, + [props.onPinThread], + ); + const handleUnpinThread = useCallback( + (thread: EnvironmentThreadShell) => { + void props.onUnpinThread(thread); + }, + [props.onUnpinThread], + ); const handleDeleteThread = props.onDeleteThread; const handleUnsettleThread = props.onUnsettleThread; // The settled tail renders in pages; expansion resets when the filter @@ -579,6 +593,15 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const pinningEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinning === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -722,6 +745,7 @@ export function HomeScreen(props: HomeScreenProps) { thread={thread} variant={item.item.variant} snoozed={item.item.snoozed} + pinned={item.item.pinned} snoozePresetMinute={nowMinute} snoozeWakeLabelText={item.snoozeWakeLabelText} project={ @@ -757,9 +781,12 @@ export function HomeScreen(props: HomeScreenProps) { settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds?.has(thread.environmentId) ?? true} + pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} + onPinThread={handlePinThread} + onUnpinThread={handleUnpinThread} onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null @@ -772,12 +799,15 @@ export function HomeScreen(props: HomeScreenProps) { [ handleChangeRequestState, handleDeleteThread, + handlePinThread, handleSettleThread, handleSnoozeThread, + handleUnpinThread, handleUnsnoozeThread, handleSwipeableClose, handleSwipeableWillOpen, handleUnsettleThread, + pinningEnvironmentIds, projectByKey, projectCwdByKey, props.onArchiveThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 0c621a04e38b..dcea2b6791b0 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -29,6 +29,13 @@ function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["enviro ); } +function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadPinning === true + ); +} + type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -202,10 +209,14 @@ export function useThreadListActions(): { readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise; readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise; readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; + readonly pinThread: (thread: EnvironmentThreadShell) => Promise; + readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); + const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); + const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false }); const snoozeInFlightThreadKeys = useRef(new Set()); const archiveThread = useCallback( @@ -310,6 +321,62 @@ export function useThreadListActions(): { async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, [executeAction], ); + const pinThread = useCallback( + async (thread: EnvironmentThreadShell) => { + if (!environmentSupportsPinning(thread.environmentId)) { + Alert.alert( + "Could not pin thread", + "This environment's server does not support pinning yet. Update the server to use Pin.", + ); + return false; + } + selectionHaptic(); + const result = await pinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not pin thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be pinned.", + ); + return false; + } + return true; + }, + [pinMutation], + ); + const unpinThread = useCallback( + async (thread: EnvironmentThreadShell) => { + if (!environmentSupportsPinning(thread.environmentId)) { + Alert.alert( + "Could not unpin thread", + "This environment's server does not support pinning yet. Update the server to use Pin.", + ); + return false; + } + selectionHaptic(); + const result = await unpinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not unpin thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be unpinned.", + ); + return false; + } + return true; + }, + [unpinMutation], + ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); @@ -320,6 +387,8 @@ export function useThreadListActions(): { snoozeThread, unsnoozeThread, unsettleThread, + pinThread, + unpinThread, }; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index feebf056eded..4bdfa368f66e 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -205,6 +205,8 @@ function ThreadNavigationSidebarPane( snoozeThread, unsnoozeThread, unsettleThread, + pinThread, + unpinThread, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); @@ -482,6 +484,15 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const pinningEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinning === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -793,6 +804,7 @@ function ThreadNavigationSidebarPane( previous.item.thread === item.item.thread && previous.item.variant === item.item.variant && previous.item.snoozed === item.item.snoozed && + previous.item.pinned === item.item.pinned && previous.snoozeWakeLabelText === item.snoozeWakeLabelText ); } @@ -880,6 +892,7 @@ function ThreadNavigationSidebarPane( thread={thread} variant={item.item.variant} snoozed={item.item.snoozed} + pinned={item.item.pinned} snoozePresetMinute={nowMinute} snoozeWakeLabelText={item.snoozeWakeLabelText} project={projectByKey.get(scopeKey) ?? null} @@ -916,9 +929,12 @@ function ThreadNavigationSidebarPane( settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds?.has(thread.environmentId) ?? true} + pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} + onPinThread={pinThread} + onUnpinThread={unpinThread} onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} @@ -1046,6 +1062,8 @@ function ThreadNavigationSidebarPane( handleSwipeableClose, handleSwipeableWillOpen, openPendingTask, + pinThread, + pinningEnvironmentIds, projectByKey, projectCwdByKey, projectTitleByProjectKey, @@ -1065,6 +1083,7 @@ function ThreadNavigationSidebarPane( nowMinute, toggleSettledShelf, toggleSnoozedShelf, + unpinThread, unsettleThread, unsnoozeThread, updateGroupDisplay, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 8d6874c78558..6b9dd52512ac 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -305,6 +305,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly variant: "card" | "slim"; /** Snoozed-shelf row: shows its wake time and offers Wake. */ readonly snoozed?: boolean; + /** Pinned-block row: shows the pin glyph and offers Unpin. */ + readonly pinned?: boolean; /** Preformatted against the parent minute tick so this memoized row's countdown keeps moving. */ readonly snoozeWakeLabelText?: string; @@ -337,11 +339,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + readonly onPinThread: (thread: EnvironmentThreadShell) => void; + readonly onUnpinThread: (thread: EnvironmentThreadShell) => void; /** False on environments whose server predates thread.settle/unsettle: swipe + menu fall back to Archive instead of failing on use. */ readonly settlementSupported: boolean; /** False on servers that predate thread.snooze/unsnooze. */ readonly snoozeSupported: boolean; + /** False on servers that predate thread.pin/unpin. */ + readonly pinningSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; /** Reports this row's live PR state up so the partition can auto-settle @@ -368,9 +374,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onUnsnoozeThread, onUnsettleThread, onArchiveThread, + onPinThread, + onUnpinThread, onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; + const pinnedRow = props.pinned === true; const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); const prState = pr?.state ?? null; @@ -383,6 +392,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const drawerColor = useThemeColor("--color-drawer"); const pressedBackgroundColor = useThemeColor("--color-subtle"); const selectedBackgroundColor = useThemeColor("--color-user-bubble"); + const pinTintColor = useThemeColor("--color-foreground-muted"); const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; @@ -398,6 +408,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]); const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); + const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); + const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); // Swipe: the v2 primary action is the lifecycle transition. Every settled @@ -434,6 +446,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { })), [snoozePresets], ); + // Pinned cards keep the full lifecycle menu; only the pin item flips to + // Unpin. (Settling a pinned thread clears the pin server-side; snoozing + // hides the card until wake with the pin intact.) + const pinMenuItem = useMemo( + () => + props.pinningSupported + ? [ + pinnedRow + ? { id: "unpin", title: "Unpin", image: "pin.slash" } + : { id: "pin", title: "Pin", image: "pin" }, + ] + : [], + [pinnedRow, props.pinningSupported], + ); const snoozableCardMenuActions = useMemo( () => [ { id: "settle", title: "Settle", image: "checkmark" }, @@ -443,15 +469,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { image: "clock", subactions: snoozePresetActions, }, + ...pinMenuItem, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [snoozePresetActions], + [pinMenuItem, snoozePresetActions], + ); + const cardMenuActions = useMemo( + () => [CARD_MENU_ACTIONS[0]!, ...pinMenuItem, ...CARD_MENU_ACTIONS.slice(1)], + [pinMenuItem], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "settle") handleSettle(); if (nativeEvent.event === "unsettle") handleUnsettle(); if (nativeEvent.event === "unsnooze") handleUnsnooze(); + if (nativeEvent.event === "pin") handlePin(); + if (nativeEvent.event === "unpin") handleUnpin(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ @@ -468,8 +501,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [ handleArchive, handleDelete, + handlePin, handleSettle, handleSnooze, + handleUnpin, handleUnsettle, handleUnsnooze, snoozePresets, @@ -560,6 +595,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {props.projectTitle ?? props.project?.title ?? ""} + {pinnedRow ? ( + + ) : null} { expect(layout.snoozedCount).toBe(1); }); + it("renders pinned threads first and exempts them from auto-settle — parity with web", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("pinned-settled"), + title: "Pinned while settled", + pinnedAt: "2026-06-01T12:00:00.000Z", + // Stale settled state (the decider clears it on pin): the pin wins. + settledOverride: "settled", + settledAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["pinned-settled", "active"]); + expect(layout.items.map((item) => item.pinned)).toEqual([true, false]); + expect(layout.settledCount).toBe(0); + }); + + it("snooze hides a pinned thread and wake restores it to the pinned block", () => { + const snoozedInput = { + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("pinned-snoozed"), + title: "Pinned and snoozed", + pinnedAt: "2026-06-01T12:00:00.000Z", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T11:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + }; + + // Before the wake time: the snooze wins; the pin holds underneath. + const whileSnoozed = buildThreadListV2Items({ ...snoozedInput, now: NOW }); + expect(whileSnoozed.items.map((item) => item.thread.id)).toEqual(["active"]); + expect(whileSnoozed.snoozedCount).toBe(1); + + // After the wake time: the thread returns pinned, back on top. + const afterWake = buildThreadListV2Items({ ...snoozedInput, now: "2026-06-03T10:00:00.000Z" }); + expect(afterWake.items.map((item) => item.thread.id)).toEqual(["pinned-snoozed", "active"]); + expect(afterWake.items[0]?.pinned).toBe(true); + expect(afterWake.snoozedCount).toBe(0); + }); + it("classifies snooze with the second-precise clock and reports the next wake", () => { const layout = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index c88aff4ec02d..fa5f58d5d0ee 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -180,6 +180,8 @@ export interface ThreadListV2Item { readonly variant: "card" | "slim"; /** Snoozed-shelf row: shows the wake countdown and offers Wake. */ readonly snoozed: boolean; + /** Pinned-block row: renders the pin glyph and offers Unpin. */ + readonly pinned: boolean; readonly isLast: boolean; } @@ -351,6 +353,7 @@ export function buildThreadListV2Items(input: { ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) : null; + const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; @@ -378,9 +381,10 @@ export function buildThreadListV2Items(input: { const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; const changeRequestState = input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; - // Visibility parity with web: a snoozed thread leaves the list until it - // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed - // work). Snooze outranks settled classification, same as web. + // Visibility parity with web: snooze outranks everything, including a + // pin — a snoozed thread leaves the list until it wakes (or raises its + // hand). The pin survives underneath, so a woken thread reappears at + // its original spot in the creation-ordered pinned block. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); if ( @@ -392,6 +396,12 @@ export function buildThreadListV2Items(input: { } continue; } + // A pin otherwise overrides the lifecycle: pinned threads render above + // the inbox and never auto-settle out of sight. + if (thread.pinnedAt != null) { + pinned.push(thread); + continue; + } if ( supportsSettlement && effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) @@ -434,11 +444,21 @@ export function buildThreadListV2Items(input: { ); const items: ThreadListV2Item[] = []; + for (const thread of sortThreadsForListV2(pinned)) { + items.push({ + thread, + variant: "card", + snoozed: false, + pinned: true, + isLast: false, + }); + } for (const thread of orderedActive) { items.push({ thread, variant: "card", snoozed: false, + pinned: false, isLast: false, }); } @@ -448,6 +468,7 @@ export function buildThreadListV2Items(input: { thread, variant: "slim", snoozed: true, + pinned: false, isLast: false, }); } @@ -457,6 +478,7 @@ export function buildThreadListV2Items(input: { thread, variant: "slim", snoozed: false, + pinned: false, isLast: false, }); } diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a14f89fd0315..b6eedb87e667 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -145,6 +145,7 @@ export const make = Effect.gen(function* () { connectionProbe: true, threadSettlement: true, threadSnooze: true, + threadPinning: true, threadTitleRegeneration: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 4f4fb61d6985..67b672271e0b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -611,6 +611,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -717,6 +718,36 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.pinned": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + pinnedAt: event.payload.pinnedAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unpinned": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + pinnedAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index e804c4049a4d..b08f29c642fc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -312,6 +312,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, titleRegeneration: null, titleRegenerationFailure: null, deletedAt: null, @@ -428,6 +429,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, titleRegeneration: null, titleRegenerationFailure: null, session: { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3aaa36243250..97153bcde4de 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -396,6 +396,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", @@ -433,6 +434,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", @@ -472,6 +474,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", @@ -911,6 +914,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", @@ -1352,6 +1356,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, titleRegeneration: mapTitleRegeneration(row), titleRegenerationFailure: mapTitleRegenerationFailure(row), deletedAt: row.deletedAt, @@ -1556,6 +1561,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, titleRegeneration: mapTitleRegeneration(row), titleRegenerationFailure: mapTitleRegenerationFailure(row), deletedAt: row.deletedAt, @@ -1691,6 +1697,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, titleRegeneration: mapTitleRegeneration(row), titleRegenerationFailure: mapTitleRegenerationFailure(row), session: sessionByThread.get(row.threadId) ?? null, @@ -1831,6 +1838,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, titleRegeneration: mapTitleRegeneration(row), titleRegenerationFailure: mapTitleRegenerationFailure(row), session: sessionByThread.get(row.threadId) ?? null, @@ -2103,6 +2111,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: threadRow.value.settledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, + pinnedAt: threadRow.value.pinnedAt, titleRegeneration: mapTitleRegeneration(threadRow.value), titleRegenerationFailure: mapTitleRegenerationFailure(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, @@ -2203,6 +2212,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: threadRow.value.settledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, + pinnedAt: threadRow.value.pinnedAt, titleRegeneration: mapTitleRegeneration(threadRow.value), titleRegenerationFailure: mapTitleRegenerationFailure(threadRow.value), deletedAt: null, diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 3b558d24739e..ee96e422945a 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -13,6 +13,8 @@ import { ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema, ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, + ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, + ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -42,6 +44,8 @@ export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; +export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; +export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.pinned.test.ts b/apps/server/src/orchestration/decider.pinned.test.ts new file mode 100644 index 000000000000..bed41e13a17e --- /dev/null +++ b/apps/server/src/orchestration/decider.pinned.test.ts @@ -0,0 +1,226 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const PINNED_AT = "1969-12-30T00:00:00.000Z"; + +function makeReadModel(input: { + readonly pinnedAt?: string | null; + readonly archivedAt?: string | null; + readonly settledOverride?: "settled" | "active" | null; + readonly settledAt?: string | null; + readonly snoozedUntil?: string | null; + readonly snoozedAt?: string | null; +}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: input.archivedAt ?? null, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? (input.settledOverride === "settled" ? NOW : null), + snoozedUntil: input.snoozedUntil ?? null, + snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? PINNED_AT : null), + pinnedAt: input.pinnedAt ?? null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("pinned thread decider", (it) => { + it.effect("pins a thread, stamping pinnedAt and updatedAt together", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinnedAt).toBe(events[0].payload.updatedAt); + } + }), + ); + + it.effect("re-pinning preserves the original pinnedAt and updatedAt", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-again"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinnedAt).toBe(PINNED_AT); + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("unpins a pinned thread", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.unpin", + commandId: CommandId.make("cmd-unpin"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.unpinned"); + if (events[0]?.type === "thread.unpinned") { + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("unpinning an unpinned thread preserves updatedAt", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.unpin", + commandId: CommandId.make("cmd-unpin-noop"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.unpinned"); + if (events[0]?.type === "thread.unpinned") { + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("pinning a settled thread also un-settles it", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-settled"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ settledOverride: "settled" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsettled"]); + const unsettled = events.find((entry) => entry.type === "thread.unsettled"); + if (unsettled?.type === "thread.unsettled") { + expect(unsettled.payload.reason).toBe("user"); + } + }), + ); + + it.effect("pinning a snoozed thread also wakes it", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-snoozed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ snoozedUntil: "1970-01-02T09:00:00.000Z" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsnoozed"]); + }), + ); + + it.effect("pinning an unparked thread emits only thread.pinned", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-plain"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events.map((entry) => entry.type)).toEqual(["thread.pinned"]); + }), + ); + + it.effect("settling a pinned thread also unpins it", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-pinned"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unpinned"]); + }), + ); + + it.effect("settling an unpinned thread emits no unpin event", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-unpinned"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events.map((entry) => entry.type)).toEqual(["thread.settled"]); + }), + ); + + it.effect("rejects pinning an archived thread", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-archived"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ archivedAt: NOW }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index e5007adb8e4a..3347fd617902 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -487,14 +487,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // settledAt: the engine rejects zero-event commands, and bulk-settle / // double-click must stay silent no-ops rather than surface errors. const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null; - return { + const settledEvent = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), - type: "thread.settled", + type: "thread.settled" as const, payload: { threadId: command.threadId, settledAt: alreadySettled ? thread.settledAt : occurredAt, @@ -504,6 +504,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" updatedAt: alreadySettled ? thread.updatedAt : occurredAt, }, }; + // Settling is "I'm done with this": it clears a pin the same way it + // parks the thread. Without this, settling a pinned thread would only + // stamp invisible state — the pin would hold the card in place until + // a separate unpin. + if (thread.pinnedAt == null) { + return settledEvent; + } + return [ + settledEvent, + { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unpinned" as const, + payload: { + threadId: command.threadId, + updatedAt: occurredAt, + }, + }, + ]; } case "thread.unsettle": { @@ -630,6 +653,98 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.pin": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + // Re-pinning an already-pinned thread is a duplicate (double-click, + // raced clients): re-emit with the original timestamps so the + // projection is a no-op. Pinning has no lifecycle invariants — a pin + // only ever promotes visibility, so it can never hide pending work. + const existingPinnedAt = thread.pinnedAt ?? null; + const pinnedEvent = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pinned" as const, + payload: { + threadId: command.threadId, + pinnedAt: existingPinnedAt ?? occurredAt, + updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt, + }, + }; + // Pinning is a promotion: it clears the parked states rather than + // silently outranking them. An explicit settle un-settles (reason + // "user", same override the un-settle button stamps), and a snooze's + // return ticket is spent — the thread is on top NOW, not on Tuesday. + const promotionEvents: Array> = []; + if (thread.settledOverride === "settled") { + promotionEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "user", + updatedAt: occurredAt, + }, + }); + } + if (thread.snoozedUntil != null) { + promotionEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: "user", + updatedAt: occurredAt, + }, + }); + } + return promotionEvents.length > 0 ? [pinnedEvent, ...promotionEvents] : pinnedEvent; + } + + case "thread.unpin": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.settle): unpinning a thread + // that is not pinned lands on the same null state without churning + // updatedAt. + const alreadyUnpinned = thread.pinnedAt == null; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unpinned", + payload: { + threadId: command.threadId, + updatedAt: alreadyUnpinned ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.pinned.test.ts b/apps/server/src/orchestration/projector.pinned.test.ts new file mode 100644 index 000000000000..35bd063667a8 --- /dev/null +++ b/apps/server/src/orchestration/projector.pinned.test.ts @@ -0,0 +1,77 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects pin lifecycle events", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + expect(created.threads[0]?.pinnedAt ?? null).toBeNull(); + + const pinned = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pinned", + payload: { threadId: ThreadId.make("thread-1"), pinnedAt: now, updatedAt: now }, + }), + ); + expect(pinned.threads[0]?.pinnedAt).toBe(now); + + const unpinned = yield* projectEvent( + pinned, + makeEvent({ + sequence: 3, + type: "thread.unpinned", + payload: { threadId: ThreadId.make("thread-1"), updatedAt: now }, + }), + ); + expect(unpinned.threads[0]?.pinnedAt).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index a6bc35fd7d6e..fb82fd767346 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -23,7 +23,9 @@ import { ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, ThreadSettledPayload, + ThreadPinnedPayload, ThreadSnoozedPayload, + ThreadUnpinnedPayload, ThreadUnarchivedPayload, ThreadUnsettledPayload, ThreadUnsnoozedPayload, @@ -394,6 +396,28 @@ export function projectEvent( })), ); + case "thread.pinned": + return decodeForEvent(ThreadPinnedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + pinnedAt: payload.pinnedAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unpinned": + return decodeForEvent(ThreadUnpinnedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + pinnedAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 4763f5656538..71d7df566fd2 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -95,6 +95,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { settledAt: "2026-03-25T00:00:00.000Z", snoozedUntil: "2026-03-26T09:00:00.000Z", snoozedAt: "2026-03-25T00:00:00.000Z", + pinnedAt: "2026-03-25T00:00:00.000Z", latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -175,6 +177,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z"); assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z"); + assert.strictEqual(row.pinnedAt, "2026-03-25T00:00:00.000Z"); // Un-settle to the keep-active pin and wake the snooze; confirm the // flips persist. @@ -184,6 +187,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, }); const repersisted = yield* threads.getById({ threadId: ThreadId.make("thread-settled"), @@ -193,6 +197,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.strictEqual(updated?.settledAt, null); assert.strictEqual(updated?.snoozedUntil, null); assert.strictEqual(updated?.snoozedAt, null); + assert.strictEqual(updated?.pinnedAt, null); }), ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1b1f5651cbd6..9480bc43d083 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -47,6 +47,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at, snoozed_until, snoozed_at, + pinned_at, title_regeneration_request_id, title_regeneration_started_at, title_regeneration_failure_request_id, @@ -75,6 +76,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.settledAt}, ${row.snoozedUntil}, ${row.snoozedAt}, + ${row.pinnedAt}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.titleRegenerationFailureRequestId ?? null}, @@ -103,6 +105,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at = excluded.settled_at, snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, + pinned_at = excluded.pinned_at, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, title_regeneration_failure_request_id = excluded.title_regeneration_failure_request_id, @@ -138,6 +141,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", @@ -175,6 +179,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a52a430cf518..d5a0f660a3b0 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -60,6 +60,9 @@ import Migration0037 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0038 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0039 from "./Migrations/036_ProjectionThreadTitleRegenerationFailure.ts"; import Migration0040 from "./Migrations/037_RepairProjectionThreadTitleRegenerationFailure.ts"; +// Upstream ProjectionThreadsPinned (upstream file 036 / runtime 36) renumbered +// past fork titleRegenerationFailure filenames 036/037 and runtime ids 39/40. +import Migration0041 from "./Migrations/038_ProjectionThreadsPinned.ts"; /** * Migration loader with all migrations defined inline. @@ -112,6 +115,7 @@ export const migrationEntries = [ [38, "ProjectionThreadTitleRegeneration", Migration0038], [39, "ProjectionThreadTitleRegenerationFailure", Migration0039], [40, "RepairProjectionThreadTitleRegenerationFailure", Migration0040], + [41, "ProjectionThreadsPinned", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinned.ts b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinned.ts new file mode 100644 index 000000000000..c96e4693fc57 --- /dev/null +++ b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinned.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "pinned_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pinned_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 50ab80c101c5..d18d8f1ca705 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -41,6 +41,7 @@ export const ProjectionThread = Schema.Struct({ settledAt: Schema.NullOr(IsoDateTime), snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), + pinnedAt: Schema.NullOr(IsoDateTime), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), titleRegenerationFailureRequestId: Schema.optional(Schema.NullOr(CommandId)), diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 1c8e0c353b14..8c641ce3e256 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -33,6 +33,7 @@ import { GitBranchIcon, EllipsisIcon, MessageSquareIcon, + PinIcon, PlusIcon, SearchIcon, ServerIcon, @@ -399,6 +400,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { settlementSupported: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; + // Renders the pin glyph. Pinned cards keep the full settle/snooze quick + // actions: settling clears the pin server-side, and snoozing hides the + // card until wake with the pin intact underneath. Pin/unpin themselves + // live in the context menu only. + isPinned: boolean; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -944,6 +950,13 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : ( )} + {props.isPinned ? ( + + ) : null} {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping the hidden state out of flow lets the project label reclaim @@ -1233,8 +1246,15 @@ export default function SidebarV2() { const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } = - useThreadActions(); + const { + settleThread, + unsettleThread, + snoozeThread, + unsnoozeThread, + pinThread, + unpinThread, + deleteThread, + } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -1611,76 +1631,91 @@ export default function SidebarV2() { // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. const serverConfigs = useAtomValue(environmentServerConfigsAtom); - const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { - const now = `${nowMinute}:00.000Z`; - // Snooze classification uses a REAL clock, not the quantized minute: - // wake times are second-precise and a woken thread must not linger on - // the shelf for the rest of the minute. snoozeWakeTick re-runs this - // memo exactly at the next wake boundary. - void snoozeWakeTick; - const preciseNow = new Date().toISOString(); - const visible = threads.filter( - (thread) => - thread.archivedAt === null && - (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), - ); - const active: EnvironmentThreadShell[] = []; - const snoozed: EnvironmentThreadShell[] = []; - const settled: EnvironmentThreadShell[] = []; - for (const thread of visible) { - // Threads on servers without the settlement capability (old server, - // or descriptor not loaded yet) never classify as settled: the user - // could neither un-settle nor pin them, so auto-settling them would - // strand rows in a tail with no working affordances. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; - // Snooze outranks settled classification: an explicitly snoozed thread - // belongs to the shelf even if it would also auto-settle (the shelf's - // wake time is a stronger statement about when it matters again). - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { - snoozed.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) - ) { - settled.push(thread); - } else { - active.push(thread); + const { pinnedThreads, activeThreads, snoozedThreads, settledThreads, snoozeNow } = + useMemo(() => { + const now = `${nowMinute}:00.000Z`; + // Snooze classification uses a REAL clock, not the quantized minute: + // wake times are second-precise and a woken thread must not linger on + // the shelf for the rest of the minute. snoozeWakeTick re-runs this + // memo exactly at the next wake boundary. + void snoozeWakeTick; + const preciseNow = new Date().toISOString(); + const visible = threads.filter( + (thread) => + thread.archivedAt === null && + (scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + ); + const pinned: EnvironmentThreadShell[] = []; + const active: EnvironmentThreadShell[] = []; + const snoozed: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of visible) { + // Threads on servers without the settlement capability (old server, + // or descriptor not loaded yet) never classify as settled: the user + // could neither un-settle nor pin them, so auto-settling them would + // strand rows in a tail with no working affordances. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true; + const supportsSnooze = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + // Snooze outranks everything, including a pin: "hide until Tuesday" + // temporarily suspends "keep on top". The pin survives underneath — + // pinned cards are creation-ordered, so on wake the thread reappears + // at its original spot in the pinned block. (For unpinned threads + // this is also the snooze-beats-auto-settle rule: the wake time is a + // stronger statement about when the thread matters again.) + if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + snoozed.push(thread); + // A pin otherwise overrides the lifecycle: pinned threads never + // auto-settle out of sight. (The decider clears settled state on + // pin and the pin on settle, so pin-vs-settled conflicts only + // arise from stale or raced writes.) + } else if (thread.pinnedAt != null) { + pinned.push(thread); + } else if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { + settled.push(thread); + } else { + active.push(thread); + } } - } - return { - activeThreads: sortThreadsForSidebarV2(active), - // Soonest wake first: "what comes back next" is the shelf's question. - snoozedThreads: snoozed.toSorted( - (left, right) => - firstValidTimestampMs(left.snoozedUntil ?? null) - - firstValidTimestampMs(right.snoozedUntil ?? null), - ), - settledThreads: sortSettledThreadsForSidebarV2(settled), - snoozeNow: preciseNow, - }; - }, [ - autoSettleAfterDays, - changeRequestStateByKey, - nowMinute, - scopedProjectKeys, - serverConfigs, - snoozeWakeTick, - threads, - ]); + return { + // Same static creation order as the inbox: a pin freezes prominence, + // it does not introduce a new ordering scheme. + pinnedThreads: sortThreadsForSidebarV2(pinned), + activeThreads: sortThreadsForSidebarV2(active), + // Soonest wake first: "what comes back next" is the shelf's question. + snoozedThreads: snoozed.toSorted( + (left, right) => + firstValidTimestampMs(left.snoozedUntil ?? null) - + firstValidTimestampMs(right.snoozedUntil ?? null), + ), + settledThreads: sortSettledThreadsForSidebarV2(settled), + snoozeNow: preciseNow, + }; + }, [ + autoSettleAfterDays, + changeRequestStateByKey, + nowMinute, + scopedProjectKeys, + serverConfigs, + snoozeWakeTick, + threads, + ]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); const isSearchingThreads = threadSearchQuery.trim().length > 0; const searchableThreads = useMemo( - () => [...activeThreads, ...snoozedThreads, ...settledThreads], - [activeThreads, settledThreads, snoozedThreads], + () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], + [activeThreads, pinnedThreads, settledThreads, snoozedThreads], ); const threadSearchResults = useMemo( () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), @@ -1784,8 +1819,8 @@ export default function SidebarV2() { }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); const orderedThreads = useMemo( - () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], - [activeThreads, visibleSnoozedThreads, renderedSettledThreads], + () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], + [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], ); const orderedThreadKeys = useMemo( () => @@ -2087,6 +2122,42 @@ export default function SidebarV2() { }, [unsnoozeThread], ); + const attemptPin = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await pinThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to pin thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [pinThread], + ); + const attemptUnpin = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await unpinThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to unpin thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [unpinThread], + ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); const attemptSnooze = useCallback( @@ -2250,7 +2321,8 @@ export default function SidebarV2() { // Post-settle navigation must skip threads settling in this same // batch — they are all leaving the card block together. Rows that // are already explicitly settled are skipped: nothing to do on a - // valid mixed selection. + // valid mixed selection. Pinned rows ARE included: the decider + // clears the pin as part of settling, so they park like the rest. const coSettlingKeys = new Set(threadKeys); for (const threadKey of threadKeys) { const thread = threadByKeyRef.current.get(threadKey); @@ -2347,6 +2419,8 @@ export default function SidebarV2() { true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const supportsPinning = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; const supportsTitleRegeneration = serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true; @@ -2354,6 +2428,7 @@ export default function SidebarV2() { const lastTitleRegenerationError = titleRegenerationFailureReason(thread); const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); + const isPinned = thread.pinnedAt != null; // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => @@ -2367,6 +2442,16 @@ export default function SidebarV2() { }, ] : []), + ...(supportsPinning + ? [ + isPinned + ? { id: "unpin", label: "Unpin thread" } + : { id: "pin", label: "Pin thread" }, + ] + : []), + // Both lifecycle actions stay available on pinned threads: + // settling clears the pin ("done" beats "keep on top"), and + // snoozing hides the card until wake with the pin intact. ...(supportsSettlement ? [ isSettled @@ -2454,6 +2539,12 @@ export default function SidebarV2() { case "unsnooze": attemptUnsnooze(threadRef); return; + case "pin": + attemptPin(threadRef); + return; + case "unpin": + attemptUnpin(threadRef); + return; case "rename": startThreadRename(threadRef, thread.title); return; @@ -2528,8 +2619,10 @@ export default function SidebarV2() { })(); }, [ + attemptPin, attemptSettle, attemptSnooze, + attemptUnpin, attemptUnsettle, attemptUnsnooze, confirmThreadDelete, @@ -2884,7 +2977,7 @@ export default function SidebarV2() { {(() => { const renderThreadRow = ( thread: EnvironmentThreadShell, - section: "active" | "snoozed" | "settled", + section: "pinned" | "active" | "snoozed" | "settled", ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), @@ -2893,7 +2986,7 @@ export default function SidebarV2() { // row: every other thread is a full card. Density comes // from users (or the auto rules) actually parking work, // not from the sidebar second-guessing what still matters. - const isCard = section === "active"; + const isCard = section === "active" || section === "pinned"; const rowVariant = isCard ? "card" : "slim"; return ( ); }; - const items: ReactNode[] = activeThreads.map((thread) => - renderThreadRow(thread, "active"), + // Pinned block: full cards above the inbox, closed by a + // thin divider (the pin glyphs carry the meaning, so no + // header text). Vanishes entirely at count 0. + const items: ReactNode[] = pinnedThreads.map((thread) => + renderThreadRow(thread, "pinned"), ); + if (pinnedThreads.length > 0) { + items.push( +
  • , + ); + } + for (const thread of activeThreads) { + items.push(renderThreadRow(thread, "active")); + } // Snoozed shelf: between the inbox and Settled — out of the // way, never gone. The header always renders while anything // is snoozed (the count is the whole footprint when @@ -3060,7 +3170,11 @@ export default function SidebarV2() { ) : null} {!isSearchingThreads && - activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? ( + pinnedThreads.length + + activeThreads.length + + snoozedThreads.length + + settledThreads.length === + 0 ? (
    {projects.length === 0 ? ( <> diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 91a3779a0575..96ec645551aa 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -21,6 +21,7 @@ import { useNewThreadHandler } from "./useHandleNewThread"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; import { readLocalApi } from "../localApi"; import { + readEnvironmentSupportsPinning, readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, readEnvironmentThreadRefs, @@ -94,6 +95,18 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass()( + "ThreadPinningUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This environment's server does not support pinning yet. Update the server to use Pin."; + } +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -111,6 +124,12 @@ export function useThreadActions() { const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false, }); + const pinThreadMutation = useAtomCommand(threadEnvironment.pin, { + reportFailure: false, + }); + const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, { + reportFailure: false, + }); const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false, }); @@ -472,6 +491,47 @@ export function useThreadActions() { [unsettleThreadMutation], ); + const pinThread = useCallback( + async (target: ScopedThreadRef) => { + // Version skew: never send the command to a server that predates it. + if (!readEnvironmentSupportsPinning(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadPinningUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + return pinThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + }, + [pinThreadMutation], + ); + + const unpinThread = useCallback( + async (target: ScopedThreadRef) => { + if (!readEnvironmentSupportsPinning(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadPinningUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + return unpinThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + }, + [unpinThreadMutation], + ); + const snoozeThread = useCallback( async (target: ScopedThreadRef, snoozedUntil: string) => { // Version skew: never send the command to a server that predates it. @@ -565,14 +625,18 @@ export function useThreadActions() { unsettleThread, snoozeThread, unsnoozeThread, + pinThread, + unpinThread, }), [ archiveThread, confirmAndDeleteThread, deleteThread, + pinThread, settleThread, snoozeThread, unarchiveThread, + unpinThread, unsettleThread, unsnoozeThread, ], diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 552468e04d94..3f82973045cc 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -241,6 +241,15 @@ export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boo ); } +/** Whether the environment's server understands thread.pin/unpin. + Same version-skew contract as settlement. */ +export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadPinning === true + ); +} + export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); } diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index ad25d6544dc1..ee200d3a22df 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -39,6 +39,8 @@ export type SettleThreadInput = CommandInput<"thread.settle">; export type UnsettleThreadInput = CommandInput<"thread.unsettle">; export type SnoozeThreadInput = CommandInput<"thread.snooze">; export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; +export type PinThreadInput = CommandInput<"thread.pin">; +export type UnpinThreadInput = CommandInput<"thread.unpin">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -197,6 +199,26 @@ export const unsnoozeThread: (input: UnsnoozeThreadInput) => CommandEffect = Eff }); }); +export const pinThread: (input: PinThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.pinThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.pin", + commandId: yield* commandId(input), + }); +}); + +export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.unpinThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.unpin", + commandId: yield* commandId(input), + }); +}); + export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( "EnvironmentCommands.updateThreadMetadata", )(function* (input) { diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 6c128eb01abc..2eabc5aec16d 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -12,11 +12,13 @@ import { type RevertThreadCheckpointInput, type SetThreadInteractionModeInput, type SetThreadRuntimeModeInput, + type PinThreadInput, type SettleThreadInput, type SnoozeThreadInput, type StartThreadTurnInput, type StopThreadSessionInput, type UnarchiveThreadInput, + type UnpinThreadInput, type UnsettleThreadInput, type UnsnoozeThreadInput, type UpdateThreadMetadataInput, @@ -29,11 +31,13 @@ import { revertThreadCheckpoint, setThreadInteractionMode, setThreadRuntimeMode, + pinThread, settleThread, snoozeThread, startThreadTurn, stopThreadSession, unarchiveThread, + unpinThread, unsettleThread, unsnoozeThread, updateThreadMetadata, @@ -50,11 +54,13 @@ export type { RevertThreadCheckpointInput, SetThreadInteractionModeInput, SetThreadRuntimeModeInput, + PinThreadInput, SettleThreadInput, SnoozeThreadInput, StartThreadTurnInput, StopThreadSessionInput, UnarchiveThreadInput, + UnpinThreadInput, UnsettleThreadInput, UnsnoozeThreadInput, UpdateThreadMetadataInput, @@ -118,6 +124,18 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + pin: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:pin", + execute: (input: PinThreadInput) => pinThread(input), + scheduler, + concurrency, + }), + unpin: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:unpin", + execute: (input: UnpinThreadInput) => unpinThread(input), + scheduler, + concurrency, + }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index eac5ab87da2f..30e8ef582489 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -61,6 +61,7 @@ export function mergeEnvironmentThread( settledAt: shell.settledAt, snoozedUntil: shell.snoozedUntil, snoozedAt: shell.snoozedAt, + pinnedAt: shell.pinnedAt, session: shell.session, }; } diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index dffae6cf6dc5..8b2479c7a349 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -231,6 +231,55 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("thread.pinned / thread.unpinned", () => { + it("sets pinnedAt", () => { + const pinnedAt = "2026-04-01T05:00:00.000Z"; + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 5, + occurredAt: pinnedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.pinned", + payload: { + threadId: ThreadId.make("thread-1"), + pinnedAt, + updatedAt: pinnedAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.pinnedAt).toBe(pinnedAt); + } + }); + + it("clears pinnedAt", () => { + const pinnedThread: OrchestrationThread = { + ...baseThread, + pinnedAt: "2026-04-01T05:00:00.000Z", + }; + const updatedAt = "2026-04-01T06:00:00.000Z"; + const result = applyThreadDetailEvent(pinnedThread, { + ...baseEventFields, + sequence: 6, + occurredAt: updatedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.unpinned", + payload: { + threadId: ThreadId.make("thread-1"), + updatedAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.pinnedAt).toBeNull(); + } + }); + }); + describe("thread.meta-updated", () => { it("patches title and branch", () => { const result = applyThreadDetailEvent(baseThread, { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index f6865b0ecd5d..680a31fd1666 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -167,6 +167,26 @@ export function applyThreadDetailEvent( }, }; + case "thread.pinned": + return { + kind: "updated", + thread: { + ...thread, + pinnedAt: event.payload.pinnedAt, + updatedAt: event.payload.updatedAt, + }, + }; + + case "thread.unpinned": + return { + kind: "updated", + thread: { + ...thread, + pinnedAt: null, + updatedAt: event.payload.updatedAt, + }, + }; + // ── Thread metadata ───────────────────────────────────────────── case "thread.meta-updated": return { diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index b49860c63883..4c44a9596558 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.pin / thread.unpin commands. Same + version-skew contract as threadSettlement. */ + threadPinning: Schema.optionalKey(Schema.Boolean), /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 9e554535c51e..8e8e85d29262 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -392,6 +392,10 @@ export const OrchestrationThread = Schema.Struct({ // Optional so payloads from pre-snooze servers still decode. snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + // A pin overrides the settled/snoozed lifecycle: while pinnedAt is set the + // thread renders in the pinned block and never classifies into a shelf. + // Optional so payloads from pre-pinning servers still decode. + pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), titleRegenerationFailure: Schema.optional(Schema.NullOr(ThreadTitleRegenerationFailure)), @@ -447,6 +451,7 @@ export const OrchestrationThreadShell = Schema.Struct({ settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), titleRegenerationFailure: Schema.optional(Schema.NullOr(ThreadTitleRegenerationFailure)), session: Schema.NullOr(OrchestrationSession), @@ -642,6 +647,18 @@ const ThreadUnsnoozeCommand = Schema.Struct({ reason: Schema.Literal("user"), }); +const ThreadPinCommand = Schema.Struct({ + type: Schema.Literal("thread.pin"), + commandId: CommandId, + threadId: ThreadId, +}); + +const ThreadUnpinCommand = Schema.Struct({ + type: Schema.Literal("thread.unpin"), + commandId: CommandId, + threadId: ThreadId, +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -795,6 +812,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUnsettleCommand, ThreadSnoozeCommand, ThreadUnsnoozeCommand, + ThreadPinCommand, + ThreadUnpinCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -820,6 +839,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUnsettleCommand, ThreadSnoozeCommand, ThreadUnsnoozeCommand, + ThreadPinCommand, + ThreadUnpinCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -940,6 +961,8 @@ export const OrchestrationEventType = Schema.Literals([ "thread.unsettled", "thread.snoozed", "thread.unsnoozed", + "thread.pinned", + "thread.unpinned", "thread.meta-updated", "thread.runtime-mode-set", "thread.interaction-mode-set", @@ -1048,6 +1071,17 @@ export const ThreadUnsnoozedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadPinnedPayload = Schema.Struct({ + threadId: ThreadId, + pinnedAt: IsoDateTime, + updatedAt: IsoDateTime, +}); + +export const ThreadUnpinnedPayload = Schema.Struct({ + threadId: ThreadId, + updatedAt: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -1244,6 +1278,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.unsnoozed"), payload: ThreadUnsnoozedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.pinned"), + payload: ThreadPinnedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.unpinned"), + payload: ThreadUnpinnedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"), From ba80b552669de0cf1761a741f685cfadcf22cf38 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 4 Aug 2026 16:53:54 -0700 Subject: [PATCH 07/14] feat(web): make pairing QR codes actually scannable, with endpoint choice (#5360) Co-authored-by: Claude Fable 5 --- .../ConnectionsSettings.logic.test.ts | 94 ++++- .../settings/ConnectionsSettings.logic.ts | 45 ++- .../settings/ConnectionsSettings.tsx | 365 +++++++++--------- 3 files changed, 315 insertions(+), 189 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index c8f039e64282..290e2daa12b8 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -1,6 +1,10 @@ -import type { DesktopWslState } from "@t3tools/contracts"; +import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + isQrShareableEndpoint, + selectQrEndpointOption, +} from "./ConnectionsSettings.logic"; const baseWslState: DesktopWslState = { enabled: false, @@ -73,3 +77,89 @@ describe("applyWslEnableSelection", () => { expect(state).toMatchObject({ enabled: true, wslOnly: true }); }); }); + +function makeEndpoint(overrides: Partial): AdvertisedEndpoint { + return { + id: "desktop-lan:http://192.168.1.42:4780", + label: "Local network", + provider: { id: "desktop-core", label: "Desktop", kind: "core", isAddon: false }, + httpBaseUrl: "http://192.168.1.42:4780", + wsBaseUrl: "ws://192.168.1.42:4780", + reachability: "lan", + compatibility: { hostedHttpsApp: "unknown", desktopApp: "compatible" }, + source: "desktop-core", + status: "available", + ...overrides, + }; +} + +describe("isQrShareableEndpoint", () => { + it("excludes loopback endpoints so a scanned phone never dials itself", () => { + expect( + isQrShareableEndpoint( + makeEndpoint({ + id: "desktop-loopback:4780", + reachability: "loopback", + httpBaseUrl: "http://127.0.0.1:4780", + }), + ), + ).toBe(false); + }); + + it("excludes unavailable endpoints and keeps reachable ones", () => { + expect(isQrShareableEndpoint(makeEndpoint({ status: "unavailable" }))).toBe(false); + expect(isQrShareableEndpoint(makeEndpoint({}))).toBe(true); + expect( + isQrShareableEndpoint(makeEndpoint({ reachability: "private-network", status: "unknown" })), + ).toBe(true); + }); +}); + +describe("selectQrEndpointOption", () => { + const options = [ + { + id: "desktop-loopback:4780", + preferenceKey: "desktop-core:loopback:http", + qrShareable: false, + }, + { + id: "tailscale-ip:http://100.84.12.7:4780", + preferenceKey: "tailscale:ip:http", + qrShareable: true, + }, + { + id: "tailscale-ip:http://100.84.12.8:4780", + preferenceKey: "tailscale:ip:http", + qrShareable: true, + }, + { + id: "desktop-lan:http://192.168.1.42:4780", + preferenceKey: "desktop-core:lan:http", + qrShareable: true, + }, + ]; + + it("resolves an explicit selection by unique endpoint id, not the shared preference key", () => { + expect(selectQrEndpointOption(options, "tailscale-ip:http://100.84.12.8:4780", null)?.id).toBe( + "tailscale-ip:http://100.84.12.8:4780", + ); + }); + + it("falls back to the saved default preference key when nothing is selected", () => { + expect(selectQrEndpointOption(options, null, "desktop-core:lan:http")?.id).toBe( + "desktop-lan:http://192.168.1.42:4780", + ); + }); + + it("skips non-QR-shareable options in the fallback so the panel never opens on loopback", () => { + expect(selectQrEndpointOption(options, "tailscale-ip:gone", "nope")?.id).toBe( + "tailscale-ip:http://100.84.12.7:4780", + ); + }); + + it("returns the first option when nothing is QR-shareable, and null when empty", () => { + const loopbackOnly = options.slice(0, 1); + expect(selectQrEndpointOption(loopbackOnly, null, null)?.id).toBe("desktop-loopback:4780"); + expect(selectQrEndpointOption([], "anything", "anything")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index 362a24dd3ff2..faa0cb6c7543 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -1,7 +1,50 @@ -import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts"; +import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts"; type WslEnableBridge = Pick; +/** + * A QR code encoding a loopback URL makes the scanning device dial itself, so + * loopback endpoints stay copyable from the endpoint menu but are never + * offered as QR targets. + */ +export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean { + return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback"; +} + +export type QrEndpointOption = { + /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */ + readonly id: string; + /** + * Stable per endpoint *type* (endpointDefaultPreferenceKey). Multiple + * endpoints can share one, so it is only used to match the saved default. + */ + readonly preferenceKey: string; + /** False for endpoints that stay copyable but must never render as a QR. */ + readonly qrShareable: boolean; +}; + +/** + * Resolves which endpoint the share panel shows: the user's explicit pick, + * else the saved default endpoint, else the first QR-shareable option (so the + * panel never opens on a loopback QR), else the first option. A stale + * selectedId (endpoint disappeared) falls back rather than blanking the panel. + */ +export function selectQrEndpointOption( + options: ReadonlyArray, + selectedId: string | null, + defaultPreferenceKey: string | null, +): T | null { + return ( + (selectedId !== null ? options.find((option) => option.id === selectedId) : undefined) ?? + (defaultPreferenceKey !== null + ? options.find((option) => option.preferenceKey === defaultPreferenceKey) + : undefined) ?? + options.find((option) => option.qrShareable) ?? + options[0] ?? + null + ); +} + export async function applyWslEnableSelection(input: { readonly bridge: WslEnableBridge; readonly mode: "both" | "wsl-only"; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 527ba96bf2e4..8bbf35083ec2 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,5 +1,4 @@ import { - ChevronDownIcon, ChevronsLeftRightEllipsisIcon, PlusIcon, QrCodeIcon, @@ -8,7 +7,7 @@ import { TriangleAlertIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { type ReactNode, memo, useCallback, useMemo, useState } from "react"; +import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react"; import { AuthAccessReadScope, AuthAccessWriteScope, @@ -42,7 +41,11 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + isQrShareableEndpoint, + selectQrEndpointOption, +} from "./ConnectionsSettings.logic"; import { SettingsPageContainer, SettingsRow, @@ -82,17 +85,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; -import { Group, GroupSeparator } from "../ui/group"; import { AnimatedHeight } from "../AnimatedHeight"; -import { - Menu, - MenuGroup, - MenuGroupLabel, - MenuItem, - MenuPopup, - MenuSeparator, - MenuTrigger, -} from "../ui/menu"; import { Textarea } from "../ui/textarea"; import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; import { readHostedPairingRequest } from "../../hostedPairing"; @@ -506,6 +499,22 @@ function isHostedAppPairingUrl(value: string): boolean { } } +function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string { + if (isHostedAppPairingUrl(url)) { + return "Opens the hosted app, no install needed"; + } + switch (endpoint.reachability) { + case "lan": + return "Devices on the same network"; + case "private-network": + return "Devices on your private network"; + case "public": + return "Reachable from anywhere"; + case "loopback": + return "Clients on this machine"; + } +} + type PairingLinkListRowProps = { pairingLink: ServerPairingLinkRecord; endpointUrl: string | null | undefined; @@ -531,6 +540,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ [pairingLink.expiresAt], ); const [isRevealDialogOpen, setIsRevealDialogOpen] = useState(false); + const [isQrPanelOpen, setIsQrPanelOpen] = useState(false); + // Ephemeral per-row choice of which endpoint the QR encodes (AdvertisedEndpoint.id); + // null falls back to the saved default endpoint. + const [qrEndpointId, setQrEndpointId] = useState(null); + const qrPanelId = useId(); const currentOriginPairingUrl = useMemo( () => resolveCurrentOriginPairingUrl(pairingLink.credential), @@ -549,10 +563,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ }, [defaultEndpointKey, endpoints, pairingLink.credential]); const endpointCopyOptions = useMemo(() => { const options: Array<{ - readonly key: string; + readonly id: string; + readonly preferenceKey: string; readonly label: string; readonly url: string; readonly detail: string; + readonly qrShareable: boolean; }> = []; for (const endpoint of endpoints) { if (endpoint.status === "unavailable") { @@ -560,10 +576,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ } const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential); options.push({ - key: endpointDefaultPreferenceKey(endpoint), + id: endpoint.id, + preferenceKey: endpointDefaultPreferenceKey(endpoint), label: endpoint.label, url, - detail: isHostedAppPairingUrl(url) ? "Hosted app link" : "Backend pairing URL", + detail: endpointShareHint(endpoint, url), + qrShareable: isQrShareableEndpoint(endpoint), }); } return options; @@ -575,16 +593,25 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ : isLoopbackHostname(window.location.hostname) ? null : currentOriginPairingUrl); - const revealValue = shareablePairingUrl ?? pairingLink.credential; - const isShareableHostedAppPairingUrl = - shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl); + // Value of the copy attempt that last failed. The clipboard-failure reveal + // dialog must show exactly what failed to copy, not the row's default URL. + const [failedCopyValue, setFailedCopyValue] = useState(null); + const revealValue = failedCopyValue ?? shareablePairingUrl ?? pairingLink.credential; + const isRevealValueUrl = revealValue !== pairingLink.credential; + const isRevealValueHostedAppPairingUrl = isRevealValueUrl && isHostedAppPairingUrl(revealValue); + // Never render a QR for a loopback URL, even in the manual-copy fallback. + const isRevealValueQrShareable = + endpointCopyOptions.find((option) => option.url === revealValue)?.qrShareable ?? true; const canCopyToClipboard = typeof window !== "undefined" && window.isSecureContext && navigator.clipboard?.writeText != null; - const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({ - onCopy: (kind) => { + const { copyToClipboard } = useCopyToClipboard<{ + value: string; + kind: "code" | "hosted-link" | "link"; + }>({ + onCopy: ({ kind }) => { toastManager.add({ type: "success", title: @@ -601,7 +628,10 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ : "Paste it into another client to finish pairing.", }); }, - onError: (error, kind) => { + onError: (error, { value, kind }) => { + // Captured per attempt so concurrent copies cannot make the dialog + // reveal a different value than the one that failed. + setFailedCopyValue(value); setIsRevealDialogOpen(true); toastManager.add( stackedThreadToast({ @@ -621,7 +651,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ const copyPairingValue = useCallback( (value: string, kind: "code" | "hosted-link" | "link") => { - copyToClipboard(value, kind); + copyToClipboard(value, { value, kind }); }, [copyToClipboard], ); @@ -635,97 +665,19 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ copyPairingValue(pairingLink.credential, "code"); }, [copyPairingValue, pairingLink.credential]); - const handleCopyDefaultLink = useCallback(() => { - if (!shareablePairingUrl) return; - copyPairingValue(shareablePairingUrl, copyKindForUrl(shareablePairingUrl)); - }, [copyKindForUrl, copyPairingValue, shareablePairingUrl]); - const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); const primaryLabel = pairingLink.label ?? "Pairing link"; - const defaultEndpointCopyOption = - endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ?? - endpointCopyOptions[0] ?? - null; - const defaultEndpointCopyLabel = defaultEndpointCopyOption?.label ?? "URL"; - const backendEndpointCopyOptions = endpointCopyOptions.filter( - (option) => !isHostedAppPairingUrl(option.url), - ); - const hostedEndpointCopyOptions = endpointCopyOptions.filter((option) => - isHostedAppPairingUrl(option.url), - ); - const renderEndpointMenuItems = ( - options: typeof endpointCopyOptions = endpointCopyOptions, - renderDetail = true, - ) => - options.map((option) => ( - copyPairingValue(option.url, copyKindForUrl(option.url))} - > - - {option.label} - {renderDetail ? ( - - {option.detail} - - ) : null} - - - )); - const renderPairingCodeMenuItem = (renderDetail = true) => ( - - - Copy code - {renderDetail ? ( - Token only - ) : null} - - - ); - const renderCompactEndpointGroup = ( - label: string, - options: typeof endpointCopyOptions, - includeSeparator: boolean, - ) => - options.length > 0 ? ( - <> - {includeSeparator ? : null} - - {label} - {renderEndpointMenuItems(options, false)} - - - ) : null; - const renderGroupedCopyMenuItems = (options?: { codeFirst?: boolean }) => ( - <> - {options?.codeFirst ? ( - <> - - Pairing code - {renderPairingCodeMenuItem(false)} - - {endpointCopyOptions.length > 0 ? : null} - - ) : null} - {renderCompactEndpointGroup("Pairing URLs", backendEndpointCopyOptions, false)} - {renderCompactEndpointGroup( - "Hosted app link", - hostedEndpointCopyOptions, - backendEndpointCopyOptions.length > 0, - )} - {!options?.codeFirst ? ( - <> - {endpointCopyOptions.length > 0 ? : null} - - Pairing code - {renderPairingCodeMenuItem(false)} - - - ) : null} - - ); - + const selectedQrOption = selectQrEndpointOption( + endpointCopyOptions, + qrEndpointId, + defaultEndpointKey, + ); + const qrPairingUrl = selectedQrOption?.url ?? shareablePairingUrl; + // With no endpoint list the fallback is never loopback: selectPairingEndpoint + // skips loopback and the current-origin fallback is guarded by + // isLoopbackHostname, so only an explicit loopback selection hides the QR. + const canRenderQrForSelection = selectedQrOption?.qrShareable ?? true; if (expiresAtMs <= nowMs) { return null; } @@ -740,35 +692,6 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ dotClassName="bg-amber-400" />

    {primaryLabel}

    - - {shareablePairingUrl ? ( - <> - - } - > - - - - - - - ) : null} -

    {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} @@ -782,46 +705,31 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ ) : null}

  • - + {shareablePairingUrl && canCopyToClipboard ? ( + + ) : null} + { + setIsRevealDialogOpen(open); + if (!open) setFailedCopyValue(null); + }} + > {canCopyToClipboard ? ( - <> - {shareablePairingUrl ? ( - - - - - - } - > - - - - {renderGroupedCopyMenuItems()} - - - - ) : ( - - )} - + shareablePairingUrl ? null : ( + + ) ) : ( }> {shareablePairingUrl ? "Show link" : "Show code"} @@ -830,15 +738,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ - {shareablePairingUrl - ? isShareableHostedAppPairingUrl + {isRevealValueUrl + ? isRevealValueHostedAppPairingUrl ? "Hosted app pairing link" : "Pairing link" : "Pairing code"} - {shareablePairingUrl - ? isShareableHostedAppPairingUrl + {isRevealValueUrl + ? isRevealValueHostedAppPairingUrl ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect." : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." : "Clipboard copy is unavailable here. Manually copy this code into another client."} @@ -848,15 +756,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({