From 12ddd3109d09ae621d93dafd714d7f27eed64830 Mon Sep 17 00:00:00 2001 From: Daehyeon Mun Date: Sun, 18 Oct 2026 23:19:06 +0900 Subject: [PATCH 1/3] T3 Code clone: refresh settings pages and connection actions Group keybindings by area, report settings an older environment drops, and add MCP URL and mobile store actions. Keep onboarding usable with offline computers and align the scoped settings polish with T3 Code 9c161462c9. Co-Authored-By: Codex --- examples/t3-code/app.contract | 2 +- .../macos/tests/real-key-chords/main.swift | 2 +- .../settings-appearance-look.contract | 2 + .../appearance/settings-appearance-look.ts | 9 +- .../src/connections/connections.contract | 5 +- .../t3-code/src/connections/connections.ts | 24 +++- .../t3-code/src/details/git-publish.contract | 6 +- .../src/device/settings-a-hosts.contract | 4 +- .../t3-code/src/pages/pages-welcome.contract | 18 ++- examples/t3-code/src/pages/pages-welcome.ts | 32 +++-- examples/t3-code/src/panel/panel.contract | 6 +- .../src/providers/providers-wizard.contract | 9 +- .../t3-code/src/providers/providers.contract | 41 ++++-- .../src/settings/keybinding-settings.ts | 33 +++-- .../t3-code/src/settings/keybinding-view.ts | 29 +++- .../src/settings/scoped-settings-plan.ts | 76 +++++++--- .../t3-code/src/settings/settings-a-about.ts | 5 +- .../t3-code/src/settings/settings-catalog.ts | 4 + .../src/settings/settings-core.contract | 3 +- .../t3-code/src/settings/settings-core.ts | 3 +- .../settings/settings-keybindings.contract | 118 +++++++++------- .../settings/settings-mobile-beta.contract | 130 ++++-------------- .../src/settings/settings-mobile-beta.ts | 64 ++------- .../src/settings/settings-rows.contract | 6 +- .../src/settings/settings-scope-sources.ts | 4 +- .../t3-code/src/settings/settings-search.ts | 10 +- .../src/settings/settings-shortcuts.contract | 3 + .../src/settings/source-control-view.ts | 8 +- .../t3-code/src/shell/app-settings.contract | 21 ++- .../t3-code/src/shell/app-window.contract | 4 +- .../t3-code/src/shell/keyboard-dispatch.ts | 4 +- examples/t3-code/src/shell/shell-commands.ts | 1 + .../t3-code/src/snapshot/snapshot-shortcut.ts | 16 +-- .../t3-code/src/snapshot/snapshot.contract | 6 +- .../t3-code/src/usage/usage-pooled.contract | 8 +- .../tests/connections/connections.test.ts | 14 +- .../t3-code/tests/pages/pages-welcome.test.ts | 8 +- .../tests/pages/welcome-pairing.test.ts | 15 +- .../settings/keybinding-defaults.test.ts | 23 +++- .../settings/scoped-settings-plan.test.ts | 45 +++++- .../tests/settings/settings-core.test.ts | 4 + .../settings/settings-mobile-beta.test.ts | 53 +++---- .../settings/settings-pages-reads.test.ts | 5 +- .../tests/settings/settings-rest.test.ts | 3 + .../settings/settings-scope-sources.test.ts | 2 +- .../t3-code/tests/shared/dialog-trap.test.ts | 3 +- examples/t3-code/tests/shell/shell.test.ts | 2 + 47 files changed, 516 insertions(+), 377 deletions(-) diff --git a/examples/t3-code/app.contract b/examples/t3-code/app.contract index 1d5d3ab19e..cff612aa7b 100644 --- a/examples/t3-code/app.contract +++ b/examples/t3-code/app.contract @@ -1675,7 +1675,7 @@ component T3Code taskModelPick = "" if (op != "toggle" or not detailsPopover) and op != "dismiss" and op != "ui:project-settings" and op != "ui:add-script" and op != "ui:scheduled-tasks" and not commandPending commandPending = true - send changed = command(op == "toggle" ? "shelllocal:details-inline" : op == "ui:changes" ? "diff" : op == "ui:editor" ? "shell:open-editor" : op, target, value, 0) + send changed = command(op == "toggle" ? "shelllocal:details-inline" : op == "ui:changes" ? "diff" : op == "ui:editor" ? "shell:select-editor" : op, target, value, 0) if op == "select-thread" or op == "shell:merge-back" draftOwner = "__unset__" jumpRequest = jumpRequest + 1 diff --git a/examples/t3-code/macos/tests/real-key-chords/main.swift b/examples/t3-code/macos/tests/real-key-chords/main.swift index 1c239c4dec..739aefb41e 100644 --- a/examples/t3-code/macos/tests/real-key-chords/main.swift +++ b/examples/t3-code/macos/tests/real-key-chords/main.swift @@ -24,7 +24,7 @@ import XCTest /// The reference's ⌘ chords (packages/shared/src/keybindings.ts DEFAULT_KEYBINDINGS; keybindingDefaults) whose /// `when` holds in an open thread with nothing focused or the composer focused, the composer's own send chords /// (`composerFocus && …`) aside. search-key-and-timeline-commit-title.test.ts checks this list against them. -let referenceChords = ["mod+b", "mod+[", "mod+]", "mod+j", "mod+alt+b", "mod+w", "mod+d", "mod+shift+j", "mod+k", "mod+p", +let referenceChords = ["mod+b", "mod+[", "mod+]", "mod+j", "mod+alt+b", "mod+shift+b", "mod+w", "mod+d", "mod+shift+j", "mod+k", "mod+p", "mod+shift+f", "mod+u", "mod+alt+a", "mod+alt+shift+a", "mod+alt+shift+t", "mod+s", "mod+shift+enter", "mod+n", "mod+shift+o", "mod+shift+n", "mod+alt+n", "mod+shift+m", "mod+shift+h", "mod+shift+e", "mod+shift+a", "mod+shift+x", "mod+shift+g", "mod+shift+l", "mod+shift+k", "mod+o", "mod+shift+[", diff --git a/examples/t3-code/src/appearance/settings-appearance-look.contract b/examples/t3-code/src/appearance/settings-appearance-look.contract index 89cbc64d05..b91b74301e 100644 --- a/examples/t3-code/src/appearance/settings-appearance-look.contract +++ b/examples/t3-code/src/appearance/settings-appearance-look.contract @@ -19,6 +19,8 @@ shape Look accent: string accentText: string message: string + wizardSteps: string + wizardPanel: string chatMax: number artwork: bool pill: string diff --git a/examples/t3-code/src/appearance/settings-appearance-look.ts b/examples/t3-code/src/appearance/settings-appearance-look.ts index 536e9c1b3d..c591ca772a 100644 --- a/examples/t3-code/src/appearance/settings-appearance-look.ts +++ b/examples/t3-code/src/appearance/settings-appearance-look.ts @@ -8,7 +8,7 @@ // palette's light-dark() tokens. import type { T3Client } from '../client/client'; import { CLIENT_DEFAULTS, decodeClientPrefs, type ClientPrefs } from '../settings/settings-core'; -import { effectiveMode, fontStack, palette, themeRoles } from './settings-appearance'; +import { alpha, effectiveMode, fontStack, palette, themeRoles } from './settings-appearance'; import type { CustomTheme } from './settings-themes'; import type { DiffState } from '../diff/diff'; import { STANDARD, previewTheme } from './settings-appearance-editor'; @@ -18,6 +18,8 @@ export type Look = { themed: boolean; mode: string; canvas: string; sidebar: string; sidebarBorder: string; sidebarText: string; sidebarMuted: string; rowActive: string; surface: string; popover: string; border: string; input: string; text: string; muted: string; accent: string; accentText: string; message: string; + /** ui/wizard.tsx: WizardSteps' bg-background (dark:bg-white/4) and WizardPanel's bg-background/80 (dark:bg-white/2). */ + wizardSteps: string; wizardPanel: string; chatMax: number; artwork: boolean; pill: string; diff: string; diffAdd: string; diffDel: string; diffAddSurface: string; diffDelSurface: string; diffAddLine: string; diffDelLine: string; terminalLight: string; terminalDark: string; terminalFont: string; terminalSize: number; @@ -63,13 +65,16 @@ export function look(client: T3Client): Look { const diff = DIFF[prefs.diffColorScheme === 'blue-orange' ? 'blue-orange' : 'red-green']; // applyAppearanceFontVariables: the root font size (app.contract `rootFont` sets it), 16 until the preferences load. const rootFontSize = clampInterfaceFontSize(prefs.fontSizeInterface); + // Light mode paints the theme's background; dark mode a white wash whatever the theme. + const background = themed ? palette(prefs, custom, 'light').canvas : '#fcfcfc'; + const half = (light: string, dark: string) => mode === 'light' ? light : mode === 'dark' ? dark : `light-dark(${light}, ${dark})`; return { themed, mode, terminalLight: terminalTheme(prefs.themeLight, 'light', custom), terminalDark: terminalTheme(prefs.themeDark, 'dark', custom), terminalFont: prefs.typographyAdvanced ? prefs.fontFamilyTerminal : prefs.fontFamilyCode, terminalSize: prefs.typographyAdvanced ? prefs.fontSizeTerminal : prefs.fontSizeCode, canvas: pal.canvas, sidebar: pal.sidebar, sidebarBorder: pal.sidebarBorder, sidebarText: pal.sidebarText, sidebarMuted: pal.sidebarMuted, rowActive: pal.rowActive, - message: messageSurface(prefs, custom, mode), surface: pal.surface, popover: pal.popover, border: pal.border, input: pal.input, text: pal.text, muted: pal.muted, accent: pal.accent, accentText: pal.accentText, + message: messageSurface(prefs, custom, mode), wizardSteps: half(background, '#ffffff0a'), wizardPanel: half(alpha(background, 0.8), '#ffffff05'), surface: pal.surface, popover: pal.popover, border: pal.border, input: pal.input, text: pal.text, muted: pal.muted, accent: pal.accent, accentText: pal.accentText, diff: prefs.diffColorScheme === 'blue-orange' ? 'blue-orange' : 'red-green', chatMax: chatMaxWidth(prefs.chatWidth, rootFontSize), artwork: prefs.environmentIdentificationMode === 'artwork', pill: prefs.environmentIdentificationMode === 'pill' ? 'Nightly' : '', diffAdd: diff.add, diffDel: diff.del, diffAddSurface: diff.addSurface, diffDelSurface: diff.delSurface, diffAddLine: diff.addLine, diffDelLine: diff.delLine, fontSans: fontStack(prefs.fontFamilySans, false) ?? 'system-ui', fontSize: rootFontSize, promptSize: clampPromptFontSize(prefs.fontSizePrompt), promptFont: fontStack(prefs.fontFamilyComposer.trim() ? prefs.fontFamilyComposer : prefs.fontFamilySans, false), codeFont: fontStack(prefs.fontFamilyCode, true) ?? 'ui-monospace', diff --git a/examples/t3-code/src/connections/connections.contract b/examples/t3-code/src/connections/connections.contract index 34755078dd..e6e839b3b3 100644 --- a/examples/t3-code/src/connections/connections.contract +++ b/examples/t3-code/src/connections/connections.contract @@ -59,6 +59,7 @@ shape SavedEnvironment switchTip: string active: bool traceId: string + mcpUrl: string update: string updateVersion: string // ServerUpdateAction: a desktop-managed server without remote app updates shows this sentence, no button. @@ -312,7 +313,7 @@ component EnvironmentRow routesOpen = not routesOpen if closing and routeKeyboard.environmentId == environment.environmentId routeKeyboardInput(environment.environmentId, environment.key, routeKeyboard.routeId, "cancel", false) - derive menuItems = [KmItem(id=`environment-icon-${environment.environmentId}`, label="Icon"), KmItem(id=(busy ? "" : `environment-routes-item-${environment.environmentId}`), label=(routesOpen ? "Hide routes" : "Routes")), KmItem(id=(busy or environment.traceId == "" ? "" : `environment-trace-${environment.environmentId}`), label="Copy trace ID"), KmItem(id=(busy ? "" : (environment.active ? "forget-server" : `environment-remove-${environment.environmentId}`)), label="Remove from this device…")].filter((item) => item.id != "") + derive menuItems = [KmItem(id=`environment-icon-${environment.environmentId}`, label="Icon"), KmItem(id=(busy ? "" : `environment-routes-item-${environment.environmentId}`), label=(routesOpen ? "Hide routes" : "Routes")), KmItem(id=(busy or environment.mcpUrl == "" ? "" : `environment-mcp-${environment.environmentId}`), label="Copy MCP URL"), KmItem(id=(busy or environment.traceId == "" ? "" : `environment-trace-${environment.environmentId}`), label="Copy trace ID"), KmItem(id=(busy ? "" : (environment.active ? "forget-server" : `environment-remove-${environment.environmentId}`)), label="Remove from this device…")].filter((item) => item.id != "") action triggerKey(k: string, e: KeyboardEvent) if not e.metaKey and not e.ctrlKey and not e.altKey and not e.shiftKey and kmTriggerKey(k) preventDefault() @@ -369,6 +370,8 @@ component EnvironmentRow CnMenu(menuId=`environment-menu-${environment.environmentId}-popup`, invoker=`environment-menu-${environment.environmentId}`, label=`More actions for ${environment.label}`, width=196, area="bottom span-left", items=menuItems, keyed=0) EnvironmentIconMenu(keysId=`environment-menu-${environment.environmentId}-popup-keys`, environment=environment, op=op) RouteMenuItem(keysId=`environment-menu-${environment.environmentId}-popup-keys`, label=(routesOpen ? "Hide routes" : "Routes"), menuId=`environment-menu-${environment.environmentId}-popup`, itemId=`environment-routes-item-${environment.environmentId}`, disabled=busy, choose=toggleRoutes) + if environment.mcpUrl != "" + CnMenuItem(keysId=`environment-menu-${environment.environmentId}-popup-keys`, label="Copy MCP URL", menuId=`environment-menu-${environment.environmentId}-popup`, itemId=`environment-mcp-${environment.environmentId}`, destructive=false, disabled=busy, radio=false, highlighted=false, note="", icon="", submenu="", choose=op("environment-mcp", environment.key, environment.mcpUrl)) if environment.traceId != "" CnMenuItem(keysId=`environment-menu-${environment.environmentId}-popup-keys`, label="Copy trace ID", menuId=`environment-menu-${environment.environmentId}-popup`, itemId=`environment-trace-${environment.environmentId}`, destructive=false, disabled=busy, radio=false, highlighted=false, note="", icon="", submenu="", choose=op("environment-trace", environment.key, environment.traceId)) CnSeparator() diff --git a/examples/t3-code/src/connections/connections.ts b/examples/t3-code/src/connections/connections.ts index 059be0b7de..4df65acfcb 100644 --- a/examples/t3-code/src/connections/connections.ts +++ b/examples/t3-code/src/connections/connections.ts @@ -25,7 +25,7 @@ import { CLIENT_VERSION, versionMismatch } from './version-skew'; import { nativeUpdateDeps, updateEnvironment, updateTargetFromConfig } from './server-update'; import { confirms } from './server-update-notices'; import { configInstallation, desktopManagedOnly, manualUpdateCopy, serverUpdateActionLabel, DESKTOP_MANAGED_NOTE } from './server-installation'; -import { gitHubRoutingConnectionKey, savedRoutes, singleRouteKey, type ConnectionRoute } from './connection-routes'; +import { gitHubRoutingConnectionKey, routeHttpBaseUrl, savedRoutes, singleRouteKey, type ConnectionRoute } from './connection-routes'; import { moveSavedRoute, placeRoute, removeSavedRoute, routeCountLabel, routeRows, routesTransportLabel, savedEntry, savedList } from './connection-routes-ops'; import { letGo } from '../client/let-go'; @@ -155,6 +155,17 @@ const sourceTransport = (source: Source, ssh: Record) => /** The icon menu's row while there is no primary (the shape is fixed; the menu is not shown). */ const PLACEHOLDER: Source = { key: '', origin: '', environmentId: '', label: 'This machine', machine: 'desktop', enabled: false, phase: 'available', error: '', traceId: '', config: {}, scopes: [], focused: false, routes: [], activeRouteId: '', primary: true }; +/** T3 9c161462c9 presentation.ts environmentMcpUrl: prefer the connected route, then saved routes; SSH has no stable address. */ +export function environmentMcpUrl(source: Pick, ssh: Record = {}): string { + const routes = source.routes.length ? source.routes : [{ id: source.origin, origin: source.origin, kind: ssh[trimOrigin(source.origin)] ? 'ssh' : '' }]; + const connected = source.phase === 'connected' ? routes.find(route => route.id === source.activeRouteId) : undefined; + for (const route of connected ? [connected, ...routes] : routes) { + const base = routeHttpBaseUrl(route); + if (base === null) continue; + try { const url = new URL(base); url.pathname = '/mcp'; url.search = ''; url.hash = ''; return url.toString(); } catch { /* Try the next route. */ } + } + return ''; +} /** One Environments row's projection (and the This machine icon menu's). */ export type SavedRowView = ReturnType; export const placeholderRow = (): SavedRowView => savedRow(PLACEHOLDER, 0); @@ -182,7 +193,7 @@ export function savedRow(source: Source, index: number, ssh: Record 0), enabled=(index > 0 and not busy), press=go("connect")) WelcomeStep(number=2, label="Agents", current=(index == 1), done=(index > 1), enabled=(index > 1 and not busy), press=go("agents")) WelcomeStep(number=3, label="Projects", current=(index == 2), done=false, enabled=false, press=go("import")) @@ -166,6 +171,7 @@ component WelcomeWizard dialogEntered: action dialogFallback: action rem: number + wizardPanel: string props view: WelcomeView scheme: string @@ -201,7 +207,7 @@ component WelcomeWizard WelcomeIdentity(scheme=scheme) WelcomeSteps(step=step, busy=view.importing, go=go) scroll width="100%" max-height=(viewportHeight - 9.625 * rem) testId="welcome-scroll" - column width="100%" padding-left="1.5rem" padding-right="1.5rem" padding-top="1.25rem" padding-bottom="1.25rem" box-sizing="border-box" border-top-width=1 border-top-style="solid" border-color="light-dark(#0000000d, #ffffff0d)" background-color="light-dark(#fcfcfccc, #ffffff05)" + column width="100%" padding-left="1.5rem" padding-right="1.5rem" padding-top="1.25rem" padding-bottom="1.25rem" box-sizing="border-box" border-top-width=1 border-top-style="solid" border-color="light-dark(#0000000d, #ffffff0d)" background-color=wizardPanel if step == "connect" WelcomeConnect(view=view, busy=busy, error=error, link=link, pairOpen=pairOpen, helpOpen=helpOpen, local=local, editLink=editLink, pair=pair, togglePair=togglePair, toggleHelp=toggleHelp, copy=copy, setup=setup) if step == "agents" @@ -278,6 +284,8 @@ component WelcomeConnect text ". Add " font-size="0.75rem" line-height="1.21875rem" white-space="pre" color="light-dark(#71717b, #818181)" text "--tailscale" font-family="ui-monospace" font-size="0.75rem" line-height="1.21875rem" color="light-dark(#71717b, #818181)" text " to use your tailnet." font-size="0.75rem" line-height="1.21875rem" white-space="pre" color="light-dark(#71717b, #818181)" + if view.skipped != "" + text view.skipped font-size="0.75rem" line-height="1rem" color="light-dark(#71717b, #818181)" margin-top="1rem" role="status" testId="welcome-skipped" row width="100%" margin-top="1.5rem" justify-content="flex-end" align-items="center" gap="0.75rem" WelcomePrimary(label="Continue", icon="arrow-right", disabled=(not view.ready or busy), press=setup, elementId="welcome-continue", testId="welcome-continue") @@ -408,7 +416,7 @@ component WelcomeImport row width="100%" justify-content="flex-end" WelcomeGhost(terminalIcon=false, label="Do not import projects", small=false, disabled=false, press=finish, elementId="welcome-skip-import", testId="welcome-skip-import") else - WelcomeTitle(title="Choose your projects", description="Import projects and conversations from your selected computers.") + WelcomeTitle(title="Import your projects", description="Import projects and conversations from Claude Code and Codex on your selected computers.") if view.candidateCount > 0 row width="100%" margin-top="1.25rem" align-items="center" justify-content="space-between" gap="0.75rem" text view.selectionLabel font-size="0.75rem" line-height="1rem" color="light-dark(#71717b, #818181)" role="status" @@ -430,7 +438,9 @@ component WelcomeImport PgIcon(name="check", size=12, color="#ffffff") column flex=1 min-width=0 gap="0.125rem" text candidate.title font-size="0.875rem" line-height="1.25rem" font-weight=500 color="light-dark(#27272a, #f5f5f5)" text-align="left" line-clamp=1 - text candidate.path font-size="0.75rem" line-height="1rem" color="light-dark(#71717b, #818181)" text-align="left" line-clamp=1 + row min-width=0 width="100%" + text candidate.parent flex-shrink=1 min-width=0 font-size="0.75rem" line-height="1rem" color="light-dark(#71717b, #818181)" text-align="left" line-clamp=1 + text candidate.path flex-shrink=0 font-size="0.75rem" line-height="1rem" color="light-dark(#71717b, #818181)" text-align="left" white-space="nowrap" text candidate.detail flex-shrink=0 font-size="0.75rem" line-height="1rem" color="light-dark(#71717b, #818181)" row width="100%" margin-top="1.5rem" flex-wrap="wrap" justify-content="flex-end" align-items="center" gap="0.75rem" WelcomeGhost(terminalIcon=false, label="Do not import projects", small=false, disabled=(view.importing or busy), press=finish, elementId="welcome-skip-import", testId="welcome-skip-import") diff --git a/examples/t3-code/src/pages/pages-welcome.ts b/examples/t3-code/src/pages/pages-welcome.ts index 458a23bd80..593966982f 100644 --- a/examples/t3-code/src/pages/pages-welcome.ts +++ b/examples/t3-code/src/pages/pages-welcome.ts @@ -10,7 +10,7 @@ // Sources: T3 Code (MIT, see LICENSE-T3) // apps/web/src/components/onboarding/{FirstRunGate,WelcomeWizard}.tsx, // onboarding/{firstRun.logic,providerReadiness.logic,projectImport.logic}.ts, -// components/settings/{providerStatus,CodexSetupSection.logic}.ts. +// components/settings/{providerStatus,CodexSetupSection.logic}.ts (9c161462c9). import { arr, obj, str, num, applyShell, type Obj } from '../client/domain'; import { ClientError, bridgeReply, type Native } from '../client/protocol'; import { environmentSources } from '../connections/connections'; @@ -206,6 +206,15 @@ setChatGptCreatedHandler((host, instanceId, displayName) => { // ── Projects (projectImport.logic.ts) ────────────────────────────────────── +/** projectImport.logic.ts splitOnboardingProjectPath: parent truncates before the checkout name, on either platform. */ +export function splitOnboardingProjectPath(path: string): { name: string; parent: string } { + if (/^[A-Za-z]:[\\/]+$/.test(path) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+[\\/]*$/.test(path)) return { name: path, parent: '' }; + const trimmed = path.replace(/[\\/]+$/, ''); + const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')); + if (!trimmed) return { name: path, parent: '' }; + return separator < 0 ? { name: trimmed, parent: '' } : { name: trimmed.slice(separator + 1), parent: trimmed.slice(0, separator + 1) }; +} + const RECENT_WINDOW = 30 * 24 * 60 * 60 * 1000; /** partitionOnboardingProjects: every candidate is offered; recent busy repositories start selected. */ export function recentCandidates(candidates: Candidate[], now: number): Set { @@ -240,8 +249,8 @@ export async function welcomeView(client: T3Client, native: Native | null | unde const view = { show: false, step: input.step === 'agents' || input.step === 'import' ? input.step : 'connect', computers: [] as { key: string; environmentId: string; label: string; url: string; status: string; selected: boolean }[], - ready: false, machines: [] as { key: string; label: string; agents: AgentRow[]; chatgpt: boolean; terminal: ReturnType & { font: string; fontSize: number; light: string; dark: string } }[], - scanning: false, scanError: '', candidates: [] as { key: string; title: string; path: string; detail: string; selected: boolean }[], + ready: false, skipped: '', machines: [] as { key: string; label: string; agents: AgentRow[]; chatgpt: boolean; terminal: ReturnType & { font: string; fontSize: number; light: string; dark: string } }[], + scanning: false, scanError: '', candidates: [] as { key: string; title: string; path: string; parent: string; detail: string; selected: boolean }[], candidateCount: 0, selectedCount: 0, selectionLabel: '', truncated: false, importLabel: 'Import 0 projects', importing: false, emptyScan: false, commandCopied: state.copied, }; @@ -284,10 +293,13 @@ export async function welcomeView(client: T3Client, native: Native | null | unde } const selected = state.selection ?? new Set(sources.map(source => source.key)); view.computers = sources.map(source => ({ key: source.key, environmentId: source.environmentId, label: source.label, url: `${source.origin.replace(/\/+$/, '')}/`, - status: source.phase === 'connected' ? 'Connected' : 'Connecting…', selected: selected.has(source.key) })); - view.ready = view.computers.some(computer => computer.selected) && view.computers.filter(computer => computer.selected).every(computer => computer.status === 'Connected'); + status: !source.enabled ? 'Switched off' : source.phase === 'connected' ? 'Connected' : source.phase === 'connecting' ? 'Connecting…' : 'Offline', selected: selected.has(source.key) })); + const selectedSources = sources.filter(source => selected.has(source.key)); + view.ready = selectedSources.some(source => source.enabled && source.phase === 'connected') && !selectedSources.some(source => source.enabled && source.phase === 'connecting'); + const skipped = selectedSources.filter(source => !source.enabled || !['connected', 'connecting'].includes(source.phase)); + view.skipped = skipped.length ? `Not connected, so setup skips ${skipped.map(source => source.label).join(', ')}. You can set ${skipped.length === 1 ? 'it' : 'them'} up later from Settings.` : ''; if (view.step === 'connect') return view; - const setup = sources.filter(source => state.setup.includes(source.key)); + const setup = sources.filter(source => state.setup.includes(source.key) && source.enabled && source.phase === 'connected'); const mounted: { host: CodexHost; native: Native; ids: string[] }[] = []; view.machines = setup.map(source => { const agents = Object.keys(source.config).length ? agentRows(source.config) : []; @@ -327,7 +339,7 @@ export async function welcomeView(client: T3Client, native: Native | null | unde const scan = state.scan; const picked = state.picked ?? recentCandidates(scan.candidates, state.now); view.scanning = scan.loading; view.scanError = scan.error; view.truncated = scan.truncated; - view.candidates = scan.candidates.map(candidate => ({ key: candidate.key, title: candidate.title, path: candidate.path, selected: picked.has(candidate.key), + view.candidates = scan.candidates.map(candidate => ({ key: candidate.key, title: candidate.title, path: splitOnboardingProjectPath(candidate.path).name, parent: splitOnboardingProjectPath(candidate.path).parent, selected: picked.has(candidate.key), detail: [`${candidate.threadCount} ${candidate.threadCount === 1 ? 'thread' : 'threads'}`, relative(candidate.lastActiveAt, state.now), candidate.alreadyImported ? 'Imported' : ''].filter(Boolean).join(' · ') })); view.candidateCount = view.candidates.length; view.selectedCount = view.candidates.filter(candidate => candidate.selected).length; @@ -380,7 +392,11 @@ export async function welcomeLocal(client: T3Client, native: Native, op: string, return ''; } // startSetup: the chosen computers, in the order the list shows them. - if (op === 'setup') { state.setup = [...(state.selection ?? state.auto)]; state.scan = null; state.picked = null; return ''; } + if (op === 'setup') { + const selected = state.selection ?? state.auto; + state.setup = environmentSources(client, fleet.saved, fleet.entries).filter(source => selected.has(source.key) && source.enabled && source.phase === 'connected').map(source => source.key); + state.scan = null; state.picked = null; return ''; + } if (op === 'pick' || op === 'pick-all' || op === 'pick-none') { const candidates = state.scan?.candidates ?? []; const current = new Set(state.picked ?? recentCandidates(candidates, state.now)); diff --git a/examples/t3-code/src/panel/panel.contract b/examples/t3-code/src/panel/panel.contract index 095f2fe31a..ae7565d780 100644 --- a/examples/t3-code/src/panel/panel.contract +++ b/examples/t3-code/src/panel/panel.contract @@ -593,6 +593,8 @@ component DeviceSurfaceDialog dialogEntered: action dialogFallback: action rem: number + wizardPanel: string + wizardSteps: string props device: DeviceSurface viewportHeight: number @@ -607,11 +609,11 @@ component DeviceSurfaceDialog column padding-left="1.5rem" padding-right="1.5rem" padding-top="1.5rem" padding-bottom="0.75rem" box-sizing="border-box" gap="0.5rem" flex-shrink=0 text "Set up devices" font-size="1.25rem" font-weight=600 line-height="1.25rem" color="light-dark(#27272a, #f5f5f5)" role="heading" aria-level=2 text "Review what runs on this environment before using simulators and emulators." font-size="0.875rem" line-height="1.25rem" color="light-dark(#71717b, #818181)" - row height="2.75rem" padding="0.25rem" gap="0.25rem" box-sizing="border-box" border-radius="0.875rem" border-style="solid" border-width=1 border-color="light-dark(#0000000d, #ffffff0d)" background-color="light-dark(#fcfcfc, #ffffff0a)" role="list" aria-label="Setup progress" + row height="2.75rem" padding="0.25rem" gap="0.25rem" box-sizing="border-box" border-radius="0.875rem" border-style="solid" border-width=1 border-color="light-dark(#0000000d, #ffffff0d)" background-color=wizardSteps role="list" aria-label="Setup progress" DeviceWizardStep(number=0, title="Device hub", current=device.step, disabled=(device.busy or device.pending != ""), local=local) DeviceWizardStep(number=1, title="Simulators", current=device.step, disabled=(device.busy or device.pending != "" or device.step < 1), local=local) DeviceWizardStep(number=2, title="Agent access", current=device.step, disabled=(device.busy or device.pending != "" or device.step < 2), local=local) - column flex-shrink=1 min-height=0 padding-left="1.5rem" padding-right="1.5rem" padding-top="1.25rem" padding-bottom="1.25rem" box-sizing="border-box" gap="0.75rem" border-top-width=1 border-bottom-width=1 border-left-width=0 border-right-width=0 border-style="solid" border-color="light-dark(#0000000d, #ffffff0d)" background-color="light-dark(#fcfcfccc, #ffffff05)" testId="device-setup-panel" + column flex-shrink=1 min-height=0 padding-left="1.5rem" padding-right="1.5rem" padding-top="1.25rem" padding-bottom="1.25rem" box-sizing="border-box" gap="0.75rem" border-top-width=1 border-bottom-width=1 border-left-width=0 border-right-width=0 border-style="solid" border-color="light-dark(#0000000d, #ffffff0d)" background-color=wizardPanel testId="device-setup-panel" for notice in device.notices key=notice.id column padding-left="0.75rem" padding-right="0.75rem" padding-top="0.5rem" padding-bottom="0.5rem" border-radius="0.375rem" border-width=1 border-style="solid" border-color="light-dark(#e4e4e799, #ffffff0a)" role=(notice.failed ? "alert" : "status") text notice.label font-size="0.75rem" font-weight=500 line-height="1rem" color="light-dark(#27272a, #f5f5f5)" diff --git a/examples/t3-code/src/providers/providers-wizard.contract b/examples/t3-code/src/providers/providers-wizard.contract index 69a7ba913f..70a11d4c62 100644 --- a/examples/t3-code/src/providers/providers-wizard.contract +++ b/examples/t3-code/src/providers/providers-wizard.contract @@ -99,6 +99,8 @@ component AddProviderDialog dialogEntered: action dialogFallback: action rem: number + wizardPanel: string + wizardSteps: string props wizard: ProviderWizard acp: AcpRegistry @@ -172,11 +174,11 @@ component AddProviderDialog column width="100%" box-sizing="border-box" padding-left="1.5rem" padding-right="1.5rem" padding-top="1.5rem" padding-bottom="0.75rem" gap="0.5rem" flex-shrink=0 text "Add provider" font-size="1.25rem" line-height="1.25rem" font-weight=600 color="light-dark(#27272a, #f5f5f5)" role="heading" aria-level=2 text `Add an account or configure a provider on ${wizard.environment}.` font-size="0.875rem" line-height="1.25rem" color="light-dark(#71717b, #818181)" - row width="100%" height="2.75rem" box-sizing="border-box" padding="0.25rem" gap="0.25rem" border-radius="0.875rem" border-style="solid" border-width=1 border-color="light-dark(#0000000d, #ffffff0d)" background-color="light-dark(#fcfcfc, #ffffff0a)" role="list" aria-label="Setup progress" + row width="100%" height="2.75rem" box-sizing="border-box" padding="0.25rem" gap="0.25rem" border-radius="0.875rem" border-style="solid" border-width=1 border-color="light-dark(#0000000d, #ffffff0d)" background-color=wizardSteps role="list" aria-label="Setup progress" WizardStep(number=0, title="Provider", summary=(shownStep > 0 ? (isAcp ? wizard.label : wizard.driverLabel) : ""), current=shownStep, disabled=(wizard.created != ""), go=ui) WizardStep(number=1, title="Identity", summary=(shownStep > 1 ? wizard.preview : ""), current=shownStep, disabled=(wizard.created != ""), go=ui) WizardStep(number=2, title=(isAcp ? "Sign in" : "Config"), summary="", current=shownStep, disabled=(wizard.created != ""), go=ui) - scroll width="100%" flex=1 min-height=0 box-sizing="border-box" border-top-style="solid" border-top-width=1 border-bottom-style="solid" border-bottom-width=1 border-color="light-dark(#0000000d, #ffffff0d)" background-color="light-dark(#fcfcfccc, #ffffff05)" testId="add-provider-panel" + scroll width="100%" flex=1 min-height=0 box-sizing="border-box" border-top-style="solid" border-top-width=1 border-bottom-style="solid" border-bottom-width=1 border-color="light-dark(#0000000d, #ffffff0d)" background-color=wizardPanel testId="add-provider-panel" column width="100%" box-sizing="border-box" padding-left="1.5rem" padding-right="1.5rem" padding-top="1.25rem" padding-bottom="1.25rem" gap=0 if shownStep == 0 column width="100%" gap="0.5rem" @@ -436,6 +438,7 @@ component ChatGptAccountDialog inject dialogSeated: string[] dialogEntered: action + wizardPanel: string props wizard: ProviderWizard busy: bool @@ -464,7 +467,7 @@ component ChatGptAccountDialog column padding-left="1.5rem" padding-right="1.5rem" padding-top="1.5rem" padding-bottom="0.75rem" gap="0.5rem" text (wizard.chatgpt.length > 0 ? wizard.chatgpt.map((created) => created.title).join("") : "Add ChatGPT account") font-size="1.25rem" line-height="1.25rem" font-weight=600 color="light-dark(#27272a, #f5f5f5)" role="heading" aria-level=2 testId="chatgpt-account-title" text "Each account has its own Codex instance and sign-in. Choose the other account on the sign-in page." font-size="0.875rem" line-height="1.25rem" color="light-dark(#71717b, #818181)" - column width="100%" box-sizing="border-box" padding-left="1.5rem" padding-right="1.5rem" padding-top="1.25rem" padding-bottom="1.25rem" border-top-style="solid" border-top-width=1 border-bottom-style="solid" border-bottom-width=1 border-color="light-dark(#0000000d, #ffffff0d)" background-color="light-dark(#fcfcfccc, #ffffff05)" + column width="100%" box-sizing="border-box" padding-left="1.5rem" padding-right="1.5rem" padding-top="1.25rem" padding-bottom="1.25rem" border-top-style="solid" border-top-width=1 border-bottom-style="solid" border-bottom-width=1 border-color="light-dark(#0000000d, #ffffff0d)" background-color=wizardPanel if wizard.chatgpt.length > 0 for created in wizard.chatgpt key=created.key ChatGptAccountSetup(created=created, wide=true, busy=setupBusy, op=op, ui=ui) diff --git a/examples/t3-code/src/providers/providers.contract b/examples/t3-code/src/providers/providers.contract index 82b8a6b929..e639bc8173 100644 --- a/examples/t3-code/src/providers/providers.contract +++ b/examples/t3-code/src/providers/providers.contract @@ -10,6 +10,7 @@ use PvIconButton from "../settings/providers-kit.contract" use PvMicro from "../settings/providers-kit.contract" use PvIcon from "./provider-icons.contract" use CnTooltip from "../settings/settings-b-kit.contract" +use Tip from "../shell/shell-tip.contract" use ProviderAccentPicker from "./settings-b-accent.contract" use DriverMark from "./provider-icons-kit.contract" use ProviderSetupView from "./providers-setup.contract" @@ -175,14 +176,21 @@ component ProvidersPanel text page.message font-size="0.875rem" line-height="1.25rem" padding="2rem" color="light-dark(#71717b, #818181)" testId="providers-unavailable" else column width="100%" gap="0.625rem" - PvSectionTitle(title="Providers") - if page.updateAll.show - ProviderUpdatesAllButton(updateAll=page.updateAll, op=op) - button press=op("provider-refresh", "", "", "", true) class=PvGhost disabled=busy aria-label="Refresh provider status" testId="refresh-providers" - PvIcon(name="refresh", size=14, color="light-dark(#71717b, #818181)") - text (busy ? "Refreshing providers" : page.checked) font-size="0.75rem" line-height="1rem" font-weight=500 color="light-dark(#71717b, #818181)" - button press=ui("open", "add") class=PvIconButton aria-label="Add provider" disabled=(busy or not page.writable) testId="add-provider" id="add-provider" title="" - PvIcon(name="plus", size=14, color="light-dark(#71717b, #818181)") + // SettingsSection's title row: Add provider sits right after the title (titleAction); the updates and refresh + // controls stay at the end (headerAction). + row min-height="1.75rem" padding-left="1rem" padding-right="1rem" gap="1rem" align-items="flex-start" justify-content="space-between" box-sizing="border-box" width="100%" + row min-height="1.75rem" gap="0.25rem" align-items="center" min-width=0 + text "Providers" font-size="0.875rem" line-height="1.25rem" font-weight=400 color="light-dark(#27272ab3, #f5f5f5b3)" role="heading" aria-level=2 + if page.writable + Tip(label="Add provider", side="top", align="center", offset=28) + button press=ui("open", "add") class=PvIconButton aria-label="Add provider" disabled=busy testId="add-provider" id="add-provider" title="" + PvIcon(name="plus", size=14, color="light-dark(#71717b, #818181)") + row min-height="1.75rem" min-width="1.75rem" gap="0.5rem" align-items="center" justify-content="flex-end" + if page.updateAll.show + ProviderUpdatesAllButton(updateAll=page.updateAll, op=op) + button press=op("provider-refresh", "", "", "", true) class=PvGhost disabled=busy aria-label="Refresh provider status" testId="refresh-providers" + PvIcon(name="refresh", size=14, color="light-dark(#71717b, #818181)") + text (busy ? "Refreshing providers" : page.checked) font-size="0.75rem" line-height="1rem" font-weight=500 color="light-dark(#71717b, #818181)" if not page.writable PvGroup() PvRow(title="Limited permissions", description=page.message, status="", policy="", wide=(width - 34 >= 512), divided=false) @@ -215,6 +223,9 @@ component ProvidersPanel box height="1.25rem" display="flex" align-items="center" flex-shrink=0 button press=op("provider-enabled", row.id, "", (row.enabled ? "false" : "true"), true) role="switch" aria-checked=row.enabled aria-label=`Enable ${row.name}` disabled=(busy or not page.writable) testId=`provider-enable-${row.id}` width="1.875rem" height="1.125rem" padding="0.125rem" border-width=0 border-radius="624.9375rem" box-sizing="border-box" display="flex" flex-direction="row" align-items="center" justify-content=(row.enabled ? "flex-end" : "flex-start") flex-shrink=0 background-color=(row.enabled ? "light-dark(#1b4ed8, #346bf1)" : "light-dark(#d4d4d8, #ffffff14)") title="" box width="0.875rem" height="0.875rem" border-radius="624.9375rem" background-color="light-dark(#fcfcfc, #0a0a0a)" + // The list's last row opens the same wizard. + if page.writable + AddProviderRow(divided=(page.rows.length > 0), busy=busy, open=ui("open", "add")) scroll flex-grow=1 flex-shrink=1 flex-basis=(wide ? "0%" : "auto") min-width=0 width=(wide ? "auto" : "100%") height=(wide ? "100%" : "auto") testId="provider-editor-scroll" column width="100%" padding="1rem" gap="1.5rem" box-sizing="border-box" for editor in page.editors key=editor.key @@ -585,3 +596,17 @@ component ProviderModelLine box width="0.75rem" height="0.75rem" border-radius="624.9375rem" background-color="light-dark(#fcfcfc, #0a0a0a)" if tip == "switch" CnTooltip(tip=model.pickerTip, tipId=`provider-model-switch-tip-${model.slug}`, above=20) + +// ProviderSettingsPanel's list row "Add provider": muted until hovered (hover:bg-muted/25 hover:text-foreground). +component AddProviderRow + props + divided: bool + busy: bool + open: action + state over = false + action hover(value: bool) + over = value + view + button press=open hover=hover disabled=busy testId="add-provider-row" display="flex" flex-direction="row" align-items="center" justify-content="flex-start" gap="0.75rem" width="100%" box-sizing="border-box" padding-left="1rem" padding-right="1rem" padding-top="0.75rem" padding-bottom="0.75rem" border-width=0 border-top-style="solid" border-top-width=(divided ? 1 : 0) border-color="light-dark(#e3e3e780, #ffffff08)" background-color=(over ? "light-dark(#f4f4f540, #ffffff0a)" : "#00000000") title="" + PvIcon(name="plus", size=16, color=(over ? "light-dark(#27272a, #f5f5f5)" : "light-dark(#71717b, #818181)")) + text "Add provider" font-size="0.875rem" line-height="1.25rem" text-align="left" color=(over ? "light-dark(#27272a, #f5f5f5)" : "light-dark(#71717b, #818181)") diff --git a/examples/t3-code/src/settings/keybinding-settings.ts b/examples/t3-code/src/settings/keybinding-settings.ts index ed45a94a83..73502da527 100644 --- a/examples/t3-code/src/settings/keybinding-settings.ts +++ b/examples/t3-code/src/settings/keybinding-settings.ts @@ -1,7 +1,7 @@ import { arr, obj, str, type Obj } from '../client/domain'; import type { T3Client } from '../client/client'; import type { Native } from '../client/protocol'; -import { buildRows, commandOptions, commandLabel, conflictLabels, conflictText, parseWhen, printWhen, shortcutLabel, whenEditor, type KeybindingRowView } from './keybinding-view'; +import { buildRows, commandOptions, commandLabel, conflictLabels, conflictText, groupRows, parseWhen, printWhen, shortcutLabel, whenEditor, type KeybindingGroup, type KeybindingRowView } from './keybinding-view'; // Canonical shortcut/when serialization from KeybindingsSettings.logic.ts. export function shortcutInput(shortcut: Obj): string { return [...['mod', 'meta', 'ctrl', 'alt', 'shift'].filter(mod => shortcut[`${mod}Key`] === true), shortcut.key === ' ' ? 'space' : shortcut.key === 'escape' ? 'esc' : str(shortcut.key)].join('+'); @@ -83,26 +83,39 @@ export function keyboardSettings(config: Obj, composerFocus: boolean, extra: Par const value = (command: string, fallback: string) => context.modalOpen ? '' : !Array.isArray(config.keybindings) ? fallback : [...winners].filter(([, winner]) => winner === command).map(([shortcut]) => shortcut).join(' '); return { keySidebar: value('sidebar.toggle', 'Meta+B'), keyNewThread: value('chat.new', 'Meta+N'), keyDiff: value('diff.toggle', 'Meta+D'), keyModels: value('modelPicker.toggle', 'Meta+Shift+M') }; } +/** A default rule as the server resolves it (shortcut flags, the `when` as an AST): the page's rows with no environment. */ +function resolvedDefault(rule: { key: string; command: string; when?: string }): Obj { + const tokens = rule.key.split('+'); + const key = rule.key.endsWith('++') || rule.key === '+' ? '+' : tokens[tokens.length - 1]!; + const modifiers = rule.key.slice(0, rule.key.length - key.length).split('+'); + const shortcut: Obj = { key, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, modKey: false }; + for (const modifier of modifiers) if (['mod', 'meta', 'ctrl', 'shift', 'alt'].includes(modifier)) shortcut[`${modifier}Key`] = true; + return { command: rule.command, shortcut, ...(rule.when ? { whenAst: parseWhen(rule.when) } : {}) }; +} export async function keybindingSettings(client: T3Client, native: Native | null | undefined, environmentId: string, query: string, active: boolean, draftRow = '', draftKey = '', draftWhen = '', draftCommand = '') { - const empty = { available: false, writable: false, error: '', path: '', count: '', rows: [] as KeybindingRowView[], commands: [] as { value: string; label: string; selected: boolean }[], - draftConflict: '', draftValid: true, draftCommandLabel: '', draftKeyLabel: '', whenView: whenEditor(draftWhen) }; + const empty = { available: false, writable: false, notice: '', error: '', path: '', rows: [] as KeybindingRowView[], groups: [] as KeybindingGroup[], + commands: [] as { value: string; label: string; selected: boolean }[], draftConflict: '', draftValid: true, draftCommandLabel: '', draftKeyLabel: '', whenView: whenEditor(draftWhen) }; if (!active) return empty; try { - if (!native?.available || !client.ready || (environmentId !== "" && environmentId !== client.environmentId)) throw new Error('Choose a connected environment to manage keybindings.'); // KeybindingsSettings.tsx: the representative environment's serverConfig (server.getConfig at connect, then - // subscribeServerConfig's keybindingsUpdated), never a read per answer. - const config = client.config, bindings = arr(config.keybindings); + // subscribeServerConfig's keybindingsUpdated), never a read per answer. With no connected environment the page + // lists the defaults (mergeWithDefaultKeybindings of nothing) and changes nothing. + const connected = !!native?.available && client.connection === 'connected' && client.configLive; + if (connected && environmentId !== '' && environmentId !== client.environmentId) throw new Error('Choose a connected environment to manage keybindings.'); + const config = connected ? client.config : {}, bindings = connected ? arr(config.keybindings) : keybindingDefaults.map(resolvedDefault); + const writable = connected && client.writable; const listed = buildRows(bindings, ''), all = listed.map(row => ({ ...row, when: row.condition })), rows = query.trim() ? buildRows(bindings, query) : listed; const when = whenEditor(draftWhen); const draftKeyValue = draftKey.trim(), draftWhenValue = when.error ? '' : printWhen(parseWhen(draftWhen.trim()) || undefined); const conflict = draftRow || draftCommand ? conflictText(conflictLabels(all, { id: draftRow || 'new', key: draftKeyValue, when: draftWhenValue })) : ''; - const count = rows.length + (draftCommand !== '' || draftRow === 'new' ? 1 : 0); - return { ...empty, available: true, writable: client.writable, path: str(config.keybindingsConfigPath), count: `${count} ${count === 1 ? 'binding' : 'bindings'}`, rows, + return { ...empty, available: true, writable, path: str(config.keybindingsConfigPath), rows, groups: groupRows(rows), + notice: writable ? '' : !connected ? 'Connect an environment to change keybindings.' : 'This connection can view keybindings but cannot change them.', commands: commandOptions(bindings).map(option => ({ ...option, selected: option.value === draftCommand })), draftConflict: conflict, draftValid: !when.error, draftCommandLabel: draftCommand ? commandLabel(draftCommand) : 'new keybinding', draftKeyLabel: draftKeyValue ? shortcutLabel(draftKeyValue) : '', whenView: when }; } catch (error) { return { ...empty, error: error instanceof Error ? error.message : 'Could not load keybindings.' }; } } -// T3 f90b77d809 shared/keybindings DEFAULT_KEYBINDINGS and contracts STATIC_KEYBINDING_COMMANDS (+ composer.cycleHost, 1e2ecbd975). -export const keybindingDefaults: { key: string; command: string; when?: string }[] = [{"key":"mod+b","command":"sidebar.toggle"},{"key":"mod+[","command":"navigation.back","when":"!terminalFocus"},{"key":"mod+]","command":"navigation.forward","when":"!terminalFocus"},{"key":"mod+j","command":"terminal.toggle"},{"key":"mod+alt+b","command":"rightPanel.toggle"},{"key":"mod+d","command":"terminal.split","when":"terminalFocus"},{"key":"mod+shift+d","command":"terminal.splitVertical","when":"terminalFocus"},{"key":"mod+n","command":"terminal.new","when":"terminalFocus"},{"key":"mod+w","command":"terminal.close","when":"terminalFocus"},{"key":"mod+w","command":"rightPanel.close","when":"!terminalFocus"},{"key":"mod+d","command":"diff.toggle","when":"!terminalFocus"},{"key":"mod+shift+j","command":"preview.toggle"},{"key":"mod+r","command":"preview.refresh","when":"previewFocus"},{"key":"mod+l","command":"preview.focusUrl","when":"previewFocus"},{"key":"mod+=","command":"preview.zoomIn","when":"previewFocus"},{"key":"mod++","command":"preview.zoomIn","when":"previewFocus"},{"key":"mod+-","command":"preview.zoomOut","when":"previewFocus"},{"key":"mod+0","command":"preview.resetZoom","when":"previewFocus"},{"key":"mod+k","command":"commandPalette.toggle","when":"!terminalFocus"},{"key":"mod+p","command":"filePicker.toggle","when":"!terminalFocus"},{"key":"mod+shift+f","command":"projectSearch.toggle","when":"!terminalFocus"},{"key":"mod+u","command":"usage.open","when":"!terminalFocus"},{"key":"mod+alt+a","command":"theme.select","when":"!terminalFocus"},{"key":"mod+alt+shift+a","command":"appearance.cycle","when":"!terminalFocus"},{"key":"mod+alt+shift+t","command":"themeEditor.toggle"},{"key":"mod+s","command":"composer.stash","when":"!terminalFocus"},{"key":"mod+shift+enter","command":"thread.steerQueuedMessage","when":"!terminalFocus"},{"key":"alt+arrowup","command":"thread.editQueuedMessage","when":"composerFocus"},{"key":"mod+enter","command":"composer.sendAlternate","when":"composerFocus && turnRunning"},{"key":"mod+enter","command":"composer.sendBackground","when":"composerFocus && draftThreadRoute"},{"key":"mod+alt+enter","command":"composer.sendBackground","when":"composerFocus && draftThreadRoute"},{"key":"mod+alt+enter","command":"composer.sendAndNewThread","when":"composerFocus && !draftThreadRoute"},{"key":"mod+n","command":"chat.new","when":"!terminalFocus"},{"key":"mod+shift+o","command":"chat.new","when":"!terminalFocus"},{"key":"mod+shift+n","command":"chat.newLocal","when":"!terminalFocus"},{"key":"mod+alt+n","command":"chat.newWithoutProject","when":"!terminalFocus"},{"key":"mod+shift+m","command":"modelPicker.toggle","when":"!terminalFocus"},{"key":"mod+shift+h","command":"composer.host","when":"!terminalFocus"},{"key":"mod+shift+e","command":"composer.effort","when":"!terminalFocus"},{"key":"mod+shift+a","command":"composer.mode","when":"!terminalFocus"},{"key":"mod+shift+x","command":"composer.workspace","when":"!terminalFocus"},{"key":"mod+shift+g","command":"composer.branch","when":"!terminalFocus"},{"key":"mod+shift+l","command":"composer.previousWorktree","when":"!terminalFocus"},{"key":"mod+shift+k","command":"pullRequest.copyNumber","when":"!terminalFocus"},{"key":"mod+shift+arrowup","command":"modelPicker.previousProvider","when":"modelPickerOpen"},{"key":"mod+shift+arrowdown","command":"modelPicker.nextProvider","when":"modelPickerOpen"},{"key":"mod+o","command":"editor.openFavorite"},{"key":"mod+shift+[","command":"thread.previous"},{"key":"mod+shift+]","command":"thread.next"},{"key":"mod+shift+c","command":"thread.copyReference","when":"!terminalFocus"},{"key":"mod+shift+s","command":"thread.settle","when":"!terminalFocus"},{"key":"mod+shift+p","command":"thread.pin","when":"!terminalFocus"},{"key":"mod+z","command":"thread.undo","when":"!terminalFocus && !editableFocus"},{"key":"mod+1","command":"thread.jump.1","when":"isDesktop"},{"key":"mod+2","command":"thread.jump.2","when":"isDesktop"},{"key":"mod+3","command":"thread.jump.3","when":"isDesktop"},{"key":"mod+4","command":"thread.jump.4","when":"isDesktop"},{"key":"mod+5","command":"thread.jump.5","when":"isDesktop"},{"key":"mod+6","command":"thread.jump.6","when":"isDesktop"},{"key":"mod+7","command":"thread.jump.7","when":"isDesktop"},{"key":"mod+8","command":"thread.jump.8","when":"isDesktop"},{"key":"mod+9","command":"thread.jump.9","when":"isDesktop"},{"key":"mod+1","command":"modelPicker.jump.1","when":"modelPickerOpen && isDesktop"},{"key":"mod+2","command":"modelPicker.jump.2","when":"modelPickerOpen && isDesktop"},{"key":"mod+3","command":"modelPicker.jump.3","when":"modelPickerOpen && isDesktop"},{"key":"mod+4","command":"modelPicker.jump.4","when":"modelPickerOpen && isDesktop"},{"key":"mod+5","command":"modelPicker.jump.5","when":"modelPickerOpen && isDesktop"},{"key":"mod+6","command":"modelPicker.jump.6","when":"modelPickerOpen && isDesktop"},{"key":"mod+7","command":"modelPicker.jump.7","when":"modelPickerOpen && isDesktop"},{"key":"mod+8","command":"modelPicker.jump.8","when":"modelPickerOpen && isDesktop"},{"key":"mod+9","command":"modelPicker.jump.9","when":"modelPickerOpen && isDesktop"},{"key":"c","command":"usage.cost","when":"usagePageOpen"},{"key":"t","command":"usage.tokens","when":"usagePageOpen"},{"key":"l","command":"usage.limits","when":"usagePageOpen"},{"key":"mod+shift+1","command":"usage.period.day","when":"usagePageOpen"},{"key":"mod+shift+2","command":"usage.period.week","when":"usagePageOpen"},{"key":"mod+shift+3","command":"usage.period.month","when":"usagePageOpen"},{"key":"mod+shift+4","command":"usage.period.quarter","when":"usagePageOpen"}]; +// T3 9c161462c9 shared/keybindings DEFAULT_KEYBINDINGS and contracts STATIC_KEYBINDING_COMMANDS, without the rules and +// commands of features this client does not have yet (rightPanel.new, view.reopenClosed, chat.find, composer.*Effort). +export const keybindingDefaults: { key: string; command: string; when?: string }[] = [{"key":"mod+b","command":"sidebar.toggle"},{"key":"mod+[","command":"navigation.back","when":"!terminalFocus"},{"key":"mod+]","command":"navigation.forward","when":"!terminalFocus"},{"key":"mod+j","command":"terminal.toggle"},{"key":"mod+alt+b","command":"rightPanel.toggle"},{"key":"mod+shift+b","command":"threadPanel.toggle","when":"!terminalFocus"},{"key":"mod+d","command":"terminal.split","when":"terminalFocus"},{"key":"mod+shift+d","command":"terminal.splitVertical","when":"terminalFocus"},{"key":"mod+n","command":"terminal.new","when":"terminalFocus"},{"key":"mod+w","command":"terminal.close","when":"terminalFocus"},{"key":"mod+w","command":"rightPanel.close","when":"!terminalFocus"},{"key":"mod+d","command":"diff.toggle","when":"!terminalFocus"},{"key":"mod+shift+j","command":"preview.toggle"},{"key":"mod+r","command":"preview.refresh","when":"previewFocus"},{"key":"mod+l","command":"preview.focusUrl","when":"previewFocus"},{"key":"mod+=","command":"preview.zoomIn","when":"previewFocus"},{"key":"mod++","command":"preview.zoomIn","when":"previewFocus"},{"key":"mod+-","command":"preview.zoomOut","when":"previewFocus"},{"key":"mod+0","command":"preview.resetZoom","when":"previewFocus"},{"key":"mod+k","command":"commandPalette.toggle","when":"!terminalFocus"},{"key":"mod+p","command":"filePicker.toggle","when":"!terminalFocus"},{"key":"mod+shift+f","command":"projectSearch.toggle","when":"!terminalFocus"},{"key":"mod+u","command":"usage.open","when":"!terminalFocus"},{"key":"mod+alt+a","command":"theme.select","when":"!terminalFocus"},{"key":"mod+alt+shift+a","command":"appearance.cycle","when":"!terminalFocus"},{"key":"mod+alt+shift+t","command":"themeEditor.toggle"},{"key":"mod+s","command":"composer.stash","when":"!terminalFocus"},{"key":"mod+shift+enter","command":"thread.steerQueuedMessage","when":"!terminalFocus"},{"key":"alt+arrowup","command":"thread.editQueuedMessage","when":"composerFocus"},{"key":"mod+enter","command":"composer.sendAlternate","when":"composerFocus && turnRunning"},{"key":"mod+enter","command":"composer.sendBackground","when":"composerFocus && draftThreadRoute"},{"key":"mod+alt+enter","command":"composer.sendBackground","when":"composerFocus && draftThreadRoute"},{"key":"mod+alt+enter","command":"composer.sendAndNewThread","when":"composerFocus && !draftThreadRoute"},{"key":"mod+n","command":"chat.new","when":"!terminalFocus"},{"key":"mod+shift+o","command":"chat.new","when":"!terminalFocus"},{"key":"mod+shift+n","command":"chat.newLocal","when":"!terminalFocus"},{"key":"mod+alt+n","command":"chat.newWithoutProject","when":"!terminalFocus"},{"key":"mod+shift+m","command":"modelPicker.toggle","when":"!terminalFocus"},{"key":"mod+shift+h","command":"composer.host","when":"!terminalFocus"},{"key":"mod+shift+e","command":"composer.effort","when":"!terminalFocus"},{"key":"mod+shift+a","command":"composer.mode","when":"!terminalFocus"},{"key":"mod+shift+x","command":"composer.workspace","when":"!terminalFocus"},{"key":"mod+shift+g","command":"composer.branch","when":"!terminalFocus"},{"key":"mod+shift+l","command":"composer.previousWorktree","when":"!terminalFocus"},{"key":"mod+shift+k","command":"pullRequest.copyNumber","when":"!terminalFocus"},{"key":"mod+shift+arrowup","command":"modelPicker.previousProvider","when":"modelPickerOpen"},{"key":"mod+shift+arrowdown","command":"modelPicker.nextProvider","when":"modelPickerOpen"},{"key":"mod+o","command":"editor.openFavorite"},{"key":"mod+shift+[","command":"thread.previous"},{"key":"mod+shift+]","command":"thread.next"},{"key":"mod+shift+c","command":"thread.copyReference","when":"!terminalFocus"},{"key":"mod+shift+s","command":"thread.settle","when":"!terminalFocus"},{"key":"mod+shift+p","command":"thread.pin","when":"!terminalFocus"},{"key":"mod+z","command":"thread.undo","when":"!terminalFocus && !editableFocus"},{"key":"mod+1","command":"thread.jump.1","when":"!modelPickerOpen && isDesktop"},{"key":"mod+2","command":"thread.jump.2","when":"!modelPickerOpen && isDesktop"},{"key":"mod+3","command":"thread.jump.3","when":"!modelPickerOpen && isDesktop"},{"key":"mod+4","command":"thread.jump.4","when":"!modelPickerOpen && isDesktop"},{"key":"mod+5","command":"thread.jump.5","when":"!modelPickerOpen && isDesktop"},{"key":"mod+6","command":"thread.jump.6","when":"!modelPickerOpen && isDesktop"},{"key":"mod+7","command":"thread.jump.7","when":"!modelPickerOpen && isDesktop"},{"key":"mod+8","command":"thread.jump.8","when":"!modelPickerOpen && isDesktop"},{"key":"mod+9","command":"thread.jump.9","when":"!modelPickerOpen && isDesktop"},{"key":"mod+1","command":"modelPicker.jump.1","when":"modelPickerOpen && isDesktop"},{"key":"mod+2","command":"modelPicker.jump.2","when":"modelPickerOpen && isDesktop"},{"key":"mod+3","command":"modelPicker.jump.3","when":"modelPickerOpen && isDesktop"},{"key":"mod+4","command":"modelPicker.jump.4","when":"modelPickerOpen && isDesktop"},{"key":"mod+5","command":"modelPicker.jump.5","when":"modelPickerOpen && isDesktop"},{"key":"mod+6","command":"modelPicker.jump.6","when":"modelPickerOpen && isDesktop"},{"key":"mod+7","command":"modelPicker.jump.7","when":"modelPickerOpen && isDesktop"},{"key":"mod+8","command":"modelPicker.jump.8","when":"modelPickerOpen && isDesktop"},{"key":"mod+9","command":"modelPicker.jump.9","when":"modelPickerOpen && isDesktop"},{"key":"c","command":"usage.cost","when":"usagePageOpen"},{"key":"t","command":"usage.tokens","when":"usagePageOpen"},{"key":"l","command":"usage.limits","when":"usagePageOpen"},{"key":"mod+shift+1","command":"usage.period.day","when":"usagePageOpen"},{"key":"mod+shift+2","command":"usage.period.week","when":"usagePageOpen"},{"key":"mod+shift+3","command":"usage.period.month","when":"usagePageOpen"},{"key":"mod+shift+4","command":"usage.period.quarter","when":"usagePageOpen"}]; export const keybindingCommands = ["sidebar.toggle","navigation.back","navigation.forward","terminal.toggle","terminal.split","terminal.splitVertical","terminal.new","terminal.close","rightPanel.toggle","threadPanel.toggle","rightPanel.toggleMaximized","rightPanel.close","pullRequest.copyNumber","diff.toggle","preview.toggle","preview.refresh","preview.focusUrl","preview.zoomIn","preview.zoomOut","preview.resetZoom","commandPalette.toggle","filePicker.toggle","projectSearch.toggle","usage.open","theme.select","appearance.cycle","themeEditor.toggle","composer.stash","composer.sendAlternate","composer.sendBackground","composer.sendAndNewThread","composer.host","composer.cycleHost","composer.effort","composer.mode","composer.workspace","composer.previousWorktree","composer.branch","chat.new","chat.newLocal","chat.newWithoutProject","editor.openFavorite","usage.cost","usage.tokens","usage.limits","usage.period.day","usage.period.week","usage.period.month","usage.period.quarter","modelPicker.toggle","modelPicker.previousProvider","modelPicker.nextProvider","modelPicker.jump.1","modelPicker.jump.2","modelPicker.jump.3","modelPicker.jump.4","modelPicker.jump.5","modelPicker.jump.6","modelPicker.jump.7","modelPicker.jump.8","modelPicker.jump.9","thread.stop","thread.steerQueuedMessage","thread.editQueuedMessage","thread.previous","thread.next","thread.copyReference","thread.settle","thread.pin","thread.undo","thread.jump.1","thread.jump.2","thread.jump.3","thread.jump.4","thread.jump.5","thread.jump.6","thread.jump.7","thread.jump.8","thread.jump.9"]; diff --git a/examples/t3-code/src/settings/keybinding-view.ts b/examples/t3-code/src/settings/keybinding-view.ts index 0d62450d5d..c03b254ac2 100644 --- a/examples/t3-code/src/settings/keybinding-view.ts +++ b/examples/t3-code/src/settings/keybinding-view.ts @@ -1,5 +1,5 @@ -// Settings → Keybindings view model: rows, pills, labels, sources and conflicts -// from KeybindingsSettings.logic.ts, and the When builder of +// Settings → Keybindings view model: rows, pills, labels, sources, conflicts and +// area groups from KeybindingsSettings.logic.ts (T3 9c161462c9), and the When builder of // KeybindingsSettings.tsx as precomputed next expressions, so every builder // control only replaces the draft expression with a string computed here. import { arr, obj, str, type Obj } from '../client/domain'; @@ -21,6 +21,7 @@ export function commandLabel(command: string): string { if (command === 'thread.steerQueuedMessage') return 'Queue: Send First Queued Message as Steer'; if (command === 'thread.editQueuedMessage') return 'Queue: Edit Last Queued Message'; if (command === 'thread.copyReference') return 'Pull Request: Copy Link or Thread ID'; + if (command === 'preview.focusUrl') return 'Preview: Focus URL'; if (usageMetrics[command]) return `Usage: ${usageMetrics[command]}`; if (usagePeriods[command]) return `Usage: Period: ${usagePeriods[command]}`; if (command.startsWith('script.') && command.endsWith('.run')) return `Run Script: ${titleCase(command.slice(7, -4))}`; @@ -223,6 +224,30 @@ export function buildRows(bindings: Obj[], query: string): KeybindingRowView[] { const filtered = term ? rows.filter(row => [row.command, row.label, row.key, row.condition, row.source].some(text => text.toLowerCase().includes(term))) : rows; return filtered.map(({ when, ...row }, index) => ({ ...row, first: index === 0 })); } +export type KeybindingGroup = { id: string; title: string; rows: KeybindingRowView[] }; +const KEYBINDING_GROUPS: { id: string; title: string; prefixes: string[] }[] = [ + { id: 'navigation', title: 'Navigation', prefixes: ['sidebar', 'rightPanel', 'commandPalette', 'filePicker', 'projectSearch', 'editor'] }, + { id: 'threads', title: 'Threads', prefixes: ['thread', 'chat', 'pullRequest'] }, + { id: 'composer', title: 'Composer', prefixes: ['composer', 'modelPicker'] }, + { id: 'terminal', title: 'Terminal', prefixes: ['terminal'] }, + { id: 'preview', title: 'Preview & diff', prefixes: ['preview', 'diff'] }, + { id: 'appearance', title: 'Appearance', prefixes: ['theme', 'appearance', 'themeEditor'] }, + { id: 'scripts', title: 'Project scripts', prefixes: ['script'] }, +]; +const OTHER_KEYBINDING_GROUP = { id: 'other', title: 'Other', prefixes: [] as string[] }; +/** groupKeybindingRows: one titled section per command area, in area order; `first` marks each section's first row. */ +export function groupRows(rows: KeybindingRowView[]): KeybindingGroup[] { + const byGroup = new Map(); + for (const row of rows) { + const prefix = row.command.split('.')[0] ?? ''; + const group = KEYBINDING_GROUPS.find(candidate => candidate.prefixes.includes(prefix)) ?? OTHER_KEYBINDING_GROUP; + byGroup.set(group.id, [...(byGroup.get(group.id) ?? []), row]); + } + return [...KEYBINDING_GROUPS, OTHER_KEYBINDING_GROUP].flatMap(group => { + const groupRows = byGroup.get(group.id); + return groupRows ? [{ id: group.id, title: group.title, rows: groupRows.map((row, index) => ({ ...row, first: index === 0 })) }] : []; + }); +} /** buildKeybindingCommandOptions: the static catalog plus configured commands, by label. */ export function commandOptions(bindings: Obj[]): { value: string; label: string; selected: boolean }[] { const commands = new Set([...keybindingCommands, ...arr(bindings).map(binding => str(binding.command))]); diff --git a/examples/t3-code/src/settings/scoped-settings-plan.ts b/examples/t3-code/src/settings/scoped-settings-plan.ts index a156c3cc4b..6c248e8885 100644 --- a/examples/t3-code/src/settings/scoped-settings-plan.ts +++ b/examples/t3-code/src/settings/scoped-settings-plan.ts @@ -1,4 +1,4 @@ -// MIT T3 Code 1e2ecbd975 (LICENSE-T3): apps/web/src/components/settings/scopedSettings.ts, +// MIT T3 Code 9c161462c9 (LICENSE-T3): apps/web/src/components/settings/scopedSettings.ts, // with the parts of packages/shared/src/projectSettings.ts it calls (resolveProjectSettings, // clearProjectSettingsOverrides, resolveWorktreeCleanup) and the key lists of // packages/contracts/src/settings.ts. Changes: settings are plain JSON objects (the clone @@ -156,7 +156,9 @@ export function scopedSettingsSource(targets: readonly Pick 1 ? 'mixed' : sources.has('project') ? 'project' : sources.has('t3.json') ? 't3.json' : 'environment'; } -export interface ScopedServerWrite { readonly environmentId: string; readonly label: string; readonly patch: Obj } +export interface ScopedServerWrite { readonly environmentId: string; readonly label: string; readonly patch: Obj; + /** The environment's settings before the write, to tell a dropped key from a saved one. */ + readonly previous: Obj } export type ScopedSettingsPlan = { clientPatch: Obj; hasClientWrite: boolean; serverWrites: ScopedServerWrite[]; unavailableReason: string | null }; function projectOverrideWrites(scope: Extract, environments: readonly ScopedSettingsEnvironment[], @@ -169,7 +171,7 @@ function projectOverrideWrites(scope: Extract ({ - environmentId: environment.environmentId, label: environment.label, - patch: environment.serverConfig?.settings.worktreeCleanup != null && serverPatch.storageCleanup && Object.keys(storage).some(key => key.startsWith('worktree')) - ? { ...serverPatch, worktreeCleanup: { mode: 'custom', rules: { ...resolveWorktreeCleanup(environment.serverConfig.settings, null), - ...Object.fromEntries(Object.entries(storage).filter(([key]) => key.startsWith('worktree'))) } } } - : serverPatch })) + : scope.kind === 'all' || scope.kind === 'environment' ? connectedEnvironments.flatMap(environment => { + const previous = environment.serverConfig?.settings; + if (!previous) return []; + return [{ environmentId: environment.environmentId, label: environment.label, previous, + patch: previous.worktreeCleanup != null && serverPatch.storageCleanup && Object.keys(storage).some(key => key.startsWith('worktree')) + ? { ...serverPatch, worktreeCleanup: { mode: 'custom', rules: { ...resolveWorktreeCleanup(previous, null), + ...Object.fromEntries(Object.entries(storage).filter(([key]) => key.startsWith('worktree'))) } } } + : serverPatch }]; + }) : []; const hasClientWrite = Object.keys(clientPatch).length > 0; const hasWrite = hasClientWrite || serverWrites.length > 0; @@ -252,28 +257,67 @@ export function planProjectOverridesClear(environments: readonly ScopedSettingsE const environment = byId.get(environmentId); if (!environment?.serverConfig || environment.connection.phase !== 'connected') continue; const existing = writes.get(environmentId); - writes.set(environmentId, { environmentId, label: environment.label, + writes.set(environmentId, { environmentId, label: environment.label, previous: environment.serverConfig.settings, patch: { projectSettingsOverrides: { ...obj(existing?.patch.projectSettingsOverrides), [projectId]: clearProjectSettingsOverrides(environment.serverConfig.settings, projectId, keys) } } }); } const serverWrites = [...writes.values()]; return { clientPatch: {}, hasClientWrite: false, serverWrites, unavailableReason: serverWrites.length > 0 ? null : 'Connect the environments to reset these overrides.' }; } +/** + * Whether a server kept `before` where `value` asked for a change. Objects are + * compared field by field, so a nested key an older server dropped is caught + * even when a sibling field saved. A null clears the field; strings may come + * back trimmed. + */ +function writeWasDropped(value: unknown, before: unknown, saved: unknown): boolean { + if (value === null) return before != null && settingsEqual(saved, before); + if (isPlainObject(value) && !Array.isArray(before) && !Array.isArray(saved)) { + const field = (source: unknown, key: string) => isPlainObject(source) ? source[key] : undefined; + return Object.entries(value).some(([key, next]) => writeWasDropped(next, field(before, key), field(saved, key))); + } + const unchanged = typeof value === 'string' && typeof before === 'string' ? value.trim() === before : settingsEqual(value, before); + return !unchanged && settingsEqual(saved, before); +} + +/** An older server drops keys it does not know, top-level or nested, and still succeeds. Left unreported, the row stays mixed forever. */ +function droppedSettingKeys(write: ScopedServerWrite, saved: Obj | undefined): string[] { + if (saved === undefined) return []; + return Object.entries(write.patch).flatMap(([key, value]) => writeWasDropped(value, write.previous[key], saved[key]) ? [key] : []); +} + +export type FailedEnvironment = ScopedServerWrite & { readonly message: string }; +export type ScopedPersistResult = { failedEnvironments: FailedEnvironment[]; savedEnvironments: ScopedServerWrite[]; savedEnvironmentCount: number }; + /** Wait for every target so a failed environment does not hide successful or later writes. */ export async function persistScopedSettingsPatch(plan: ScopedSettingsPlan, - persistServer: (input: { environmentId: string; input: { patch: Obj } }) => Promise<{ readonly _tag: 'Success' | 'Failure' }>, persistClient: (patch: Obj) => void) { + persistServer: (input: { environmentId: string; input: { patch: Obj } }) => Promise<{ readonly _tag: 'Success' | 'Failure'; readonly error?: unknown; readonly value?: Obj }>, + persistClient: (patch: Obj) => void): Promise { if (plan.hasClientWrite) persistClient(plan.clientPatch); const results = await Promise.allSettled(plan.serverWrites.map(({ environmentId, patch }) => persistServer({ environmentId, input: { patch } }))); - const failedEnvironments = plan.serverWrites.filter((_, index) => { const result = results[index]; return result?.status !== 'fulfilled' || result.value._tag === 'Failure'; }); - return { failedEnvironments, savedEnvironmentCount: plan.serverWrites.length - failedEnvironments.length }; + const saved = (index: number) => { + const result = results[index]; + return result?.status === 'fulfilled' && result.value._tag === 'Success' && droppedSettingKeys(plan.serverWrites[index]!, result.value.value).length === 0; + }; + const failedEnvironments = plan.serverWrites.flatMap((environment, index): FailedEnvironment[] => { + if (saved(index)) return []; + const result = results[index]; + if (result?.status === 'fulfilled' && result.value._tag === 'Success') return [{ ...environment, message: 'This environment does not support this setting. Update T3 Code on it.' }]; + const error: unknown = result?.status === 'rejected' ? result.reason : result?.status === 'fulfilled' ? result.value.error : undefined; + return [{ ...environment, message: error instanceof Error ? error.message : 'The save failed. Try reconnecting and saving again.' }]; + }); + const savedEnvironments = plan.serverWrites.filter((_, index) => saved(index)); + return { failedEnvironments, savedEnvironments, savedEnvironmentCount: savedEnvironments.length }; } -/** useRunScopedPlan's toasts: the warning for a refused plan, the error naming environments that did not save. */ -export function scopedPlanNotice(plan: Pick, result?: { failedEnvironments: readonly { label: string }[]; savedEnvironmentCount: number }): +/** useRunScopedPlan's toasts: the warning for a refused plan, the error naming each environment that did not save and why. */ +export function scopedPlanNotice(plan: Pick, result?: Pick & { + failedEnvironments: readonly { label: string; message: string }[]; savedEnvironments: readonly { label: string }[] }): { kind: 'warning' | 'error'; title: string; description: string } | null { if (plan.unavailableReason) return { kind: 'warning', title: 'Setting not saved', description: plan.unavailableReason }; if (!result || result.failedEnvironments.length === 0) return null; const saved = result.savedEnvironmentCount > 0; return { kind: 'error', title: saved ? 'Setting saved on some environments' : 'Setting not saved', - description: `Could not update ${result.failedEnvironments.map(environment => environment.label).join(', ')}.${saved ? ' The other selected environments saved the change.' : ''}` }; + description: [...result.failedEnvironments.map(({ label, message }) => `Could not save on ${label}: ${message}`), + ...(saved ? [`Saved on ${result.savedEnvironments.map(({ label }) => label).join(', ')}.`] : [])].join('\n') }; } diff --git a/examples/t3-code/src/settings/settings-a-about.ts b/examples/t3-code/src/settings/settings-a-about.ts index 5f50c777d8..fa8d03c5b5 100644 --- a/examples/t3-code/src/settings/settings-a-about.ts +++ b/examples/t3-code/src/settings/settings-a-about.ts @@ -12,7 +12,7 @@ import type { T3Client } from '../client/client'; import { coreRow, type CoreRow } from './settings-core'; import { ClientError, type Native } from '../client/protocol'; -import { mobileBetaRow, showMobileBeta } from './settings-mobile-beta'; +import { mobileAppRow } from './settings-mobile-beta'; import { CLIENT_VERSION } from '../connections/connections'; import { defaultUpdateChannel, writeDesktopSettings, type DesktopUpdateChannel } from '../connections/local-backend'; import { letGo } from '../client/let-go'; @@ -52,8 +52,7 @@ export function aboutRows(client: T3Client, version: string): CoreRow[] { coreRow('update-track', 'Update track', 'Use stable releases or nightly builds. Switch back anytime.', 'select', { value: channel, label: channel === 'nightly' ? 'Nightly' : 'Stable', options, width: 160, disabled: enabled, menuWidth: 0, }), - // f1dcd93931: Mobile app after the version/update rows on a Nightly build or the Nightly track. - ...(showMobileBeta(CLIENT_VERSION, channel) ? [mobileBetaRow(client)] : []), + mobileAppRow(), ]; } diff --git a/examples/t3-code/src/settings/settings-catalog.ts b/examples/t3-code/src/settings/settings-catalog.ts index e114a9f352..287e2caa30 100644 --- a/examples/t3-code/src/settings/settings-catalog.ts +++ b/examples/t3-code/src/settings/settings-catalog.ts @@ -56,6 +56,10 @@ export const CATALOG: readonly (readonly [string, string, string, string, string ["delete-confirmation", "Delete confirmation", "general", "", "ask before thread chat history", "", ""], ["quit-confirmation", "Quit shortcut", "general", "", "confirmation desktop app exit direct hold double click press twice", "", "desktop"], ["text-generation-model", "Text generation model", "general", "", "generated thread titles source control content default provider", "project-defaults", ""], + // The About rows (the Version row is `version` here). + ["app-version", "Version", "general", "version", "about check for updates download install upgrade release", "", ""], + ["update-track", "Update track", "general", "", "release channel stable latest nightly prerelease", "", ""], + ["mobile-app", "Mobile app", "general", "", "phone tablet iphone ipad android app store google play qr code", "", ""], ["diagnostics", "Diagnostics", "general", "", "logs traces processes resource history failures spans cpu memory", "", ""], ["open-source-licenses", "Open source licenses", "general", "", "", "", ""], ["legacy-plan-mode", "Plan mode (legacy)", "general", "", "build plan composer old", "", ""], diff --git a/examples/t3-code/src/settings/settings-core.contract b/examples/t3-code/src/settings/settings-core.contract index 788c786eb1..b78f4ff56f 100644 --- a/examples/t3-code/src/settings/settings-core.contract +++ b/examples/t3-code/src/settings/settings-core.contract @@ -74,7 +74,6 @@ shape QrMark url: string label: string caption: string - copied: number cells: QrCell[] shape CoreSection id: string @@ -393,6 +392,8 @@ component SettingsNav box hover=updateTip("settings-update") margin-left="auto" flex-shrink=0 display="flex" testId="tip-settings-update" SidebarUpdatePill(color=pal.sidebarIcon, testId="settings-check-updates") button press=onKey("/") aria-keyshortcuts="/" aria-label="Focus settings search" position="absolute" width=1 height=1 opacity=0 left=0 top=0 tabindex=-1 testId="settings-search-shortcut" title="" + // ⌘F focuses it too, from a text field as well (SettingsSidebarNav's isFindShortcut). + button press=onKey("/") aria-keyshortcuts="Meta+F" aria-label="Find in settings" position="absolute" width=1 height=1 opacity=0 left=0 top=0 tabindex=-1 testId="settings-find-shortcut" title="" // Settings' main sidebar trigger (AppSidebarLayout's SidebarControl over the settings nav, Toggle main sidebar): one // node at the window controls whether the nav is open or closed, so a VoiceOver press on it is answered by the element diff --git a/examples/t3-code/src/settings/settings-core.ts b/examples/t3-code/src/settings/settings-core.ts index 30c69bdfe0..367d4b9d54 100644 --- a/examples/t3-code/src/settings/settings-core.ts +++ b/examples/t3-code/src/settings/settings-core.ts @@ -21,7 +21,7 @@ import { aboutRows, updateCommand, updateTrackCommand } from './settings-a-about import { removeThemes, useCollectionDefaults } from '../appearance/settings-a-collections'; import type { DiffState } from '../diff/diff'; import { notificationPermission, notificationPermissionMessage } from '../shell/shell-notify'; -import { MOBILE_BETA_ROW, mobileBetaCommand, type QrMark } from './settings-mobile-beta'; +import { type QrMark } from './settings-mobile-beta'; import { CLIENT_VERSION } from '../connections/connections'; import { ALL_ENVIRONMENTS_VALUE, environmentAxisValue, resolveSettingsScope, scopeSearch, settingsScopeEnvironmentLabel, type ResolvedSettingsScope } from './settings-scope'; import { persistScopedSettingsPatch, planScopedSettingsClear, planScopedSettingsPatch, resolveScopedSettingsTargets, scopedPlanNotice, scopedSettingsSource, @@ -678,7 +678,6 @@ export async function applyCoreSetting(client: T3Client, native: Native, id: str } if (target.row === 'version' || target.row === 'update') return updateCommand(client, target.row, target.part); if (target.row === 'update-track') return updateTrackCommand(client, native, value); - if (target.row === MOBILE_BETA_ROW) return mobileBetaCommand(client, native, target.part, value); if (target.row !== 'restore-device-defaults' && !key) { // Device rows: saved in this app's preferences, never sent to a server. if (target.part === 'reset') { diff --git a/examples/t3-code/src/settings/settings-keybindings.contract b/examples/t3-code/src/settings/settings-keybindings.contract index 1921e60d2f..25d08aedd2 100644 --- a/examples/t3-code/src/settings/settings-keybindings.contract +++ b/examples/t3-code/src/settings/settings-keybindings.contract @@ -1,10 +1,10 @@ -// Settings → Keybindings (reference KeybindingsSettings.tsx): one settings row -// per binding with its When clause and shortcut pills; the shortcut becomes a -// capture field, a dirty row offers Save, a hover menu offers Reset/Remove, and -// a new-binding row sits on top. The When builder is a popover whose controls -// replace the draft with expressions keybinding-view.ts precomputed. +// Settings → Keybindings (reference KeybindingsSettings.tsx, T3 9c161462c9): a toolbar (search, Add keybinding, +// keybindings.json), then one titled section per command area with a settings row per binding: its When clause and +// shortcut pills; the shortcut becomes a capture field, a dirty row offers Save, a hover menu offers Reset/Remove, and +// a new-binding row sits above the sections. Without a connection that can write settings, a line says why and the +// list is inert. The When builder is a popover whose controls replace the draft with expressions keybinding-view.ts +// precomputed. use SelectOption from "./settings-kit.contract" -use DialogOpenFocus from "../shared/dialog-trap.contract" use FieldPlaceholder from "../shared/field-placeholder.contract" use SettingsNotice from "./settings-kit.contract" use SettingsSelect from "./settings-kit.contract" @@ -66,13 +66,19 @@ shape WhenEditorView lines: WhenLine[] addRootCondition: string addRootGroup: string +shape KeybindingGroup + id: string + title: string + rows: KeybindingRowView[] shape KeybindingPage available: bool writable: bool + // KeybindingsSettingsPanel's read-only line ("" while the connection can write settings). + notice: string error: string path: string - count: string rows: KeybindingRowView[] + groups: KeybindingGroup[] commands: SelectOption[] draftConflict: string draftValid: bool @@ -90,6 +96,14 @@ style KbCompact display="flex" flex-direction="row" align-items="center" justify-content="center" gap="0.25rem" box-sizing="border-box" height="1.75rem" padding-left="0.4375rem" padding-right="0.4375rem" border-radius="0.375rem" border-style="solid" border-width=1 border-color="light-dark(#d4d4d8, #ffffff14)" background-color="light-dark(#ffffff, #ffffff07)" flex-shrink=0 style KbCapture box-sizing="border-box" width="11rem" height="1.75rem" padding-left="0.5625rem" padding-right="0.5625rem" border-radius="0.5rem" border-style="solid" border-width=1 border-color="light-dark(#d4d4d8, #ffffff14)" background-color="light-dark(#fcfcfc, #ffffff07)" font-family="monospace" font-size="0.8125rem" color="light-dark(#27272a, #f5f5f5)" +// Button variant="outline" at its default size (h-8 on a wide window), the toolbar's. +style KbToolbar + cursor="pointer" + display="flex" flex-direction="row" align-items="center" justify-content="center" gap="0.5rem" box-sizing="border-box" height="2rem" padding-left="0.6875rem" padding-right="0.6875rem" border-radius="0.5rem" border-style="solid" border-width=1 border-color="light-dark(#d4d4d8, #ffffff14)" background-color="light-dark(#ffffff, #ffffff07)" box-shadow="0 1px 2px #0000000d" flex-shrink=0 +style KbCard + display="flex" flex-direction="column" width="100%" box-sizing="border-box" border-radius="0.875rem" border-style="solid" border-width=1 border-color="light-dark(#e4e4e899, #ffffff09)" background-color="light-dark(#ffffff66, #11111166)" box-shadow="0 1px 2px #0000000d" +style KbSectionTitle + font-size="0.875rem" font-weight=400 line-height="1.25rem" color="light-dark(#262629b3, #f5f5f5b3)" style KbPrimary cursor="pointer" display="flex" flex-direction="row" align-items="center" justify-content="center" box-sizing="border-box" height="1.75rem" padding-left="0.5625rem" padding-right="0.5625rem" border-radius="0.5rem" border-width=0 background-color="light-dark(#1b4ed8, #346bf1)" flex-shrink=0 @@ -98,7 +112,6 @@ component KeybindingsPanel props data: KeybindingPage query: string - searchOpen: bool adding: bool draftRow: string draftCommand: string @@ -108,9 +121,9 @@ component KeybindingsPanel busy: bool error: string search: action - searchToggle: action searchKey: action searchFocus: action // the search field has the focus, so its Escape is its own + searchBlur: action add: action cancelAdd: action chooseCommand: action @@ -124,48 +137,58 @@ component KeybindingsPanel rest: action openFile: action view - column width="100%" gap="0.625rem" testId="keybindings-settings" - row min-height="1.75rem" padding-left="1rem" padding-right="1rem" align-items="center" justify-content="space-between" gap="1rem" box-sizing="border-box" - text "Keybindings" font-size="0.875rem" font-weight=400 line-height="1.25rem" color="light-dark(#262629b3, #f5f5f5b3)" role="heading" aria-level=2 - row align-items="center" gap="0.375rem" min-height="1.75rem" - if not searchOpen - text data.count font-size="0.6875rem" line-height="1rem" color="light-dark(#71717b, #818181)" testId="keybindings-count" - button press=searchToggle(true) class=KbIcon aria-label="Search keybindings" testId="open-keybindings-search" title="" + column width="100%" gap="2rem" testId="keybindings-settings" + // The page toolbar: the "Keybindings" section's card holds the filter, Add keybinding and keybindings.json. + column width="100%" gap="0.625rem" + row min-height="1.75rem" padding-left="1rem" padding-right="1rem" align-items="center" box-sizing="border-box" + text "Keybindings" class=KbSectionTitle role="heading" aria-level=2 + column class=KbCard + row flex-wrap="wrap" align-items="center" gap="0.5rem" box-sizing="border-box" padding-top="0.75rem" padding-bottom="0.75rem" padding-left="1rem" padding-right="1rem" + row flex=1 min-width=0 height="2rem" align-items="center" gap="0.5rem" box-sizing="border-box" padding-left="0.6875rem" padding-right="0.6875rem" border-radius="0.5rem" border-style="solid" border-width=1 border-color="light-dark(#d4d4d8, #ffffff14)" background-color="light-dark(#fcfcfc, #ffffff07)" LucideSearch(size=14, color="light-dark(#71717b, #818181)") - if searchOpen - row width="11rem" height="1.75rem" align-items="center" gap="0.375rem" box-sizing="border-box" padding-left="0.5625rem" padding-right="0.5625rem" border-radius="0.5rem" border-style="solid" border-width=1 border-color="light-dark(#d4d4d8, #ffffff14)" background-color="light-dark(#fcfcfc, #ffffff07)" - LucideSearch(size=12, color="light-dark(#71717b, #818181)") - DialogOpenFocus(target="keybindings-filter") - input value=query input=search key=searchKey focus=searchFocus blur=searchToggle(false) autofocus=true placeholder="Search keybindings" class=FieldPlaceholder aria-label="Search keybindings" id="keybindings-filter" testId="keybindings-filter" flex=1 min-width=0 height="1.5rem" padding=0 position="relative" top="0.21875rem" line-height="1.5rem" border-width=0 background-color="#00000000" font-size="0.8125rem" color="light-dark(#27272a, #f5f5f5)" autocorrect="off" - button press=add disabled=(busy or not data.writable) class=KbIcon aria-label="Add keybinding" id="add-keybinding" testId="add-keybinding" opacity=(busy or not data.writable ? 0.64 : 1) title="" - LucidePlus(size=14, color="light-dark(#71717b, #818181)") - button press=openFile disabled=(busy or data.path == "") class=KbIcon aria-label="Open keybindings.json" testId="open-keybindings-file" opacity=(data.path == "" ? 0.64 : 1) title="" - LucideFileBraces(size=14, color="light-dark(#71717b, #818181)") + input value=query input=search key=searchKey focus=searchFocus blur=searchBlur placeholder="Search keybindings" class=FieldPlaceholder aria-label="Search keybindings" id="keybindings-filter" testId="keybindings-filter" flex=1 min-width=0 height="1.75rem" padding=0 border-width=0 background-color="#00000000" font-size="0.875rem" color="light-dark(#27272a, #f5f5f5)" autocorrect="off" + button press=add disabled=(busy or adding or not data.writable) class=KbToolbar id="add-keybinding" testId="add-keybinding" opacity=(busy or adding or not data.writable ? 0.64 : 1) + LucidePlus(size=16, color="light-dark(#27272a, #f5f5f5)") + text "Add keybinding" font-size="0.875rem" font-weight=500 line-height="1.25rem" color="light-dark(#27272a, #f5f5f5)" + Tip(label="Open keybindings.json", side="top", align="center", offset=36) + button press=openFile disabled=(busy or data.path == "") class=KbToolbar width="2rem" padding=0 aria-label="Open keybindings.json" testId="open-keybindings-file" opacity=(data.path == "" ? 0.64 : 1) title="" + LucideFileBraces(size=16, color="light-dark(#27272a, #f5f5f5)") if error != "" text error font-size="0.75rem" line-height="1.125rem" color="light-dark(#dc2626, #f87171)" role="alert" testId="keybinding-error" padding-left="1rem" - column width="100%" box-sizing="border-box" border-radius="0.875rem" border-style="solid" border-width=1 border-color="light-dark(#e4e4e899, #ffffff09)" background-color="light-dark(#ffffff66, #11111166)" box-shadow="0 1px 2px #0000000d" - if data.error != "" + if data.error != "" + column class=KbCard SettingsNotice(message=data.error, warning=true) + if data.notice != "" + text data.notice font-size="0.75rem" line-height="1rem" color="light-dark(#71717b, #818181)" testId="keybindings-read-only" + column width="100%" gap="2rem" inert=(data.available and not data.writable) testId="keybindings-groups" if adding - row width="100%" box-sizing="border-box" padding-top="0.75rem" padding-bottom="0.75rem" padding-left="1rem" padding-right="1rem" gap="2rem" align-items="center" background-color="light-dark(#f4f4f526, #ffffff05)" testId="new-keybinding-row" - column flex=1 min-width=0 gap="0.25rem" - text "New keybinding" font-size="0.875rem" font-weight=500 line-height="1.25rem" color="light-dark(#27272a, #f5f5f5)" role="heading" aria-level=3 - row height="1.5rem" align-items="center" gap="0.375rem" - text "When" class=KbMuted - WhenButton(label=data.draftCommandLabel, expression=draftWhen, rowId="new", command=draftCommand, key=draftKey, edit=edit) - row flex-shrink=0 align-items="center" gap="0.5rem" flex-wrap="wrap" justify-content="flex-end" - SettingsSelect(selectId="new-keybinding-command", label="Command", valueLabel=(draftCommand == "" ? "Command" : data.draftCommandLabel), empty=(draftCommand == ""), value=draftCommand, width=224, disabled=busy, options=data.commands, choose=chooseCommand) - if data.draftConflict != "" - Warning(label=data.draftConflict, detail=`${data.draftConflict} The most recent matching binding wins when both conditions can apply.`) - CaptureField(rowId="new", label=data.draftCommandLabel, value=draftKey, original="", recording=(recording and draftRow == "new"), setKey=setKey, record=record("new", draftCommand, draftKey, draftWhen), capture=capture, held=held) - button press=rest("keybinding", `action=save&command=${encodeURIComponent(draftCommand)}&key=${encodeURIComponent(draftKey)}&when=${encodeURIComponent(draftWhen)}`) disabled=(busy or draftCommand == "" or draftKey == "" or not data.draftValid) class=KbPrimary testId="save-new-keybinding" opacity=(busy or draftCommand == "" or draftKey == "" or not data.draftValid ? 0.64 : 1) - text (busy ? "Saving" : "Save") font-size="0.875rem" font-weight=500 line-height="1.25rem" color="#ffffff" - button press=cancelAdd disabled=busy class=KbIcon width="1.75rem" height="1.75rem" aria-label="Cancel new keybinding" testId="cancel-new-keybinding" title="" - LucideX(size=14, color="light-dark(#71717b, #818181)") - for binding in data.rows key=binding.id - KeybindingRow(row=binding, editing=(draftRow == binding.id), draftKey=draftKey, draftWhen=draftWhen, recording=(recording and draftRow == binding.id), conflict=(draftRow == binding.id ? data.draftConflict : binding.conflict), valid=data.draftValid, busy=busy, edit=edit, setKey=setKey, record=record, capture=capture, held=held, rest=rest) - if data.available and data.rows.length == 0 and not adding - text "No keybindings match your search." font-size="0.875rem" line-height="1.25rem" color="light-dark(#71717b, #818181)" text-align="center" padding-top="3rem" padding-bottom="3rem" width="100%" testId="keybindings-empty" + column class=KbCard + row width="100%" box-sizing="border-box" padding-top="0.75rem" padding-bottom="0.75rem" padding-left="1rem" padding-right="1rem" gap="2rem" align-items="center" border-radius="0.8125rem" background-color="light-dark(#f4f4f526, #ffffff05)" testId="new-keybinding-row" + column flex=1 min-width=0 gap="0.25rem" + text "New keybinding" font-size="0.875rem" font-weight=500 line-height="1.25rem" color="light-dark(#27272a, #f5f5f5)" role="heading" aria-level=3 + row height="1.5rem" align-items="center" gap="0.375rem" + text "When" class=KbMuted + WhenButton(label=data.draftCommandLabel, expression=draftWhen, rowId="new", command=draftCommand, key=draftKey, edit=edit) + row flex-shrink=0 align-items="center" gap="0.5rem" flex-wrap="wrap" justify-content="flex-end" + SettingsSelect(selectId="new-keybinding-command", label="Command", valueLabel=(draftCommand == "" ? "Command" : data.draftCommandLabel), empty=(draftCommand == ""), value=draftCommand, width=224, disabled=busy, options=data.commands, choose=chooseCommand) + if data.draftConflict != "" + Warning(label=data.draftConflict, detail=`${data.draftConflict} The most recent matching binding wins when both conditions can apply.`) + CaptureField(rowId="new", label=data.draftCommandLabel, value=draftKey, original="", recording=(recording and draftRow == "new"), setKey=setKey, record=record("new", draftCommand, draftKey, draftWhen), capture=capture, held=held) + button press=rest("keybinding", `action=save&command=${encodeURIComponent(draftCommand)}&key=${encodeURIComponent(draftKey)}&when=${encodeURIComponent(draftWhen)}`) disabled=(busy or draftCommand == "" or draftKey == "" or not data.draftValid) class=KbPrimary testId="save-new-keybinding" opacity=(busy or draftCommand == "" or draftKey == "" or not data.draftValid ? 0.64 : 1) + text (busy ? "Saving" : "Save") font-size="0.875rem" font-weight=500 line-height="1.25rem" color="#ffffff" + button press=cancelAdd disabled=busy class=KbIcon width="1.75rem" height="1.75rem" aria-label="Cancel new keybinding" testId="cancel-new-keybinding" title="" + LucideX(size=14, color="light-dark(#71717b, #818181)") + // KeybindingsGroups: one SettingsSection per command area. + for group in data.groups key=group.id + column width="100%" gap="0.625rem" testId=`keybindings-${group.id}` + row min-height="1.75rem" padding-left="1rem" padding-right="1rem" align-items="center" box-sizing="border-box" + text group.title class=KbSectionTitle role="heading" aria-level=2 + column class=KbCard + for binding in group.rows key=binding.id + KeybindingRow(row=binding, editing=(draftRow == binding.id), draftKey=draftKey, draftWhen=draftWhen, recording=(recording and draftRow == binding.id), conflict=(draftRow == binding.id ? data.draftConflict : binding.conflict), valid=data.draftValid, busy=busy, edit=edit, setKey=setKey, record=record, capture=capture, held=held, rest=rest) + if data.available and data.groups.length == 0 + column class=KbCard + text "No keybindings match your search." font-size="0.875rem" line-height="1.25rem" color="light-dark(#71717b, #818181)" text-align="center" padding-top="3rem" padding-bottom="3rem" width="100%" testId="keybindings-empty" WhenEditor(view=data.whenView, expression=draftWhen, setWhen=setWhen) component KeybindingRow @@ -199,7 +222,8 @@ component KeybindingRow if target != "" focus(target) view - row hover=hover width="100%" box-sizing="border-box" padding-top="0.75rem" padding-bottom="0.75rem" padding-left="1rem" padding-right="1rem" gap="2rem" align-items="center" testId=`keybinding-row-${row.id}` + // SettingsGroup's divider between a section's rows. + row hover=hover width="100%" box-sizing="border-box" padding-top="0.75rem" padding-bottom="0.75rem" padding-left="1rem" padding-right="1rem" gap="2rem" align-items="center" border-top-style="solid" border-top-width=(row.first ? 0 : 1) border-color="light-dark(#e3e3e780, #ffffff08)" testId=`keybinding-row-${row.id}` column flex=1 min-width=0 gap="0.25rem" row min-height="1.25rem" align-items="center" gap="0.5rem" text row.label font-size="0.875rem" font-weight=500 line-height="1.25rem" color="light-dark(#27272a, #f5f5f5)" role="heading" aria-level=3 aria-description=row.command diff --git a/examples/t3-code/src/settings/settings-mobile-beta.contract b/examples/t3-code/src/settings/settings-mobile-beta.contract index 12ce766468..66de9eb7a4 100644 --- a/examples/t3-code/src/settings/settings-mobile-beta.contract +++ b/examples/t3-code/src/settings/settings-mobile-beta.contract @@ -1,118 +1,38 @@ -// General → About → Mobile app (upstream f1dcd93931 -// NightlyMobileBeta.tsx NightlyMobileBetaRow): `sm` outline iPhone and Android -// buttons, each opening an end-aligned popover with the beta links as QR codes -// (QRCodeSvg size 128, level M, margin 1, on a white rounded pad) and an `xs` -// outline Copy link that reads "Copied" for two seconds. -// Each PopoverPopup is align="end": `position-area="bottom span-left"` puts the -// panel's right edge under its trigger's, 4 points below, and it flips near the -// window's edge. An absolute layer sizes to its min-content, so nothing in a -// panel may wrap narrower than the reference draws it. The codes' boxes mount on -// first use only (the pointer arriving or a press). +// General → About → Mobile app, ported from T3 Code 9c161462c9 MobileAppSettingsRow.tsx (MIT, LICENSE-T3). use CoreRow from "./settings-core.contract" use CorePalette from "./settings-core.contract" -use QrMark from "./settings-core.contract" use SettingsGhost from "./settings-core.contract" -keyframes mbShownA - from opacity=1 - 99% opacity=1 - to opacity=0 -keyframes mbShownB - from opacity=1 - 99% opacity=1 - to opacity=0 -keyframes mbHiddenA - from opacity=0 - 99% opacity=0 - to opacity=1 -keyframes mbHiddenB - from opacity=0 - 99% opacity=0 - to opacity=1 - -component MobileBetaControl +component MobileAppControl props row: CoreRow pal: CorePalette busy: bool - change: action - state iosOpened = false - state androidOpened = false - action hoverIos(over: bool) - iosOpened = iosOpened or over - action hoverAndroid(over: bool) - androidOpened = androidOpened or over - action openIos - iosOpened = true - action openAndroid - androidOpened = true + state opened = "" + action show(id: string) + opened = id view - row gap="0.5rem" align-items="center" flex-shrink=0 id="mobile-beta-control" testId="mobile-beta-control" - box position="relative" flex-shrink=0 - button popovertarget="mobile-beta-ios-popup" press=openIos hover=hoverIos id="mobile-beta-ios" aria-label="iPhone" class=SettingsGhost gap="0.375rem" height="1.75rem" padding-left="0.5625rem" padding-right="0.5625rem" border-radius="0.5rem" border-width=1 border-style="solid" border-color=pal.input background-color=pal.field box-shadow="0 1px 2px #0000000d" testId="mobile-beta-ios" data-paint="input canvas" data-pick="canvas" - // Button's [&_svg]:-mx-0.5: the glyph overlaps the padding by 2 points a side. - box margin-left="-0.125rem" margin-right="-0.125rem" display="flex" - MbAppleGlyph(size=16, color=pal.muted) - text "iPhone" font-size="0.875rem" font-weight=500 line-height="1.25rem" color=pal.text white-space="nowrap" id=`paint-MobileBetaControl-2-${row.id}` data-paint="text" data-pick="text" - row id="mobile-beta-ios-popup" popover="auto" position-area="bottom span-left" position-try-fallbacks="flip-block, flip-inline, flip-block flip-inline" position="absolute" margin-top="0.25rem" testId="mobile-beta-ios-popup" - column padding="1rem" gap="0.75rem" flex-shrink=0 box-sizing="border-box" border-radius="0.625rem" border-width=1 border-style="solid" border-color=pal.popoverBorder background-color=pal.popover box-shadow="0 16px 40px -18px #0000008c" role="dialog" aria-label="TestFlight beta" id=`paint-MobileBetaControl-3-${row.id}` data-paint="text surfaceOverlay" data-pick="surfaceOverlay" - column gap="0.25rem" - // One line each: an absolute layer sizes to its min-content, so the panel must not offer a narrower one. - text "TestFlight beta" font-size="0.875rem" font-weight=600 line-height="0.875rem" color=pal.text white-space="nowrap" role="heading" aria-level=2 id=`paint-MobileBetaControl-4-${row.id}` data-paint="text" data-pick="text" - text "Scan with your iPhone camera." font-size="0.75rem" line-height="1rem" color=pal.muted white-space="nowrap" id=`paint-MobileBetaControl-5-${row.id}` data-paint="mutedForeground" data-pick="mutedForeground" - for mark in row.qr key=mark.id + row gap="0.5rem" align-items="center" flex-shrink=0 testId="mobile-app-control" + for mark in row.qr key=mark.id + box position="relative" flex-shrink=0 + button popovertarget=`mobile-app-${mark.id}-popup` press=show(mark.id) class=SettingsGhost gap="0.375rem" height="1.75rem" padding-left="0.5625rem" padding-right="0.5625rem" border-radius="0.5rem" border-width=1 border-style="solid" border-color=pal.input background-color=pal.field box-shadow="0 1px 2px #0000000d" testId=`mobile-app-${mark.id}` + box margin-left="-0.125rem" margin-right="-0.125rem" display="flex" if mark.id == "ios" - BetaLinkQr(mark=mark, mounted=iosOpened, pal=pal, busy=busy, copy=change(row.id, "copy", mark.url)) - box position="relative" flex-shrink=0 - button popovertarget="mobile-beta-android-popup" press=openAndroid hover=hoverAndroid id="mobile-beta-android" aria-label="Android" class=SettingsGhost gap="0.375rem" height="1.75rem" padding-left="0.5625rem" padding-right="0.5625rem" border-radius="0.5rem" border-width=1 border-style="solid" border-color=pal.input background-color=pal.field box-shadow="0 1px 2px #0000000d" testId="mobile-beta-android" data-paint="input canvas" data-pick="canvas" - box margin-left="-0.125rem" margin-right="-0.125rem" display="flex" - MbAndroidGlyph(size=16, color=pal.muted) - text "Android" font-size="0.875rem" font-weight=500 line-height="1.25rem" color=pal.text white-space="nowrap" id=`paint-MobileBetaControl-7-${row.id}` data-paint="text" data-pick="text" - row id="mobile-beta-android-popup" popover="auto" position-area="bottom span-left" position-try-fallbacks="flip-block, flip-inline, flip-block flip-inline" position="absolute" margin-top="0.25rem" testId="mobile-beta-android-popup" - column padding="1rem" gap="0.75rem" flex-shrink=0 box-sizing="border-box" border-radius="0.625rem" border-width=1 border-style="solid" border-color=pal.popoverBorder background-color=pal.popover box-shadow="0 16px 40px -18px #0000008c" role="dialog" aria-label="Google Play beta" id=`paint-MobileBetaControl-8-${row.id}` data-paint="text surfaceOverlay" data-pick="surfaceOverlay" - column gap="0.25rem" width="18.5rem" - text "Google Play beta" font-size="0.875rem" font-weight=600 line-height="0.875rem" color=pal.text role="heading" aria-level=2 id=`paint-MobileBetaControl-9-${row.id}` data-paint="text" data-pick="text" - text "Use the same Google account for both steps. Step 2 can take up to an hour to work after you join the group." font-size="0.75rem" line-height="1rem" color=pal.muted max-width="18rem" id=`paint-MobileBetaControl-10-${row.id}` data-paint="mutedForeground" data-pick="mutedForeground" - row gap="1rem" align-items="flex-start" - for mark in row.qr key=mark.id - if mark.id != "ios" - column width="8.75rem" gap="0.5rem" - text mark.caption font-size="0.75rem" font-weight=500 line-height="1rem" color=pal.text text-align="center" width="100%" id=`paint-MobileBetaControl-11-${row.id}-${mark.id}` data-paint="text" data-pick="text" - BetaLinkQr(mark=mark, mounted=androidOpened, pal=pal, busy=busy, copy=change(row.id, "copy", mark.url)) - -// BetaLinkQr: the white pad (rounded-lg bg-white p-1.5) around the 128-point code, then Copy link. -// Copied: a second, narrower button over the first for two seconds (an A/B pair restarts it). -component BetaLinkQr - props - mark: QrMark - mounted: bool - pal: CorePalette - busy: bool - copy: action - view - column gap="0.5rem" align-items="center" width="100%" - box width="8.75rem" height="8.75rem" padding="0.375rem" box-sizing="border-box" border-radius="0.625rem" background-color="#ffffff" flex-shrink=0 - box width="8rem" height="8rem" position="relative" background-color="#ffffff" role="img" aria-label=mark.label testId=`mobile-beta-qr-${mark.id}` - if mounted - for cell in mark.cells key=`${cell.x}:${cell.y}` - box position="absolute" left=cell.x top=cell.y width=cell.w height=cell.h background-color="#000000" - box position="relative" flex-shrink=0 - BetaCopyButton(label="Copy link", animation=(mark.copied == 0 ? "none" : (mark.copied % 2 == 0 ? "mbHiddenA 2s linear forwards" : "mbHiddenB 2s linear forwards")), pal=pal, busy=busy, copy=copy, testId=`mobile-beta-copy-${mark.id}`) - if mark.copied > 0 - row position="absolute" left="-1.25rem" right="-1.25rem" top=0 bottom=0 justify-content="center" align-items="center" - BetaCopyButton(label="Copied", animation=(mark.copied % 2 == 0 ? "mbShownA 2s linear forwards" : "mbShownB 2s linear forwards"), pal=pal, busy=busy, copy=copy, testId=`mobile-beta-copied-${mark.id}`) - -component BetaCopyButton - props - label: string - animation: string - pal: CorePalette - busy: bool - copy: action - testId: string - view - button press=copy disabled=busy aria-label=label class=SettingsGhost height="1.5rem" padding-left="0.4375rem" padding-right="0.4375rem" border-radius="0.5rem" border-width=1 border-style="solid" border-color=pal.input background-color=pal.field box-shadow="0 1px 2px #0000000d" animation=animation testId=testId id=testId data-paint="input canvas" data-pick="canvas" - text label font-size="0.75rem" font-weight=500 line-height="1rem" color=pal.text white-space="nowrap" id=`paint-BetaCopyButton-2-${testId}` data-paint="text" data-pick="text" + MbAppleGlyph(size=16, color=pal.muted) + else + MbAndroidGlyph(size=16, color=pal.muted) + text mark.label font-size="0.875rem" font-weight=500 line-height="1.25rem" color=pal.text white-space="nowrap" + row id=`mobile-app-${mark.id}-popup` popover="auto" position-area="bottom span-left" position-try-fallbacks="flip-block, flip-inline, flip-block flip-inline" position="absolute" margin-top="0.25rem" testId=`mobile-app-${mark.id}-popup` + column padding="1rem" gap="0.75rem" align-items="center" flex-shrink=0 box-sizing="border-box" border-radius="0.625rem" border-width=1 border-style="solid" border-color=pal.popoverBorder background-color=pal.popover box-shadow="0 16px 40px -18px #0000008c" role="dialog" aria-label=mark.caption + text mark.caption font-size="0.875rem" font-weight=600 line-height="0.875rem" color=pal.text white-space="nowrap" role="heading" aria-level=2 + text "Scan with your phone camera." font-size="0.75rem" line-height="1rem" color=pal.muted white-space="nowrap" + box width="8.75rem" height="8.75rem" padding="0.375rem" box-sizing="border-box" border-radius="0.625rem" background-color="#ffffff" flex-shrink=0 + box width="8rem" height="8rem" position="relative" background-color="#ffffff" role="img" aria-label=`${mark.caption} link` testId=`mobile-app-qr-${mark.id}` + if opened == mark.id + for cell in mark.cells key=`${cell.x}:${cell.y}` + box position="absolute" left=cell.x top=cell.y width=cell.w height=cell.h background-color="#000000" + link href=mark.url class=SettingsGhost height="1.75rem" padding-left="0.5625rem" padding-right="0.5625rem" border-radius="0.5rem" border-width=1 border-style="solid" border-color=pal.input background-color=pal.field box-shadow="0 1px 2px #0000000d" testId=`mobile-app-open-${mark.id}` + text `Open ${mark.caption}` font-size="0.875rem" font-weight=500 line-height="1.25rem" color=pal.text white-space="nowrap" // Icons.tsx AppleIcon and AndroidIcon (24-unit grid, currentColor). component MbAppleGlyph diff --git a/examples/t3-code/src/settings/settings-mobile-beta.ts b/examples/t3-code/src/settings/settings-mobile-beta.ts index ae43876ce7..dbd8970eaf 100644 --- a/examples/t3-code/src/settings/settings-mobile-beta.ts +++ b/examples/t3-code/src/settings/settings-mobile-beta.ts @@ -1,57 +1,15 @@ -// General → About → Mobile app (upstream f1dcd93931 -// NightlyMobileBeta.tsx NightlyMobileBetaRow). Orchestrator V2 ships to Nightly -// first and the store apps cannot connect to it, so a Nightly build (or a desktop -// whose update track is Nightly) links the beta apps: TestFlight for iPhone, and the -// Google group then the Play testing page for Android, each as a QR code -// (QRCodeSvg size 128, level M, marginSize 1) with a Copy link button. -import type { T3Client } from '../client/client'; -import type { Native } from '../client/protocol'; -import { ClientError } from '../client/protocol'; +// General → About → Mobile app, ported from T3 Code 9c161462c9 MobileAppSettingsRow.tsx (MIT, LICENSE-T3). import { coreRow, type CoreRow } from './settings-core'; import { qrCells, type QrCell } from './settings-qr'; -export const IOS_TESTFLIGHT_URL = 'https://testflight.apple.com/join/XgaxaRtd'; -export const ANDROID_BETA_GROUP_URL = 'https://groups.google.com/g/t3-code-v2-beta'; -export const ANDROID_PLAY_TESTING_URL = 'https://play.google.com/apps/testing/com.t3tools.t3code'; -export const MOBILE_BETA_ROW = 'nightly-mobile-beta'; - -/** `copied` counts the link's copies: each new one replays the view's two-second "Copied" (useCopyToClipboard's timeout). */ -export type QrMark = { id: string; url: string; label: string; caption: string; copied: number; cells: QrCell[] }; -const LINKS: [string, string, string, string][] = [ - ['ios', IOS_TESTFLIGHT_URL, 'TestFlight beta link', ''], - ['android-group', ANDROID_BETA_GROUP_URL, 'Android beta group link', '1. Join the group'], - ['android-play', ANDROID_PLAY_TESTING_URL, 'Google Play beta link', '2. Become a tester'], -]; -let cells: QrCell[][] | null = null; -// Data sources have no clock (Date.now() is unavailable there): the two seconds are the view's animation. -const copies = new WeakMap>(); -/** The three codes (encoded once per process; they never change) with each link's copy count. */ -export function mobileBetaMarks(owner: object): QrMark[] { - cells ??= LINKS.map(([, url]) => qrCells(url, 128, 1, 'M')); - const copied = copies.get(owner); - return LINKS.map(([id, url, label, caption], index) => ({ id, url, label, caption, copied: copied?.get(url) ?? 0, cells: cells![index]! })); -} - -/** IS_NIGHTLY_BUILD: the version's first prerelease identifier is `nightly` (parseSemver). */ -export function isNightlyVersion(version: string): boolean { - const match = /^v?\d+\.\d+\.\d+-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim()); - return match?.[1]?.split('.')[0] === 'nightly'; -} -/** AboutVersionSection showNightlyMobileBeta: a Nightly build, or Nightly picked as the desktop update track. */ -export const showMobileBeta = (version: string, channel: string) => isNightlyVersion(version) || channel === 'nightly'; - -export function mobileBetaRow(owner: object): CoreRow { - return coreRow(MOBILE_BETA_ROW, 'Mobile app', 'Nightly needs the beta app. The App Store and Google Play versions cannot connect.', 'mobile-beta', { qr: mobileBetaMarks(owner), width: 0 }); -} - -/** BetaLinkQr's Copy link: only the three beta links are ever written to the clipboard. */ -export async function mobileBetaCommand(client: T3Client, native: Native, part: string, value: string): Promise { - if (part !== 'copy' || !LINKS.some(([, url]) => url === value)) throw new ClientError('Unsupported mobile app action.'); - if (!native?.available) throw new ClientError('Copying links needs the macOS app.'); - const reply = await client.restAccess(native).call({ op: 'copyText', text: value }); - if (reply.copied === false || reply.ok === false) throw new ClientError('Could not copy the link.'); - const copied = copies.get(client) ?? new Map(); - copied.set(value, (copied.get(value) ?? 0) + 1); - copies.set(client, copied); - return ''; +export const APP_STORE_URL = 'https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824'; +export const GOOGLE_PLAY_URL = 'https://play.google.com/store/apps/details?id=com.t3tools.t3code'; +export type QrMark = { id: string; url: string; label: string; caption: string; cells: QrCell[] }; +let marks: QrMark[] | null = null; +export function mobileAppRow(): CoreRow { + marks ??= [ + { id: 'ios', url: APP_STORE_URL, label: 'iPhone and iPad', caption: 'App Store' }, + { id: 'android', url: GOOGLE_PLAY_URL, label: 'Android', caption: 'Google Play' }, + ].map(mark => ({ ...mark, cells: qrCells(mark.url, 128, 1, 'M') })); + return coreRow('mobile-app', 'Mobile app', 'Connect to your server from your phone or tablet.', 'mobile-app', { qr: marks, width: 0 }); } diff --git a/examples/t3-code/src/settings/settings-rows.contract b/examples/t3-code/src/settings/settings-rows.contract index e20771b66e..0b02aab7dd 100644 --- a/examples/t3-code/src/settings/settings-rows.contract +++ b/examples/t3-code/src/settings/settings-rows.contract @@ -14,7 +14,7 @@ use CcIcon from "../composer/composer-controls.contract" use CnMachineIcon from "./settings-b-kit.contract" use PolicyTip from "./settings-kit.contract" use AboutUpdateButton from "./settings-a-about.contract" -use MobileBetaControl from "./settings-mobile-beta.contract" +use MobileAppControl from "./settings-mobile-beta.contract" use ScopedSwitch from "./settings-scoped-switch.contract" use FontFamilyPicker from "../appearance/settings-a-fonts.contract" use PromptFontPreview from "../appearance/settings-prompt-preview.contract" @@ -275,8 +275,8 @@ component CoreRowView TraitsMenu(options=row.options2, pal=pal, menuId=`effort-${section}-${row.id}`, items=traitsItems, keyed=0, choose=change(row.id, "effort"), testId=`setting-${row.id}-effort-menu`) if row.kind == "text-only" text row.label font-size="0.875rem" line-height="1.25rem" color=pal.muted id=`paint-CoreRowView-15-${row.id}` data-paint="mutedForeground" data-pick="mutedForeground" - if row.kind == "mobile-beta" - MobileBetaControl(row=row, pal=pal, busy=busy, change=change) + if row.kind == "mobile-app" + MobileAppControl(row=row, pal=pal, busy=busy) if row.kind == "number" or row.kind == "text" CoreField(value=row.value, rowId=row.id, width=row.width, placeholder=row.placeholder, numeric=(row.kind == "number"), label=row.title, pal=pal, disabled=(busy or row.inert), commit=change(row.id, row.target)) if row.kind == "link" diff --git a/examples/t3-code/src/settings/settings-scope-sources.ts b/examples/t3-code/src/settings/settings-scope-sources.ts index 39fc882b6e..05769a4eb5 100644 --- a/examples/t3-code/src/settings/settings-scope-sources.ts +++ b/examples/t3-code/src/settings/settings-scope-sources.ts @@ -90,13 +90,13 @@ export async function environmentRequest(client: Writer, native: Native, environ /** persistScopedSettingsPatch's persistServer: one server.updateSettings per environment; the answer replaces that environment's settings. */ export function scopedWriter(client: Writer, native: Native, environments: readonly ScopeEnvironment[], source: EnvironmentFleet = appFleet) { - return async ({ environmentId, input }: { environmentId: string; input: { patch: Obj } }): Promise<{ _tag: 'Success' | 'Failure' }> => { + return async ({ environmentId, input }: { environmentId: string; input: { patch: Obj } }): Promise<{ _tag: 'Success' | 'Failure'; value?: Obj }> => { const environment = environments.find(candidate => candidate.environmentId === environmentId); if (!environment) return { _tag: 'Failure' }; const updated = await environmentRequest(client, native, environment, 'server.updateSettings', input, true, source); if (!environment.fleetKey) client.config = { ...client.config, settings: updated }; else { const entry = entryOf(source, environment.fleetKey); if (entry) { entry.config = { ...entry.config, settings: updated }; source.revision++; } } - return { _tag: 'Success' }; + return { _tag: 'Success', value: updated }; }; } diff --git a/examples/t3-code/src/settings/settings-search.ts b/examples/t3-code/src/settings/settings-search.ts index d429235a71..89096187f3 100644 --- a/examples/t3-code/src/settings/settings-search.ts +++ b/examples/t3-code/src/settings/settings-search.ts @@ -3,6 +3,7 @@ // project/checkout scope); a query replaces the list with ranked catalog results whose // availability and owning scope match this client. import { CATALOG } from './settings-catalog'; +import { commandLabel } from './keybinding-view'; // settingsSearch.ts imports KeybindingsSettings.logic's import { arr, obj, str, type Obj } from '../client/domain'; import { canManageLocalBackend, primary } from '../connections/local-primary'; @@ -34,14 +35,7 @@ export function scopeAvailable(required: string, kind: string) { default: return true; } } -const segment = (part: string) => part.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[-_\s]+/).filter(Boolean).map(word => word[0]!.toUpperCase() + word.slice(1)).join(' '); -export function commandLabel(command: string) { - const special: Record = { 'composer.sendAlternate': 'Composer: Opposite Queue or Steer Action', 'composer.sendBackground': 'Composer: Start in Background', 'composer.sendAndNewThread': 'Composer: Send and Start New Thread', - 'thread.steerQueuedMessage': 'Queue: Send First Queued Message as Steer', 'thread.editQueuedMessage': 'Queue: Edit Last Queued Message', 'thread.copyReference': 'Pull Request: Copy Link or Thread ID' }; - if (special[command]) return special[command]!; - if (command.startsWith('script.') && command.endsWith('.run')) return `Run Script: ${segment(command.slice(7, -4))}`; - return command.split('.').map(segment).join(': '); -} +export { commandLabel }; /** `localBackend`: canManageLocalBackend; `localEnvironment`: the Local environment switch is on. */ export type SearchContext = { connected: boolean; autoSettle: boolean; scopeKind: string; keybindings?: Obj[]; localBackend?: boolean; localEnvironment?: boolean }; diff --git a/examples/t3-code/src/settings/settings-shortcuts.contract b/examples/t3-code/src/settings/settings-shortcuts.contract index b160924ce8..bcbf26c24a 100644 --- a/examples/t3-code/src/settings/settings-shortcuts.contract +++ b/examples/t3-code/src/settings/settings-shortcuts.contract @@ -22,6 +22,7 @@ component ShortcutDispatch chatNew: action deviceSetting: action toggleDiff: action + details: action // threadPanel.toggle: the header's "Toggle thread details panel" (detailsAct("toggle")) openDiff: action closeDiff: action panel: action // ⌘W closes the active surface tab (panelUi("tab-close", id)) @@ -56,6 +57,8 @@ component ShortcutDispatch ShortcutButton(item=item, press=deviceSetting("appearanceMode", item.target)) if item.kind == "diff" ShortcutButton(item=item, press=toggleDiff) + if item.kind == "details" + ShortcutButton(item=item, press=details("toggle", "", "")) if item.kind == "diff-open" ShortcutButton(item=item, press=openDiff) if item.kind == "close-diff" diff --git a/examples/t3-code/src/settings/source-control-view.ts b/examples/t3-code/src/settings/source-control-view.ts index 1a119b0c43..688fd6fef2 100644 --- a/examples/t3-code/src/settings/source-control-view.ts +++ b/examples/t3-code/src/settings/source-control-view.ts @@ -32,7 +32,7 @@ export type ScopedRow = { key: string; kind: string; title: string; description: /** A model row's picker trigger mark: the instance's driver and ProviderInstanceIcon badge. */ driver: string; badge: string; accent: string }; -export const DEFAULTS: Obj = { defaultAutoPull: false, pullRequestMergeMethod: null, branchNamingMode: 'static', branchNamePrefix: 't3code', branchNameInstructions: '', +export const DEFAULTS: Obj = { defaultAutoPull: false, pullRequestMergeMethod: null, branchNamingMode: 'static', branchNamePrefix: 't3', branchNameInstructions: '', sourceControlWritingStyle: { mode: 'repo_conventions', customInstructions: '', followChangeRequestTemplates: true }, sourceControlWriterModelSelection: null, enableAgentBrowserAccess: true, enableAgentDeviceAccess: false, enableDeviceSupport: false, defaultModelSelection: null, defaultThreadEnvMode: null, deviceHosts: [] }; const PROJECT_KEYS = ['defaultModelSelection', 'defaultThreadEnvMode', 'defaultAutoPull', 'enableAgentBrowserAccess', 'enableAgentDeviceAccess', 'sourceControlWriterModelSelection', 'sourceControlWritingStyle', 'branchNamingMode', 'branchNamePrefix', 'branchNameInstructions', 'pullRequestMergeMethod']; @@ -99,10 +99,10 @@ export function sourceControlRows(settings: Obj, projectId: string, environmentL const available = textProviders.some(provider => providerAvailable(provider) && arr(provider.models).some(model => model.isUnavailable !== true)); const text = [ row(settings, projectId, environmentLabel, 'branchNamingMode', { kind: 'select', title: 'Worktree branch naming', resetLabel: 'branch naming', first: true, value: mode, valueLabel: BRANCH_MODES[mode] || mode, disabled: !writable, - description: 'Choose how new worktree branches are named from your first message.', options: Object.entries(BRANCH_MODES).map(([v, l]) => ({ value: v, label: l, selected: v === mode })), reset: resetFor('branchNamingMode', mode !== 'static') }), + description: 'Choose how new worktree branches are named from your first message.', options: Object.entries(BRANCH_MODES).map(([v, l]) => ({ value: v, label: l, selected: v === mode })), reset: resetFor('branchNamingMode', mode !== 'static'), + status: mode === 'semantic' ? 'The model chooses a prefix that describes the work, such as feat/add-search, fix/login-timeout, or refactor/auth.' : '' }), ...(mode === 'static' ? [row(settings, projectId, environmentLabel, 'branchNamePrefix', { kind: 'text', title: 'Branch prefix', resetLabel: 'branch prefix', value: str(value.branchNamePrefix), placeholder: 'No prefix', disabled: !writable, - description: 'For example, t3code or t3code/ produces t3code/add-search. Leave empty for no prefix.', reset: resetFor('branchNamePrefix', str(value.branchNamePrefix) !== 't3code') })] : []), - ...(mode === 'semantic' ? [row(settings, projectId, environmentLabel, 'branchNamingMode', { kind: 'note', title: '', description: 'The model chooses a prefix that describes the work, such as feat/add-search, fix/login-timeout, or refactor/auth.' })] : []), + description: 'For example, t3 or t3/ produces t3/add-search. Leave empty for no prefix.', reset: resetFor('branchNamePrefix', str(value.branchNamePrefix) !== 't3') })] : []), ...(mode === 'custom' ? [row(settings, projectId, environmentLabel, 'branchNameInstructions', { kind: 'textarea', title: 'Branch naming instructions', resetLabel: 'branch naming instructions', value: str(value.branchNameInstructions), placeholder: 'Use julius/ followed by the issue ID and a short description.', disabled: !writable, description: 'Appended to the naming prompt. The model returns the complete branch name; no prefix or suffix is added.', reset: resetFor('branchNameInstructions', str(value.branchNameInstructions) !== '') })] : []), row(settings, projectId, environmentLabel, 'sourceControlWritingStyle', { kind: 'select', title: 'Source control writing style', resetLabel: 'source control writing style', value: writing, valueLabel: WRITING_MODES[writing]?.label || writing, disabled: !writable, diff --git a/examples/t3-code/src/shell/app-settings.contract b/examples/t3-code/src/shell/app-settings.contract index 4ec5cc6a68..2812207915 100644 --- a/examples/t3-code/src/shell/app-settings.contract +++ b/examples/t3-code/src/shell/app-settings.contract @@ -197,7 +197,6 @@ component SettingsWindow state settingsEscapeHeld = false state settingsLegacyOpen = false state settingsRestoreOpen = false - state keybindingSearch = false state licenseSearchOpen = false // The "Models" block whose "Add custom model" field is open (ProviderModelsSection // isAdding, by the block's key: a successful add gives the block a new one), the field whose onKeyDown would take @@ -218,7 +217,7 @@ component SettingsWindow // Escape closes only itself. While one owns it, Back gives up its Escape shortcut; with // none (a search left open with a query, the focus elsewhere), Escape leaves Settings. The When editor's and the // font list's popovers and the recorder are `aria-modal` too, and the dialogs make this view inert (below). - derive escapeOwned = routeKeyboard.phase != "" or settingsMenu != "" or modelsOpen or paletteOpen or chipOpen or themeInspecting or settingsRestoreOpen or settingsEscapeHeld or snapshotSetupOpen or (settingsRoute == "keybindings" and (keybindingRecording or (keybindingSearch and escapeFocus == "keybindings-filter"))) or (settingsRoute == "open-source-licenses" and licenseSearchOpen and escapeFocus == "license-search") or (settingsRoute == "providers" and (providerPage.escapeOwned or (modelAddingShown and escapeFocus == "custom-model-slug"))) + derive escapeOwned = routeKeyboard.phase != "" or settingsMenu != "" or modelsOpen or paletteOpen or chipOpen or themeInspecting or settingsRestoreOpen or settingsEscapeHeld or snapshotSetupOpen or (settingsRoute == "keybindings" and (keybindingRecording or escapeFocus == "keybindings-filter")) or (settingsRoute == "open-source-licenses" and licenseSearchOpen and escapeFocus == "license-search") or (settingsRoute == "providers" and (providerPage.escapeOwned or (modelAddingShown and escapeFocus == "custom-model-slug"))) // A Settings dialog is modal (Base UI Dialog): the page behind it is inert, so Back's Escape is the dialog's. // "Remove from this device…" (EnvironmentRemoveDialog) counts here; Network access's confirmation does not, as it stays // asked for after its serial moves on, when no dialog shows. Both are `aria-modal` too. @@ -275,17 +274,15 @@ component SettingsWindow settingsCommand("settings-core", `restore-device-defaults:|${settingsCore.scopeKey}`, "") action coreSlide(row: string, part: string, value: number) settingsCommand("settings-core", `${row}:${part}|${settingsCore.scopeKey}`, `${value}`) - action searchKeybindings(open: bool) - if open or keybindingQuery == "" - keybindingSearch = open - if not open - escapeFocusSet("keybindings-filter", false) + // KeybindingsSearchInput: Escape clears the filter, then leaves the field; Settings' Back takes neither. action keybindingSearchKey(name: string) if name == "Escape" preventDefault() - keybindingSearch = false - escapeFocusSet("keybindings-filter", false) - editKeybindingQuery("") + if keybindingQuery != "" + editKeybindingQuery("") + else + escapeFocusSet("keybindings-filter", false) + blur("keybindings-filter") action openLicenseSearch licenseSearchOpen = true action closeLicenseSearch @@ -310,7 +307,7 @@ component SettingsWindow // Each page owns its scroller (SettingsPageContainer's `data-settings-page-scroll`): a new page opens at its top. for page in [settingsPage] key=page scroll flex=1 min-height=0 width="100%" padding-left="0.375rem" padding-right="0.375rem" box-sizing="border-box" id="settings-content-scroll" scrollTop=settingsScrollTop scroll-behavior="smooth" testId="settings-content-scroll" - column gap="1.125rem" width="100%" max-width=(settingsRoute == "diagnostics" ? "72rem" : (settingsRoute == "providers" or settingsRoute == "connections" ? "64rem" : "56rem")) min-width=0 margin-left="auto" margin-right="auto" padding-left="1.5rem" padding-right="1.5rem" padding-top="1.5rem" padding-bottom="3rem" box-sizing="border-box" position="relative" id="settings-content-column" + column gap="1.125rem" width="100%" max-width=(settingsRoute == "diagnostics" ? "72rem" : "56rem") min-width=0 margin-left="auto" margin-right="auto" padding-left="1.5rem" padding-right="1.5rem" padding-top="1.5rem" padding-bottom="3rem" box-sizing="border-box" position="relative" id="settings-content-column" if settingsMenu != "" button press=coreMenuClose position="absolute" left=0 top=0 width="100%" height="100%" z-index=40 background-color="#00000000" aria-label="Close menu" aria-keyshortcuts="Escape" testId="settings-menu-backdrop" title="" if settingsCore.showScope or settingsRoute == "general" or settingsRoute == "appearance" @@ -322,7 +319,7 @@ component SettingsWindow if settingsRoute == "snap-shot" SnapshotPanel(data=snapshotSettings, busy=commandPending, setupOpen=snapshotSetupOpen, toggle=toggleSnapshots, setup=showSnapshotSetup(true), change=changeSnapshot, preview=previewSnapshotSound, record=recordSnapshotShortcut, saveShortcut=saveSnapshotShortcut) if settingsRoute == "keybindings" - KeybindingsPanel(data=bindings, query=keybindingQuery, searchOpen=keybindingSearch, adding=keybindingOpen, draftRow=keybindingPrevious, draftCommand=keybindingCommand, draftKey=keybindingKey, draftWhen=keybindingWhen, recording=keybindingRecording, busy=commandPending, error=modalError, search=editKeybindingQuery, searchToggle=searchKeybindings, searchKey=keybindingSearchKey, searchFocus=escapeFocusSet("keybindings-filter", true), add=newKeybinding, cancelAdd=cancelKeybinding, chooseCommand=chooseKeybindingCommand, edit=editKeybinding, setKey=editKeybindingKey, setWhen=editKeybindingWhen, record=recordKeybinding, capture=captureKeybinding, held=keybindingHeld, rest=rest, openFile=rest("keybinding-open", "")) + KeybindingsPanel(data=bindings, query=keybindingQuery, adding=keybindingOpen, draftRow=keybindingPrevious, draftCommand=keybindingCommand, draftKey=keybindingKey, draftWhen=keybindingWhen, recording=keybindingRecording, busy=commandPending, error=modalError, search=editKeybindingQuery, searchKey=keybindingSearchKey, searchFocus=escapeFocusSet("keybindings-filter", true), searchBlur=escapeFocusSet("keybindings-filter", false), add=newKeybinding, cancelAdd=cancelKeybinding, chooseCommand=chooseKeybindingCommand, edit=editKeybinding, setKey=editKeybindingKey, setWhen=editKeybindingWhen, record=recordKeybinding, capture=captureKeybinding, held=keybindingHeld, rest=rest, openFile=rest("keybinding-open", "")) if settingsRoute == "scheduled-tasks" ScheduledPanel(data=scheduled, busy=commandPending, error=(restEditor == "" ? modalError : ""), open=restOpen, rest=rest) if settingsRoute == "storage" diff --git a/examples/t3-code/src/shell/app-window.contract b/examples/t3-code/src/shell/app-window.contract index ca03692191..995f53c2a4 100644 --- a/examples/t3-code/src/shell/app-window.contract +++ b/examples/t3-code/src/shell/app-window.contract @@ -345,6 +345,8 @@ component T3Window dialogClosed dialogFallback rem = data.look.fontSize + wizardSteps = data.look.wizardSteps // the setup wizards' step list and panel follow the theme (ui/wizard.tsx) + wizardPanel = data.look.wizardPanel still = viewport.prefersReducedMotion hoverTipAt hoverTip @@ -864,7 +866,7 @@ component T3Window SettingsWindow(settingsOpen=settingsOpen, themeInspecting=(themeInspecting and settingsCore.editor.open), settingsCore=settingsCore, connecting=connecting, connectionRemove=connectionRemove, settingsThemeDialog=settingsThemeDialog, data=data, settingsNavigation=settingsNavigation, settingsRoute=settingsRoute, settingsQuery=settingsQuery, sidebarWidth=sidebarWidth, viewport=viewport, scheme=scheme, settingsMenu=settingsMenu, modelsOpen=modelsOpen, providerVisit=providerVisit, paletteOpen=paletteOpen, chipOpen=chipShown, coreClearSearch=coreClearSearch, closeModal=closeModal, toggleSidebar=toggleSidebar, keySidebar=settings.keySidebar, commandPending=commandPending, settingsScrollTop=settingsScrollTop, settingsPage=settingsPage, coreMenuClose=coreMenuClose, coreMenu=coreMenu, coreChange=coreChange, coreScope=coreScope, coreThemeDialog=coreThemeDialog, sourceControl=sourceControl, modalError=modalError, rest=rest, restInput=restInput, restLocal=restLocal, importWizardFocusMoved=importWizardFocusMoved, integrations=integrations, snapshotSettings=snapshotSettings, snapshotSetupOpen=snapshotSetupOpen, toggleSnapshots=toggleSnapshots, showSnapshotSetup=showSnapshotSetup, changeSnapshot=settingsCommand("setting-snapshot"), previewSnapshotSound=previewSnapshotSound, recordSnapshotShortcut=settingsCommand("snapshot-shortcut-record", ""), saveSnapshotShortcut=settingsCommand("snapshot-shortcut-save", ""), bindings=bindings, keybindingQuery=keybindingQuery, keybindingOpen=keybindingOpen, keybindingPrevious=keybindingPrevious, keybindingCommand=keybindingCommand, keybindingKey=keybindingKey, keybindingWhen=keybindingWhen, keybindingRecording=keybindingRecording, editKeybindingQuery=editKeybindingQuery, newKeybinding=newKeybinding, cancelKeybinding=cancelKeybinding, chooseKeybindingCommand=chooseKeybindingCommand, editKeybinding=editKeybinding, editKeybindingKey=editKeybindingKey, editKeybindingWhen=editKeybindingWhen, recordKeybinding=recordKeybinding, captureKeybinding=captureKeybinding, keybindingHeld=holdKeybindingField, scheduled=scheduled, restEditor=restEditor, restOpen=restOpen, storageSettings=storageSettings, archives=archives, restMenu=restMenu, restRaw=restRaw, restMenuOpen=restMenuOpen, restClose=restClose, licenses=licenses, licenseQuery=licenseQuery, editLicenseQuery=editLicenseQuery, toggleLicense=toggleLicense, retryLicenses=retryLicenses, diagnostics=diagnostics, selectDiagnosticPeriod=selectDiagnosticPeriod, refreshSettings=refreshSettings, copyDiagnostic=copyDiagnostic, connectionsPage=connectionsPage, pendingModal=pendingModal, openConnection=openConnection, connectionOp=connectionOp, connectionAskRemove=connectionAskRemove, connectionMenu=connectionMenu, connectionToggleMenu=connectionToggleMenu, openRoute=openRoute, providerPage=providerPage, providerDialog=providerDialog, providerUi=providerUi, providerOp=providerOp, providerAccent=providerAccent, setupBusy=providerAuthPending, projectsSettings=projectsSettings, settingsThemeSubject=settingsThemeSubject, coreThemeChange=coreThemeChange, editSettingsQueryRoot=editSettingsQuery, coreSearchKeyRoot=coreSearchKey, coreNavigateRoot=coreNavigate, corePickRoot=corePick, settingsCommand=settingsCommand, resizeSidebar=resizeSidebar, finishResize=finishResize, resetSidebarWidth=resetSidebarWidth) ThemeEditorHost(inspecting=themeInspecting, inspectSet=setThemeInspecting, reveal=themeRevealRequest, usage=themeUsage, kind=settingsThemeDialog, core=settingsCore, viewport=viewport, busy=commandPending, error=modalError, close=coreThemeDialog("", ""), commit=coreChange, live=coreThemeLive, save=coreThemeChange("theme-editor-save", ""), outsideFocus=outsideFocus) WelcomeLayer(welcome=welcome, viewport=viewport, scheme=scheme, welcomeStep=welcomeStep, commandPending=commandPending, modalError=modalError, welcomeLink=welcomeLink, welcomePairOpen=welcomePairOpen, welcomeGo=welcomeGo, welcomeImport=welcomeImport, welcomeEditLink=welcomeEditLink, welcomePair=welcomePair, welcomeTogglePair=welcomeTogglePair, prCopy=prCopy, welcomeSetup=welcomeSetup, providerUi=providerUi, welcomeFinish=welcomeFinish, providerOp=providerOp, setupBusy=providerAuthPending) - ShortcutDispatch(items=shortcuts, selectThread=selectThread, newThread=newThread, chatNew=sidebarNewThread, deviceSetting=deviceSetting, toggleDiff=panelUi("toggle", ""), openDiff=panelUi("choose", "diff"), closeDiff=panelUi("close", ""), panel=panelUi, prClose=prClose, stop=command("stop", "", "", 0), openOptions=openOptions, selectProvider=selectProvider, selectModel=selectModel, back=closeModal, copyThread=restLocal, palette=paletteShow, run=paletteRun, command=command, settings=openSettings, copy=prCopy, confirm=titleMenuPick) + ShortcutDispatch(items=shortcuts, selectThread=selectThread, newThread=newThread, chatNew=sidebarNewThread, deviceSetting=deviceSetting, toggleDiff=panelUi("toggle", ""), details=detailsAct, openDiff=panelUi("choose", "diff"), closeDiff=panelUi("close", ""), panel=panelUi, prClose=prClose, stop=command("stop", "", "", 0), openOptions=openOptions, selectProvider=selectProvider, selectModel=selectModel, back=closeModal, copyThread=restLocal, palette=paletteShow, run=paletteRun, command=command, settings=openSettings, copy=prCopy, confirm=titleMenuPick) ProjectDialogs(restEditor=restEditor, restSubject=restSubject, restLabel=restLabel, scheduled=scheduled, projectsSettings=projectsSettings, commandPending=commandPending, modalError=modalError, rest=rest, restRaw=restRaw, restClose=restClose, restOpen=restOpen, sbPicker=sbPicker, sbQuery=sbQuery, sbLettersEdited=sbLettersEdited, sbLetters=sbLetters, sbEmoji=sbEmoji, viewport=viewport, scheme=scheme, sbSet=sbSet, taskBase=taskBaseRoot, cardScripts=(details.scriptName != "")) SidebarOverlays(data=data, width=sidebarWidth, open=(data.sidebarOpen and not modal), busy=commandPending, local=sidebarLocal, run=sidebarRun, hoverId=sidebarHoverId, hoverLast=sidebarHoverLast, hoverY=sidebarHoverY, dragId=dragId, dragTarget=dragTarget, dragVerb=dragVerb, ghostX=dragCX, ghostY=dragCY, shell=shell, pullRequests=home.pullRequests, viewportHeight=viewport.height, hoverH=sidebarHoverH, resize=resizeSidebar, finishResize=finishResize, resetWidth=resetSidebarWidth, hoverCard=sidebarHover, covered=paletteOpen) SettingsModals(snapshotSetupOpen=snapshotSetupOpen, snapshotSettings=snapshotSettings, commandPending=commandPending, modalError=modalError, snapshotSetupWasEnabled=snapshotSetupWasEnabled, snapshotSetupFrom=snapshotSetupFrom, showSnapshotSetup=showSnapshotSetup, toggleSnapshots=toggleSnapshots, changeSnapshot=settingsCommand("setting-snapshot"), recordSnapshotShortcut=settingsCommand("snapshot-shortcut-record", ""), saveSnapshotShortcut=settingsCommand("snapshot-shortcut-save", ""), connecting=connecting, origin=origin, credential=credential, pairFields=pairFields, data=data, viewport=viewport, scheme=scheme, sbSshHosts=sbSshHosts, sbSshHost=sbSshHost, editOrigin=editOrigin, editCredential=editCredential, connectionOp=connectionOp, sbSet=sbSet, closeConnection=closeConnection, providerDialog=providerDialog, providerWizard=providerWizard, acpAgents=acpAgents, wizardStep=wizardStep, wizardAttempted=wizardAttempted, wizardLabelSet=wizardLabelSet, wizardLabel=wizardLabel, wizardIdSet=wizardIdSet, wizardId=wizardId, acpQuery=acpQuery, wizardManual=wizardManual, providerUi=providerUi, providerAdd=providerAdd, providerOp=providerOp, providerPage=providerPage, providerTarget=providerTarget, setupBusy=providerAuthPending, connectionRemove=connectionRemove, connectionsPage=connectionsPage, connectionAskRemove=connectionAskRemove, routeTarget=routeTarget, routeLabel=routeLabel, localCommand=settingsCommand) diff --git a/examples/t3-code/src/shell/keyboard-dispatch.ts b/examples/t3-code/src/shell/keyboard-dispatch.ts index 6c8dc8ae01..709958d15d 100644 --- a/examples/t3-code/src/shell/keyboard-dispatch.ts +++ b/examples/t3-code/src/shell/keyboard-dispatch.ts @@ -232,12 +232,14 @@ function threadRows(add: DispatchAdd, client: T3Client, threads: Obj[], browsePr const project = (client.shell?.projects ?? []).find(candidate => candidate.id === client.projectId); if (project && favoriteEditor(client.config) && openFavoriteEnabled(client, str(project.title))) add('editor.openFavorite', 'flow', 'open-favorite', 'Open in Editor', str(thread?.worktreePath, str(project.workspaceRoot))); } -/** The right panel: close, toggle, Diff, and Copy Thread ID. */ +/** The right panel: close, toggle, thread details, Diff, and Copy Thread ID. */ function panelRows(add: DispatchAdd, client: T3Client, threads: Obj[], browseProvider: string, modelQuery: string, context: DispatchContext): void { // rightPanel.close: an open panel closes before ⌘W reaches the window, a draft's panel too. const closing = closeChordTarget(client, context.diffOpen, context.prNumber ?? ''); // the active surface tab first if (closing) add('rightPanel.close', closing[0], closing[1], 'Close Right Panel'); add('rightPanel.toggle', 'diff', '', 'Toggle Right Panel'); // a draft's panel toggles too (ChatView toggleRightPanel) + // threadPanel.toggle: ChatView's toggleThreadPanel, for the open thread or draft (no ChatView under a utility page). + if (!context.page && (client.threadId || client.projectId)) add('threadPanel.toggle', 'details', '', 'Toggle Thread Details'); if (!context.draftThreadRoute) { add('diff.toggle', diffShown(client) ? 'diff' : 'diff-open', '', 'Toggle Diff'); add('thread.copyReference', 'copy-thread', '', 'Copy Thread ID'); diff --git a/examples/t3-code/src/shell/shell-commands.ts b/examples/t3-code/src/shell/shell-commands.ts index 22008d31b6..59a7605e4a 100644 --- a/examples/t3-code/src/shell/shell-commands.ts +++ b/examples/t3-code/src/shell/shell-commands.ts @@ -38,6 +38,7 @@ export async function shellCommand(client: T3Client, native: Native, storage: Fi if (op.startsWith('clone-')) return cloneCommand(client, native, storage, op, id, value); // project-clones-live.ts if (op.startsWith('automation-')) return automationCommand(client, native, op.slice(11), id, value); // scheduled-tasks-commands.ts const access = client.restAccess(native); + if (op === 'select-editor') { rememberEditor(client, value); return ''; } if (op === 'open-editor') { if (!id) throw new ClientError('This thread does not have a workspace path to open.'); // OpenInPicker: remotely a deep link to this Mac's editor, never an editor run on the other machine (remote-open.ts). diff --git a/examples/t3-code/src/snapshot/snapshot-shortcut.ts b/examples/t3-code/src/snapshot/snapshot-shortcut.ts index 9bc3605561..9c5562aee2 100644 --- a/examples/t3-code/src/snapshot/snapshot-shortcut.ts +++ b/examples/t3-code/src/snapshot/snapshot-shortcut.ts @@ -1,5 +1,6 @@ import { arr, obj, str, type Obj } from '../client/domain'; import { shortcutInput, validShortcut } from '../settings/keybinding-settings'; +import { commandLabel } from '../settings/keybinding-view'; export function snapshotShortcut(value: string): string { const aliases: Record = { cmd: 'meta', command: 'meta', control: 'ctrl', option: 'alt', escape: 'esc', arrowleft: 'left', arrowright: 'right', arrowup: 'up', arrowdown: 'down' }; const input = value.trim().toLowerCase(), tokens = (input.endsWith('++') ? input.slice(0, -1) + 'plus' : input).split('+').map(token => aliases[token] || token); @@ -58,17 +59,4 @@ export function snapshotSystemConflict(value: string): string { return ''; } -const specialCommands: Record = { - 'composer.sendAlternate': 'Composer: Opposite Queue or Steer Action', 'composer.sendBackground': 'Composer: Start in Background', - 'composer.sendAndNewThread': 'Composer: Send and Start New Thread', - 'thread.steerQueuedMessage': 'Queue: Send First Queued Message as Steer', 'thread.editQueuedMessage': 'Queue: Edit Last Queued Message', - 'thread.copyReference': 'Pull Request: Copy Link or Thread ID', 'usage.cost': 'Usage: Cost', 'usage.tokens': 'Usage: Tokens', 'usage.limits': 'Usage: Limits', - 'usage.period.day': 'Usage: Period: Past 24h', 'usage.period.week': 'Usage: Period: 7 days', 'usage.period.month': 'Usage: Period: 30 days', 'usage.period.quarter': 'Usage: Period: 90 days', -}; -const titleCase = (segment: string) => segment.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[-_\s]+/).filter(Boolean).map(part => part.slice(0, 1).toUpperCase() + part.slice(1)).join(' '); -/** Reference KeybindingsSettings.logic commandLabel. */ -export function commandLabel(command: string): string { - if (specialCommands[command]) return specialCommands[command]; - if (command.startsWith('script.') && command.endsWith('.run')) return `Run Script: ${titleCase(command.slice('script.'.length, -'.run'.length))}`; - return command.split('.').map(titleCase).join(': '); -} +export { commandLabel }; diff --git a/examples/t3-code/src/snapshot/snapshot.contract b/examples/t3-code/src/snapshot/snapshot.contract index 66226a1274..3bed3cd5f7 100644 --- a/examples/t3-code/src/snapshot/snapshot.contract +++ b/examples/t3-code/src/snapshot/snapshot.contract @@ -335,6 +335,8 @@ component SnapshotSetup inject dialogEntered: action dialogFallback: action + wizardPanel: string + wizardSteps: string props data: SnapshotSettings busy: bool @@ -362,7 +364,7 @@ component SnapshotSetup DialogOpenFocus(target=(busy ? (error != "" ? "snapshot-setup-advanced" : "") : (data.setupReady and not back ? "snapshot-setup-record" : (not data.screenRecording ? "snapshot-permission-screen-allow" : (not data.accessibility ? "snapshot-permission-accessibility-allow" : (error != "" ? "snapshot-setup-advanced" : "snapshot-setup-cancel")))))) column padding-top="1.5rem" padding-left="1.5rem" padding-right="1.5rem" padding-bottom="0.75rem" gap="0.5rem" box-sizing="border-box" text "Set up snapshots" font-size="1.25rem" font-weight=600 line-height="1.25rem" class=SnapInk role="heading" aria-level=2 - row gap="0.25rem" padding="0.25rem" border-radius="0.875rem" background-color="light-dark(#fcfcfc, #ffffff0a)" box-shadow="0 0 0 1px light-dark(#0000000d, #ffffff0d)" role="list" aria-label="Setup progress" + row gap="0.25rem" padding="0.25rem" border-radius="0.875rem" background-color=wizardSteps box-shadow="0 0 0 1px light-dark(#0000000d, #ffffff0d)" role="list" aria-label="Setup progress" row flex=1 min-width=0 gap="0.5rem" align-items="center" padding-left="0.625rem" padding-right="0.625rem" padding-top="0.5rem" padding-bottom="0.5rem" border-radius="0.625rem" background-color=(data.setupReady and not back ? "#00000000" : "light-dark(#ffffff, #111111)") box-shadow=(data.setupReady and not back ? "none" : "0 1px 2px #0000000d, 0 0 0 1px light-dark(#0000000d, #ffffff0d)") testId="snapshot-setup-step-access" row width="1.25rem" height="1.25rem" border-radius="0.625rem" align-items="center" justify-content="center" flex-shrink=0 background-color=(data.setupReady and not back ? "light-dark(#1b4ed8, #346bf1)" : "light-dark(#1b4ed81a, #346bf11a)") box-shadow=(data.setupReady and not back ? "0 0 0 1px light-dark(#1b4ed8, #346bf1)" : "0 0 0 1px light-dark(#1b4ed84d, #346bf14d)") if data.setupReady and not back @@ -374,7 +376,7 @@ component SnapshotSetup row width="1.25rem" height="1.25rem" border-radius="0.625rem" align-items="center" justify-content="center" flex-shrink=0 background-color=(data.setupReady and not back ? "light-dark(#1b4ed81a, #346bf11a)" : "light-dark(#ffffff, #ffffff0d)") box-shadow=(data.setupReady and not back ? "0 0 0 1px light-dark(#1b4ed84d, #346bf14d)" : "0 0 0 1px light-dark(#0000001a, #ffffff1a)") text "2" font-size="0.875rem" font-weight=500 line-height="1.25rem" color=(data.setupReady and not back ? "light-dark(#1b4ed8, #346bf1)" : "light-dark(#71717b, #818181)") text "Shortcut" font-size="0.875rem" font-weight=500 line-height="1.25rem" color=(data.setupReady and not back ? "light-dark(#27272a, #f5f5f5)" : "light-dark(#71717b, #818181)") - column padding-left="1.5rem" padding-right="1.5rem" padding-top="1.25rem" padding-bottom="1.25rem" gap="1rem" box-sizing="border-box" background-color="light-dark(#fcfcfccc, #ffffff05)" box-shadow="0 0 0 1px light-dark(#0000000d, #ffffff0d)" + column padding-left="1.5rem" padding-right="1.5rem" padding-top="1.25rem" padding-bottom="1.25rem" gap="1rem" box-sizing="border-box" background-color=wizardPanel box-shadow="0 0 0 1px light-dark(#0000000d, #ffffff0d)" if data.setupReady and not back column gap="0.5rem" text "Choose your shortcut" font-size="0.875rem" font-weight=500 line-height="1.25rem" class=SnapInk role="heading" aria-level=3 diff --git a/examples/t3-code/src/usage/usage-pooled.contract b/examples/t3-code/src/usage/usage-pooled.contract index dde00dad6b..173ada5c88 100644 --- a/examples/t3-code/src/usage/usage-pooled.contract +++ b/examples/t3-code/src/usage/usage-pooled.contract @@ -184,9 +184,11 @@ component PoolWindowCard text window.label font-size="0.875rem" line-height="1.25rem" font-weight=500 color="light-dark(#27272a, #f5f5f5)" row align-items="baseline" gap="0.5rem" text `${window.remaining}%` font-size="1.875rem" line-height="2.25rem" font-weight=600 font-variant-numeric="tabular-nums" color="light-dark(#27272a, #f5f5f5)" testId=`usage-window-left-${pool.key}-${window.key}` - text "left" font-size="0.875rem" line-height="1.25rem" color="light-dark(#71717b, #818181)" - if window.pace != "" - PaceIcon(pace=window.pace, label=window.paceLabel) + // "left" and the pace icon centred on each other, the pair on the percentage's baseline. + row align-items="center" gap="0.5rem" + text "left" font-size="0.875rem" line-height="1.25rem" color="light-dark(#71717b, #818181)" + if window.pace != "" + PaceIcon(pace=window.pace, label=window.paceLabel) if window.refill != "" text window.refill font-size="0.75rem" line-height="1rem" font-weight=500 font-variant-numeric="tabular-nums" color="light-dark(#27272a, #f5f5f5)" testId=`usage-window-refill-${pool.key}-${window.key}` // PoolBar: equal-width segments, one per account; narrow, a legend under the strip. diff --git a/examples/t3-code/tests/connections/connections.test.ts b/examples/t3-code/tests/connections/connections.test.ts index e5db311696..8e0d17c567 100644 --- a/examples/t3-code/tests/connections/connections.test.ts +++ b/examples/t3-code/tests/connections/connections.test.ts @@ -2,7 +2,7 @@ import { afterEach, test, expect } from 'bun:test'; afterEach(resetPrimary); import { connectionsProjection, connectionsPage, environmentStatus, runConnectionOp, savedStatus, statusText, versionMismatch, compareSemver, - machineKind, decodePrefs, loadPreference, summarizeLoad, summarizeRouting, routingKey, type ConnectionHost } from '../../src/connections/connections'; + environmentMcpUrl, machineKind, decodePrefs, loadPreference, summarizeLoad, summarizeRouting, routingKey, type ConnectionHost } from '../../src/connections/connections'; import { fleet, phaseOf, environmentKey, type FleetEntry } from '../../src/connections/settings-b-fleet'; import { primaryAt, resetPrimary } from '../support/local-primary-fixture'; import { initialShell } from '../../src/client/domain'; @@ -205,3 +205,15 @@ test('trace IDs copy, preferences persist on this device, and icon writes need a await expect(runConnectionOp(native, 'environment-icon', environmentKey('https://remote.example.com', 'env-b'), 'cloud', true)).rejects.toThrow('Connect to this environment to change its icon.'); await expect(runConnectionOp(native, 'environment-icon', environmentKey('https://remote.example.com', 'env-b'), 'toaster', true)).rejects.toThrow('Choose one of the listed icons.'); }); + +test('MCP URLs prefer the connected direct route and skip SSH forwards', async () => { + const source = { origin: 'http://127.0.0.1:16473', phase: 'connected' as const, activeRouteId: 'lan', routes: [ + { id: 'ssh', origin: 'http://127.0.0.1:16555', kind: 'ssh' }, { id: 'public', origin: 'https://example.com/base?token=secret#hash' }, { id: 'lan', origin: 'http://192.168.1.20:3773' }, + ] }; + expect(environmentMcpUrl(source)).toBe('http://192.168.1.20:3773/mcp'); + expect(environmentMcpUrl({ ...source, phase: 'error' })).toBe('https://example.com/mcp'); + expect(environmentMcpUrl({ ...source, routes: [source.routes[0]!] })).toBe(''); + const native = new FakeTransport(); + await runConnectionOp(native, 'environment-mcp', '', 'http://192.168.1.20:3773/mcp'); + expect(native.copied).toEqual(['http://192.168.1.20:3773/mcp']); +}); diff --git a/examples/t3-code/tests/pages/pages-welcome.test.ts b/examples/t3-code/tests/pages/pages-welcome.test.ts index b00ba83c0e..72145b0a28 100644 --- a/examples/t3-code/tests/pages/pages-welcome.test.ts +++ b/examples/t3-code/tests/pages/pages-welcome.test.ts @@ -3,7 +3,7 @@ import { noPrimary, resetPrimary } from '../support/local-primary-fixture'; // These cases are the hosted rules (resolveHostedFirstRunDecision): no embedded server runs on this Mac. beforeEach(noPrimary); afterEach(resetPrimary); -import { hostedDecision, transition, agentRows, providerState, decodeCandidates, recentCandidates, welcomeView, welcomeLocal, importProjects, welcomeShowing } from '../../src/pages/pages-welcome'; +import { splitOnboardingProjectPath, hostedDecision, transition, agentRows, providerState, decodeCandidates, recentCandidates, welcomeView, welcomeLocal, importProjects, welcomeShowing } from '../../src/pages/pages-welcome'; import { pagesPrefs } from '../../src/pages/pages-prefs'; import type { T3Client } from '../../src/client/client'; import type { Native } from '../../src/client/protocol'; @@ -159,3 +159,9 @@ test('the agents step holds one sign-in subscription per stream, and the project expect(keys('subscribe')).toEqual(['provider-auth:codex', 'provider-install:codex']); expect(keys('unsubscribe')).toEqual(['provider-auth:codex', 'provider-install:codex']); }); + +test('project paths keep sibling checkout names visible on Unix and Windows', () => { + expect(splitOnboardingProjectPath('/Users/me/Projects/app/')).toEqual({ name: 'app', parent: '/Users/me/Projects/' }); + expect(splitOnboardingProjectPath('C:\\Users\\me\\app\\')).toEqual({ name: 'app', parent: 'C:\\Users\\me\\' }); + for (const path of ['/', 'C:\\', '//server/share/']) expect(splitOnboardingProjectPath(path)).toEqual({ name: path, parent: '' }); +}); diff --git a/examples/t3-code/tests/pages/welcome-pairing.test.ts b/examples/t3-code/tests/pages/welcome-pairing.test.ts index ba2080ef7c..b709303a7c 100644 --- a/examples/t3-code/tests/pages/welcome-pairing.test.ts +++ b/examples/t3-code/tests/pages/welcome-pairing.test.ts @@ -29,7 +29,20 @@ test('a pairing that never connected is not listed as a computer that is still c const live = wizardClient('connected'); expect((await welcomeView(live, native([]), { step: 'connect', now: 1 })).computers.map(computer => computer.status)).toEqual(['Connected']); (live as unknown as { connection: string }).connection = 'reconnecting'; - expect((await welcomeView(live, native([]), { step: 'connect', now: 1 })).computers.map(computer => computer.status)).toEqual(['Connecting…']); + expect((await welcomeView(live, native([]), { step: 'connect', now: 1 })).computers.map(computer => computer.status)).toEqual(['Offline']); +}); + +test('Continue skips selected offline and switched-off computers beside a connected one', async () => { + const client = wizardClient('connected'); + await welcomeView(client, native([]), { step: 'connect', now: 1 }); + const saved = [ + { origin: 'http://127.0.0.1:16442', environmentId: 'offline', label: 'Offline Mac', enabled: true }, + { origin: 'http://127.0.0.1:16443', environmentId: 'off', label: 'Off Mac', enabled: false }, + ]; + const view = await welcomeView(client, native(saved), { step: 'connect', now: 1 }); + expect(view.ready).toBe(true); + expect(view.computers.map(computer => computer.status)).toEqual(['Connected', 'Offline', 'Switched off']); + expect(view.skipped).toBe('Not connected, so setup skips Offline Mac, Off Mac. You can set them up later from Settings.'); }); test('copying the pairing command flips the button to Check without a toast', async () => { diff --git a/examples/t3-code/tests/settings/keybinding-defaults.test.ts b/examples/t3-code/tests/settings/keybinding-defaults.test.ts index 601e64927c..76a6e5b791 100644 --- a/examples/t3-code/tests/settings/keybinding-defaults.test.ts +++ b/examples/t3-code/tests/settings/keybinding-defaults.test.ts @@ -1,8 +1,8 @@ // Keybindings follow T3 f90b77d809's defaults (upstream 5c0429b157 // mod+alt+enter sends and opens a new thread; 263097a8da mod+enter starts in the background). import { describe, expect, test } from 'bun:test'; -import { keybindingCommands, keybindingDefaults } from '../../src/settings/keybinding-settings'; -import { buildRows, commandLabel, commandOptions } from '../../src/settings/keybinding-view'; +import { keybindingCommands, keybindingDefaults, keybindingSettings } from '../../src/settings/keybinding-settings'; +import { buildRows, commandLabel, commandOptions, groupRows } from '../../src/settings/keybinding-view'; import { commandLabel as searchLabel } from '../../src/settings/settings-search'; import { contractFns } from '../support/contract-fn-fixture'; import { contractComponent } from '../support/contract-action-fixture'; @@ -159,3 +159,22 @@ describe('the Settings capture field (settings-keybindings.contract CaptureField expect(drawn('testId=`keybinding-capture-${rowId}`', false, false)).toBe(true); }); }); + +describe('keybinding areas and disconnected settings', () => { + test('area sections preserve row order and mark their own first row', () => { + const rows = buildRows(['composer.sendAlternate', 'preview.focusUrl', 'sidebar.toggle', 'chat.new', 'terminal.toggle', 'theme.select', 'script.test.run', 'usage.cost'] + .map(command => ({ command, shortcut: { key: 'x', modKey: true } })), ''); + const groups = groupRows(rows); + expect(groups.map(group => group.title)).toEqual(['Navigation', 'Threads', 'Composer', 'Terminal', 'Preview & diff', 'Appearance', 'Project scripts', 'Other']); + expect(groups.every(group => group.rows[0]?.first)).toBe(true); + expect(commandLabel('preview.focusUrl')).toBe('Preview: Focus URL'); + expect(searchLabel('preview.focusUrl')).toBe('Preview: Focus URL'); + }); + test('no environment still lists defaults and explains why the list is read-only', async () => { + const page = await keybindingSettings({ connection: 'disconnected' } as never, null, '', '', true); + expect(page).toMatchObject({ available: true, writable: false, notice: 'Connect an environment to change keybindings.', error: '' }); + expect(page.groups.length).toBeGreaterThan(0); + expect(page.rows.some(row => row.command === 'threadPanel.toggle' && row.key === 'mod+shift+b')).toBe(true); + expect(keybindingDefaults.filter(rule => /^thread.jump/.test(rule.command)).every(rule => rule.when === '!modelPickerOpen && isDesktop')).toBe(true); + }); +}); diff --git a/examples/t3-code/tests/settings/scoped-settings-plan.test.ts b/examples/t3-code/tests/settings/scoped-settings-plan.test.ts index 499149c523..f05eab7fd7 100644 --- a/examples/t3-code/tests/settings/scoped-settings-plan.test.ts +++ b/examples/t3-code/tests/settings/scoped-settings-plan.test.ts @@ -1,4 +1,4 @@ -// Ported from T3 Code 1e2ecbd975 scopedSettings.test.ts (MIT, LICENSE-T3), original names. +// Ported from T3 Code 9c161462c9 scopedSettings.test.ts (MIT, LICENSE-T3), original names. // DEFAULT_SERVER_SETTINGS is the subset these cases read; applyServerSettingsPatch is the // one-level merge the cleanup case needs (storageCleanup merged, worktreeCleanup replaced). import { describe, expect, it, mock } from 'bun:test'; @@ -100,8 +100,8 @@ describe('scoped settings writes', () => { const plan = planScopedSettingsPatch(project, [laptop, withExisting], { defaultAutoPull: true }); expect(plan.unavailableReason).toBeNull(); expect(plan.serverWrites).toEqual([ - { environmentId: server.environmentId, label: server.label, patch: { projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false, defaultAutoPull: true } } } }, - { environmentId: laptop.environmentId, label: laptop.label, patch: { projectSettingsOverrides: { [laptopProjectId]: { defaultAutoPull: true } } } }, + { environmentId: server.environmentId, label: server.label, previous: expect.anything(), patch: { projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false, defaultAutoPull: true } } } }, + { environmentId: laptop.environmentId, label: laptop.label, previous: expect.anything(), patch: { projectSettingsOverrides: { [laptopProjectId]: { defaultAutoPull: true } } } }, ]); expect(planScopedSettingsPatch(checkout, [laptop, server], { defaultAutoPull: true })).toMatchObject({ serverWrites: [{ environmentId: server.environmentId }] }); }); @@ -132,7 +132,7 @@ describe('scoped settings writes', () => { it('clears overrides per member and removes an emptied entry', () => { const withOverrides = environment('Server', { settings: { projectSettingsOverrides: { [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false } } } }); const plan = planScopedSettingsClear(checkout, [laptop, withOverrides], ['defaultAutoPull']); - expect(plan.serverWrites).toEqual([{ environmentId: server.environmentId, label: server.label, patch: { projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } } } }]); + expect(plan.serverWrites).toEqual([{ environmentId: server.environmentId, label: server.label, previous: expect.anything(), patch: { projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } } } }]); expect(planScopedSettingsClear(checkout, [laptop, withOverrides], ['defaultAutoPull', 'enableAgentBrowserAccess']).serverWrites[0]?.patch).toEqual({ projectSettingsOverrides: { [projectId]: null } }); }); it('never substitutes an environment-default write for an invalid scope', () => { @@ -155,7 +155,40 @@ describe('scoped settings writes', () => { expect(persistServer).toHaveBeenCalledTimes(4); // useRunScopedPlan's toast for this result. expect(scopedPlanNotice({ unavailableReason: null }, result)).toEqual({ kind: 'error', title: 'Setting saved on some environments', - description: 'Could not update Server, Third. The other selected environments saved the change.' }); + description: 'Could not save on Server: The save failed. Try reconnecting and saving again.\nCould not save on Third: Disconnected during save\nSaved on Laptop, Fourth.' }); + }); + + describe('an environment that saves but drops keys it does not know', () => { + // Laptop knows every key; Server is an older build that keeps its settings as they were. + const upgradedLaptop = (saved: Obj) => mock(async ({ environmentId }: { environmentId: string }) => ({ + _tag: 'Success' as const, value: { ...DEFAULT_SERVER_SETTINGS, ...(environmentId === laptop.environmentId ? saved : {}) } })); + const outcome = async (plan: ReturnType, persistServer: ReturnType) => { + const result = await persistScopedSettingsPatch(plan, persistServer, () => {}); + return { saved: result.savedEnvironments.map(({ label }) => label), failed: result.failedEnvironments.map(({ label, message }) => ({ label, message })) }; + }; + const unsupported = [{ label: server.label, message: 'This environment does not support this setting. Update T3 Code on it.' }]; + it('reports a dropped toggle', async () => { + expect(await outcome(planScopedSettingsPatch(all, environments, { enableAgentDeviceAccess: true }), upgradedLaptop({ enableAgentDeviceAccess: true }))) + .toEqual({ saved: [laptop.label], failed: unsupported }); + }); + it('reports dropped text but accepts the server trimming it', async () => { + expect(await outcome(planScopedSettingsPatch(all, environments, { branchNameInstructions: ' Read tests ' }), upgradedLaptop({ branchNameInstructions: 'Read tests' }))) + .toEqual({ saved: [laptop.label], failed: unsupported }); + }); + it('reports a dropped key inside a project override', async () => { + expect(await outcome(planScopedSettingsPatch(project, environments, { defaultAutoPull: true }), + upgradedLaptop({ projectSettingsOverrides: { [laptopProjectId]: { defaultAutoPull: true } } }))).toEqual({ saved: [laptop.label], failed: unsupported }); + }); + it('reports a nested key dropped beside one that saved', async () => { + const patch = { defaultAutoPull: true, enableAgentBrowserAccess: false }; + const persistServer = mock(async ({ environmentId }: { environmentId: string }) => ({ _tag: 'Success' as const, + value: { ...DEFAULT_SERVER_SETTINGS, projectSettingsOverrides: environmentId === laptop.environmentId ? { [laptopProjectId]: patch } : { [projectId]: { defaultAutoPull: true } } } })); + expect(await outcome(planScopedSettingsPatch(project, environments, patch), persistServer)).toEqual({ saved: [laptop.label], failed: unsupported }); + }); + it('accepts a write that matches what the environment already had', async () => { + expect(await outcome(planScopedSettingsPatch(all, environments, { enableAgentDeviceAccess: false }), upgradedLaptop({}))) + .toEqual({ saved: [laptop.label, server.label], failed: [] }); + }); }); }); @@ -188,7 +221,7 @@ describe('project overrides at environment scope', () => { it('clears only those keys and drops entries that become empty', () => { const plan = planProjectOverridesClear([env(laptopEnv, { [fleet]: { defaultAutoPull: true, defaultThreadEnvMode: 'local' }, [t3]: { defaultAutoPull: true } })], [{ environmentId: laptopEnv, projectId: fleet }, { environmentId: laptopEnv, projectId: t3 }], ['defaultAutoPull']); - expect(plan.serverWrites).toEqual([{ environmentId: laptopEnv, label: laptopEnv, patch: { projectSettingsOverrides: { [fleet]: { defaultThreadEnvMode: 'local' }, [t3]: null } } }]); + expect(plan.serverWrites).toEqual([{ environmentId: laptopEnv, label: laptopEnv, previous: expect.anything(), patch: { projectSettingsOverrides: { [fleet]: { defaultThreadEnvMode: 'local' }, [t3]: null } } }]); }); }); diff --git a/examples/t3-code/tests/settings/settings-core.test.ts b/examples/t3-code/tests/settings/settings-core.test.ts index 741388b4b0..3e1a468104 100644 --- a/examples/t3-code/tests/settings/settings-core.test.ts +++ b/examples/t3-code/tests/settings/settings-core.test.ts @@ -350,6 +350,10 @@ describe('navigation and search', () => { expect(searchSettings('wrap', context)[0]!.id).toBe('word-wrap'); expect(searchSettings('auto settle', { ...context, autoSettle: false }).map(item => item.id)).not.toContain('auto-settle-inactive-threads'); expect(searchSettings('tailscale', context)).toEqual([]); + // The About rows (472ea7ec5f): Version by its update words, and Update track, both on General. + expect(searchSettings('check for updates', context).map(item => item.id)).toContain('app-version'); + expect(searchSettings('update track', context)).toEqual([expect.objectContaining({ id: 'update-track', title: 'Update track', route: 'general' })]); + expect(searchSettings('nightly', context).map(item => [item.id, item.target])).toContainEqual(['update-track', 'update-track']); const result = settingsNavigation('send shortcut', context); expect([result.firstRoute, result.firstTarget, result.items[0]!.section]).toEqual(['general', 'setting-send-shortcut', 'General']); expect(scopeAvailable('environment-defaults', 'project')).toBe(false); diff --git a/examples/t3-code/tests/settings/settings-mobile-beta.test.ts b/examples/t3-code/tests/settings/settings-mobile-beta.test.ts index 5b0eb819d3..7807e9720f 100644 --- a/examples/t3-code/tests/settings/settings-mobile-beta.test.ts +++ b/examples/t3-code/tests/settings/settings-mobile-beta.test.ts @@ -1,12 +1,11 @@ -// General → About → Mobile app (upstream f1dcd93931 NightlyMobileBeta.tsx). +// General → About → Mobile app (T3 Code 9c161462c9 MobileAppSettingsRow.tsx). import { describe, expect, test } from 'bun:test'; import type { T3Client } from '../../src/client/client'; -import type { Native } from '../../src/client/protocol'; import { encodeQr, qrCells } from '../../src/settings/settings-qr'; import { aboutRows, rememberDelivery } from '../../src/settings/settings-a-about'; -import { CLIENT_VERSION } from '../../src/connections/connections'; -import { ANDROID_BETA_GROUP_URL, ANDROID_PLAY_TESTING_URL, IOS_TESTFLIGHT_URL, isNightlyVersion, mobileBetaCommand, mobileBetaMarks, showMobileBeta } from '../../src/settings/settings-mobile-beta'; +import { APP_STORE_URL, GOOGLE_PLAY_URL, mobileAppRow } from '../../src/settings/settings-mobile-beta'; +const IOS_TESTFLIGHT_URL = 'https://testflight.apple.com/join/XgaxaRtd'; // Module rows of the reference's QrCode.encodeText(IOS_TESTFLIGHT_URL, MEDIUM) (version 3, mask 2), // as packages/shared/src/qrCode.ts at f90b77d809 draws it ('#' dark). const TESTFLIGHT_TOP = ['#######..#..#...#####.#######', '#.....#..##....##.....#.....#', '#.###.#.##.#..#.#.#...#.###.#']; @@ -19,10 +18,6 @@ describe('beta links as QR codes', () => { // Finder patterns at three corners, timing row between them. expect(modules[6]!.slice(8, 21).map(dark => dark ? 1 : 0).join('')).toBe('1010101010101'); }); - test('the Android links are version 4 (33 modules)', () => { - expect(encodeQr(ANDROID_BETA_GROUP_URL, 'M').length).toBe(33); - expect(encodeQr(ANDROID_PLAY_TESTING_URL, 'M').length).toBe(33); - }); test('cells are dark runs inside a 128-point square with one light module of margin', () => { const cells = qrCells(IOS_TESTFLIGHT_URL, 128, 1, 'M'), unit = 128 / 31; expect(cells.length).toBeGreaterThan(29); @@ -39,36 +34,20 @@ describe('beta links as QR codes', () => { }); describe('Mobile app row', () => { - test('IS_NIGHTLY_BUILD reads the first prerelease identifier', () => { - expect(isNightlyVersion('0.0.46-nightly.20261004.1')).toBe(true); - expect(isNightlyVersion('0.0.45')).toBe(false); - expect(isNightlyVersion('0.0.46-beta.1')).toBe(false); - expect(showMobileBeta('0.0.45', 'nightly')).toBe(true); - expect(showMobileBeta('0.0.45', 'latest')).toBe(false); - }); - test('About lists Mobile app after the version rows: this client is a Nightly build', () => { + test('both stable and nightly show store links after the version rows', () => { const client = {} as T3Client; - rememberDelivery(client, 'embedded', false); - expect(isNightlyVersion(CLIENT_VERSION)).toBe(true); - expect(aboutRows(client, CLIENT_VERSION).map(row => row.title)).toEqual(['Version', 'Update track', 'Mobile app']); - expect(aboutRows(client, CLIENT_VERSION)[0]!.value).toBe('0.0.46-nightly.20261004.1'); - rememberDelivery(client, 'nightly/macos-arm64', false); - const mobile = aboutRows(client, CLIENT_VERSION).at(-1)!; - expect(mobile).toMatchObject({ id: 'nightly-mobile-beta', kind: 'mobile-beta', description: 'Nightly needs the beta app. The App Store and Google Play versions cannot connect.' }); - expect(mobile.qr.map(mark => [mark.id, mark.caption, mark.copied])).toEqual([['ios', '', 0], ['android-group', '1. Join the group', 0], ['android-play', '2. Become a tester', 0]]); + for (const stream of ['embedded', 'nightly/macos-arm64', 'latest/macos-arm64']) { + rememberDelivery(client, stream, false); + const rows = aboutRows(client, '0.0.45'); + expect(rows.map(row => row.title)).toEqual(['Version', 'Update track', 'Mobile app']); + expect(rows.at(-1)).toMatchObject({ id: 'mobile-app', kind: 'mobile-app', description: 'Connect to your server from your phone or tablet.' }); + } }); - test('Copy link writes only a beta link; each copy replays Copied', async () => { - const copied: string[] = []; - const client = { restAccess: () => ({ call: async (request: { op: string; text: string }) => { copied.push(request.text); return { copied: true }; } }) } as unknown as T3Client; - const native = { available: true } as unknown as Native; - expect(mobileBetaMarks(client).map(mark => mark.copied)).toEqual([0, 0, 0]); - await mobileBetaCommand(client, native, 'copy', IOS_TESTFLIGHT_URL); - expect(copied).toEqual([IOS_TESTFLIGHT_URL]); - expect(mobileBetaMarks(client).map(mark => mark.copied)).toEqual([1, 0, 0]); - await mobileBetaCommand(client, native, 'copy', IOS_TESTFLIGHT_URL); - await mobileBetaCommand(client, native, 'copy', ANDROID_PLAY_TESTING_URL); - expect(mobileBetaMarks(client).map(mark => mark.copied)).toEqual([2, 0, 1]); - await expect(mobileBetaCommand(client, native, 'copy', 'https://example.com')).rejects.toThrow('Unsupported mobile app action.'); - expect(copied.length).toBe(3); + test('the QR symbols encode exactly the store links their Open buttons use', () => { + const row = mobileAppRow(); + expect(row.qr.map(mark => [mark.id, mark.label, mark.caption, mark.url])).toEqual([ + ['ios', 'iPhone and iPad', 'App Store', APP_STORE_URL], ['android', 'Android', 'Google Play', GOOGLE_PLAY_URL], + ]); + for (const mark of row.qr) expect(mark.cells).toEqual(qrCells(mark.url, 128, 1, 'M')); }); }); diff --git a/examples/t3-code/tests/settings/settings-pages-reads.test.ts b/examples/t3-code/tests/settings/settings-pages-reads.test.ts index a66bcc9247..64c38f5792 100644 --- a/examples/t3-code/tests/settings/settings-pages-reads.test.ts +++ b/examples/t3-code/tests/settings/settings-pages-reads.test.ts @@ -132,11 +132,12 @@ describe('a change still reaches each page, without a read', () => { test('Keybindings show a keybindingsUpdated list', async () => { const { server, ask, wake } = await launched(); - expect((await ask(PAGES.Keybindings!)).view).toMatchObject({ count: '1 binding', path: '/lane/keybindings.json' }); + expect((await ask(PAGES.Keybindings!)).view).toMatchObject({ rows: [expect.anything()], path: '/lane/keybindings.json' }); server.config.keybindings = [...arr(server.config.keybindings), { command: 'chat.new', shortcut: { key: 'n', modKey: true } }]; server.emit('config', { type: 'keybindingsUpdated', payload: { keybindings: server.config.keybindings, issues: [] } }); const view = await wake(PAGES.Keybindings!); - expect({ sent: view.sent, count: view.view.count }).toEqual({ sent: [], count: '2 bindings' }); + expect(view.sent).toEqual([]); + expect(arr(view.view.rows)).toHaveLength(2); }); test('Scheduled tasks show the live list and the editor a new default model; the editor reads its refs once', async () => { diff --git a/examples/t3-code/tests/settings/settings-rest.test.ts b/examples/t3-code/tests/settings/settings-rest.test.ts index efda32664a..de671946f9 100644 --- a/examples/t3-code/tests/settings/settings-rest.test.ts +++ b/examples/t3-code/tests/settings/settings-rest.test.ts @@ -223,6 +223,9 @@ describe('source control and integrations (scoped rows)', () => { // The controls' accessible names and SettingResetButton labels of the reference. expect(rows.repositories.map(row => [row.control, row.resetLabel])).toEqual([['Default automatic pull', 'default automatic pull'], ['Default pull request merge method', 'default merge method']]); expect(rows.text.map(row => row.resetLabel)).toEqual(['branch naming', 'branch prefix', 'source control writing style', 'change request templates', '']); + const semantic = sourceControlRows({ ...settings, branchNamingMode: 'semantic' }, '', 'Mac', [], true).text; + expect(semantic[0]).toMatchObject({ title: 'Worktree branch naming', status: 'The model chooses a prefix that describes the work, such as feat/add-search, fix/login-timeout, or refactor/auth.' }); + expect(semantic.some(row => row.kind === 'note')).toBe(false); expect(sourceControlRows({ ...settings, branchNamingMode: 'custom' }, '', 'Mac', [], true).text[1]).toMatchObject({ title: 'Branch naming instructions', resetLabel: 'branch naming instructions' }); expect(fetchInterval({})).toMatchObject({ seconds: '30', canReset: false }); expect(fetchIntervalPatch({}, '45')).toEqual({ backgroundActivity: { schemaVersion: 1, profile: 'custom', baseProfile: 'balanced', overrides: { automaticGitFetchInterval: 45000 } } }); diff --git a/examples/t3-code/tests/settings/settings-scope-sources.test.ts b/examples/t3-code/tests/settings/settings-scope-sources.test.ts index 958a9a928d..824097652c 100644 --- a/examples/t3-code/tests/settings/settings-scope-sources.test.ts +++ b/examples/t3-code/tests/settings/settings-scope-sources.test.ts @@ -96,7 +96,7 @@ describe('settings scope across environments', () => { await applyCoreSetting(as(client), nativeFor(new Set([B])), 'provider-update-checks:|||', 'false'); expect(client.writes.length).toBe(1); expect(toasts(as(client)).at(-1)).toMatchObject({ kind: 'error', title: 'Setting saved on some environments', - description: 'Could not update Server. The other selected environments saved the change.' }); + description: 'Could not save on Server: Disconnected during save\nSaved on Studio.' }); // An offline named environment refuses before any request. fleet.entries.set(C, entry(C, 'env-c', 'Offline box', {}, 'error')); await applyCoreSetting(as(client), nativeFor(), 'provider-update-checks:|env-c||', 'true'); diff --git a/examples/t3-code/tests/shared/dialog-trap.test.ts b/examples/t3-code/tests/shared/dialog-trap.test.ts index 12caf3ecc2..9213097464 100644 --- a/examples/t3-code/tests/shared/dialog-trap.test.ts +++ b/examples/t3-code/tests/shared/dialog-trap.test.ts @@ -109,8 +109,7 @@ const NOT_MODAL: Record = { 'settings-a-hosts.contract "Device tools"': 'Popover', 'settings-appearance-editor.contract "theme-editor"': 'ThemeEditorPanel, a floating panel that is no modal', 'settings-b-accent.contract "provider-accent-panel"': 'Popover', - 'settings-mobile-beta.contract "TestFlight beta"': 'NightlyMobileBeta Popover', - 'settings-mobile-beta.contract "Google Play beta"': 'NightlyMobileBeta Popover', + 'settings-mobile-beta.contract mark.caption': 'MobileAppSettingsRow Popover', 'settings-rows.contract `setting-inheritance-${row.id}-popover`': 'Popover', 'settings-source-control.contract `device-versions-${tool.kind}-popover`': 'Popover', 'shell-details.contract "thread-details"': 'Popover', diff --git a/examples/t3-code/tests/shell/shell.test.ts b/examples/t3-code/tests/shell/shell.test.ts index 9565748eb7..fa884a6e83 100644 --- a/examples/t3-code/tests/shell/shell.test.ts +++ b/examples/t3-code/tests/shell/shell.test.ts @@ -365,6 +365,8 @@ describe('shell commands and routed failures', () => { test('archive refuses a running thread; the editor uses its RPC', async () => { const running = client({ shell: { threads: [{ id: 't1', title: 'Busy', status: 'running', activeRunId: 'r' }], projects: [] }, config: { providers: [{ driver: 'codex', instanceId: 'codex' }] } }); await expect(shellCommand(running.value, {} as Native, storage, 'archive', 't1', '')).rejects.toThrow('Stop the running turn'); + await shellCommand(running.value, {} as Native, storage, 'select-editor', '/work/p1', 'zed'); + expect(running.requested).toEqual([]); await shellCommand(running.value, {} as Native, storage, 'open-editor', '/work/p1', 'cursor'); expect(running.requested).toEqual([{ method: 'shell.openInEditor', cwd: '/work/p1', editor: 'cursor' }]); }); From 36ec227a37d01e9478b42d3805ba3065d09a9c86 Mon Sep 17 00:00:00 2001 From: Daehyeon Mun Date: Sun, 18 Oct 2026 23:35:13 +0900 Subject: [PATCH 2/3] Remove obsolete beta mobile notice after restoring store links Co-Authored-By: Codex --- examples/t3-code/src/shell/shell-nightly.ts | 39 ------------------- examples/t3-code/src/shell/shell.ts | 2 - .../tests/details/details-rows.test.ts | 21 +--------- .../tests/shell/relaunch-persistence.test.ts | 20 +++------- 4 files changed, 7 insertions(+), 75 deletions(-) delete mode 100644 examples/t3-code/src/shell/shell-nightly.ts diff --git a/examples/t3-code/src/shell/shell-nightly.ts b/examples/t3-code/src/shell/shell-nightly.ts deleted file mode 100644 index b60f5e8294..0000000000 --- a/examples/t3-code/src/shell/shell-nightly.ts +++ /dev/null @@ -1,39 +0,0 @@ -// NightlyMobileBetaNotice (MIT reference, see LICENSE-T3: apps/web/src/ -// components/NightlyMobileBeta.tsx, upstream f1dcd93): a one-time Nightly toast -// pointing to the beta mobile app. Any close (the corner orb, Dismiss, or the -// action) hides it for good on this client; the action opens Settings › -// General at the "Mobile app" row (`#nightly-mobile-beta`). -import type { T3Client } from '../client/client'; -import { pushToast } from './toast'; -import { shellPrefs } from './shell-prefs'; - -/** This client is the Nightly build: its sidebar carries the Nightly artwork and it pairs with Nightly servers. */ -export const IS_NIGHTLY_BUILD = true; -export const NIGHTLY_ROW_ID = 'nightly-mobile-beta'; -/** The row's first control (settings-mobile-beta.contract): Settings scrolls it to the middle and focuses it, standing in for `#nightly-mobile-beta`. */ -export const NIGHTLY_ROW_TARGET = 'mobile-beta-ios'; -export const NIGHTLY_NOTICE = { - title: 'Nightly needs the beta mobile app', - description: 'Nightly uses the new orchestrator. The App Store and Google Play versions of T3 Code cannot connect to it.', -}; - -// Guards against a second toast for one window (the reference's module-level noticeShown). -const shown = new WeakSet(); - -/** - * Shown once the saved preferences are read (the reference reads its storage - * flag synchronously on mount), unless dismissed before. Returns the toast id, or 0. - */ -export function nightlyMobileBetaNotice(client: T3Client, preferencesLoaded: boolean, nightly = IS_NIGHTLY_BUILD): number { - if (!nightly || !preferencesLoaded || shown.has(client)) return 0; - shown.add(client); - const prefs = shellPrefs(client); - if (prefs.nightlyNoticeDismissed) return 0; - return pushToast(client, { - kind: 'info', title: NIGHTLY_NOTICE.title, description: NIGHTLY_NOTICE.description, timeoutMs: 0, - leading: 'smartphone:foreground', stacked: true, hideCopy: true, closeOnAction: true, - action: { label: 'Get the beta app', op: 'ui:settings', id: 'general', value: NIGHTLY_ROW_TARGET }, - secondary: { label: 'Dismiss', op: 'dismiss' }, secondaryVariant: 'ghost', - onClose: () => { shellPrefs(client).nightlyNoticeDismissed = true; }, - }); -} diff --git a/examples/t3-code/src/shell/shell.ts b/examples/t3-code/src/shell/shell.ts index 2ef92cde73..91e246c908 100644 --- a/examples/t3-code/src/shell/shell.ts +++ b/examples/t3-code/src/shell/shell.ts @@ -19,7 +19,6 @@ import { diffNotGit } from '../diff/diff'; import { threadNotifications, providerUpdates, nativeNotifyStatus, reportWindowFacts, type NotifyStatus } from './shell-notify'; import { slowRequests, tracking } from './shell-slow'; import { settleLiveTraces } from '../client/protocol-reader'; // answered requests whose answer was let go end on the next shell read -import { nightlyMobileBetaNotice } from './shell-nightly'; import { pullRequestPanelTarget } from '../panel/shell-pr'; import { composerFileView } from '../composer/composer-chip-file'; import { availability, panelView, surfaceStore } from '../panel/panel-surfaces'; // the tabbed Files / Linked pull requests / Device surfaces @@ -269,7 +268,6 @@ export async function shellView(client: T3Client, native: Native | null | undefi // A closed toast's onClose may change saved preferences (shell-prefs.ts): write them now. if (applyDismissals(client, dismissed)) await client.savePreferences(storage); const state = shellState(client); - nightlyMobileBetaNotice(client, client.preferencesLoaded); // shell-nightly.ts if (native?.available) { native.watch('t3.notify'); state.status = await nativeNotifyStatus(native, state.status, page.focused); diff --git a/examples/t3-code/tests/details/details-rows.test.ts b/examples/t3-code/tests/details/details-rows.test.ts index eb26007375..19cc95f00c 100644 --- a/examples/t3-code/tests/details/details-rows.test.ts +++ b/examples/t3-code/tests/details/details-rows.test.ts @@ -4,7 +4,6 @@ afterEach(resetPrimary); import { environmentIndicator, shellDetails } from '../../src/details/shell-details'; import { lineageView, statusLabel, formatElapsed, liveSubagent, latestMergeBackRun } from '../../src/details/shell-lineage'; import { adoptShellPrefs, inlineOpen, shellPrefs, toggleInline } from '../../src/shell/shell-prefs'; -import { nightlyMobileBetaNotice, NIGHTLY_NOTICE } from '../../src/shell/shell-nightly'; import { pushToast, toasts } from '../../src/shell/toast'; import { applyDismissals, toastViews } from '../../src/shell/shell'; import { applyStatusEvent, refreshVcsOnFocus, vcsStatusEvent, VCS_STATUS_KEY } from '../../src/details/shell-vcs'; @@ -94,26 +93,8 @@ describe('shell preferences', () => { }); }); -describe('Nightly beta-app notice (upstream f1dcd93931)', () => { +describe('toast action dismissal', () => { const client = () => ({ local: {}, environmentId: 'env' }) as unknown as T3Client; - test('one stacked toast with a ghost Dismiss and the settings action; any close remembers it', () => { - const owner = client(); - expect(nightlyMobileBetaNotice(owner, false)).toBe(0); - const id = nightlyMobileBetaNotice(owner, true); - expect(id).toBeGreaterThan(0); - expect(nightlyMobileBetaNotice(owner, true)).toBe(0); - const [view] = toastViews(toasts(owner)); - expect(view).toMatchObject({ title: NIGHTLY_NOTICE.title, description: NIGHTLY_NOTICE.description, icon: 'smartphone', iconColor: 'light-dark(#27272a, #f5f5f5)', - stacked: true, copyText: '', actionLabel: 'Get the beta app', actionOp: 'ui:settings', actionId: 'general', actionValue: 'mobile-beta-ios', secondaryLabel: 'Dismiss', secondaryOp: 'dismiss', secondaryGhost: true }); - // The action closes it (`a`) and, unlike other toasts, that close also runs onClose. - expect(applyDismissals(owner, `a${id}`)).toBe(true); - expect(toasts(owner)).toHaveLength(0); - expect(shellPrefs(owner).nightlyNoticeDismissed).toBe(true); - const other = client(); - shellPrefs(other).nightlyNoticeDismissed = true; - expect(nightlyMobileBetaNotice(other, true)).toBe(0); - expect(nightlyMobileBetaNotice(client(), true, false)).toBe(0); - }); test('an ordinary action close leaves onClose alone', () => { const owner = client(); let closed = 0; diff --git a/examples/t3-code/tests/shell/relaunch-persistence.test.ts b/examples/t3-code/tests/shell/relaunch-persistence.test.ts index 6e761fed38..b1ab1f1a7e 100644 --- a/examples/t3-code/tests/shell/relaunch-persistence.test.ts +++ b/examples/t3-code/tests/shell/relaunch-persistence.test.ts @@ -19,7 +19,6 @@ import { shellView } from '../../src/shell/shell'; import { toasts } from '../../src/shell/toast'; import { usagePrefs, usageLocal } from '../../src/usage/pages-usage'; import { commandLabel } from '../../src/snapshot/snapshot-shortcut'; -import { NIGHTLY_NOTICE } from '../../src/shell/shell-nightly'; import { commandShortcut } from '../../src/shell/shell'; const NOW = Date.parse('2026-10-04T12:00:00.000Z'); @@ -38,19 +37,12 @@ function disk(initial = '') { const load = (client: T3Client, files: Files) => (client as unknown as { load(storage: Files): Promise }).load(files); describe('relaunch persistence (the preference file the reference keeps in localStorage)', () => { - test('the Nightly notice dismissed by its × stays dismissed after a relaunch', async () => { - const { files, box } = disk(); - const first = new T3Client(); - await load(first, files); - await shellView(first, null, files, NOW, '', false); - const notice = toasts(first).find(toast => toast.title === NIGHTLY_NOTICE.title)!; - expect(notice).toBeDefined(); - await shellView(first, null, files, NOW + 1000, `x${notice.id}`, false); - expect(JSON.parse(box.text).shell.nightlyNoticeDismissed).toBe(true); - const relaunched = new T3Client(); - await load(relaunched, files); - await shellView(relaunched, null, files, NOW + 2000, '', false); - expect(toasts(relaunched).some(toast => toast.title === NIGHTLY_NOTICE.title)).toBe(false); + test('a fresh launch keeps the obsolete beta-app warning absent', async () => { + const { files } = disk(); + const client = new T3Client(); + await load(client, files); + await shellView(client, null, files, NOW, '', false); + expect(toasts(client).some(toast => toast.title === 'Nightly needs the beta mobile app')).toBe(false); }); test('Usage preferences and drafts survive a relaunch while navigation starts at Home', async () => { const { files } = disk(); From f9f4ed53903b7700836344388031421141f2caec Mon Sep 17 00:00:00 2001 From: Daehyeon Mun Date: Mon, 19 Oct 2026 00:01:25 +0900 Subject: [PATCH 3/3] Update the rendered keybinding fixture for grouped rows Co-Authored-By: Codex --- examples/t3-code/macos/src/contract_keys_tests.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/examples/t3-code/macos/src/contract_keys_tests.rs b/examples/t3-code/macos/src/contract_keys_tests.rs index ec61af3bf3..8176cb6b4d 100644 --- a/examples/t3-code/macos/src/contract_keys_tests.rs +++ b/examples/t3-code/macos/src/contract_keys_tests.rs @@ -132,6 +132,14 @@ fn host() -> exact_apple::Host { ] { set(&plan, row_type, &mut row, &[name], value); } + let (group_type, mut group) = element(&plan, "bindings", &["groups"]); + for (name, value) in [ + ("id", Value::str("chat")), + ("title", Value::str("Chat")), + ("rows", Value::list(vec![row.clone()])), + ] { + set(&plan, group_type, &mut group, &[name], value); + } replies.fixture( &plan, "bindings", @@ -140,6 +148,7 @@ fn host() -> exact_apple::Host { (&["writable"], Value::Bool(true)), (&["draftValid"], Value::Bool(true)), (&["rows"], Value::list(vec![row])), + (&["groups"], Value::list(vec![group])), ], ); let measurer = CallbackMeasurer::new(measure, std::ptr::null_mut(), None);