{/* The desktop window only moves where CSS opts in, so keep a titlebar strip. */} {isElectron ? : null} diff --git a/apps/web/src/components/PersistentThreadTerminals.tsx b/apps/web/src/components/PersistentThreadTerminals.tsx index 5e2fe0dbe91f5..a387349b4c038 100644 --- a/apps/web/src/components/PersistentThreadTerminals.tsx +++ b/apps/web/src/components/PersistentThreadTerminals.tsx @@ -294,7 +294,7 @@ export interface PersistentThreadTerminalPanelProps { onSplitTerminalVertical: () => void; onNewTerminal: () => void; onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; + onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; splitShortcutLabel?: string | undefined; splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; diff --git a/apps/web/src/components/ProjectFavicon.browser.test.tsx b/apps/web/src/components/ProjectFavicon.browser.test.tsx new file mode 100644 index 0000000000000..746302ef5b242 --- /dev/null +++ b/apps/web/src/components/ProjectFavicon.browser.test.tsx @@ -0,0 +1,57 @@ +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId } from "@t3tools/contracts"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; +import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; + +const state = vi.hoisted(() => ({ favicon: undefined as Atom.Atom | undefined })); +vi.mock(import("../state/assets"), () => ({ + projectFaviconUrlAtom: () => { + if (!state.favicon) throw new Error("Favicon resource not configured"); + return state.favicon; + }, +})); + +function setup() { + const activeResources = new Set(); + state.favicon = Atom.make((get) => { + activeResources.add("favicon"); + get.addFinalizer(() => activeResources.delete("favicon")); + return null; + }); + return { activeResources }; +} + +it("only activates the favicon resource after removing a custom icon", async () => { + const { activeResources } = setup(); + const registry = AtomRegistry.make(); + const project: ProjectFaviconProject = { + environmentId: EnvironmentId.make("test"), + workspaceRoot: "/project", + title: "Project", + faviconPath: "icon.png", + projectIcon: { kind: "monogram", text: "PR", color: "blue" }, + }; + const view = (projectIcon: ProjectFaviconProject["projectIcon"]) => ( + + + + ); + try { + await using screen = await render(view(project.projectIcon)); + expect(activeResources.size).toBe(0); + await screen.rerender(view({ kind: "emoji", emoji: "๐ŸŒฑ" })); + expect(activeResources.size).toBe(0); + await screen.rerender(view({ kind: "lucide", name: "leaf", color: "green" })); + expect(activeResources.size).toBe(0); + await screen.rerender(view(null)); + expect(activeResources.size).toBe(1); + await screen.rerender(view(project.projectIcon)); + await screen.rerender(view(null)); + expect(activeResources.size).toBe(1); + } finally { + registry.dispose(); + } +}); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 14a00ae86583b..3545a0aa5db87 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -38,13 +38,6 @@ export function ProjectFavicon(input: { fallbackIcon?: ComponentType<{ className?: string }>; }) { const { project } = input; - const src = useAtomValue( - projectFaviconUrlAtom({ - environmentId: project.environmentId, - cwd: project.workspaceRoot, - faviconPath: project.faviconPath, - }), - ); if (project.projectIcon?.kind === "monogram") { return ( ); } + return ; +} + +function AutomaticProjectFavicon(input: Parameters[0]) { + const { project } = input; + const src = useAtomValue( + projectFaviconUrlAtom({ + environmentId: project.environmentId, + cwd: project.workspaceRoot, + faviconPath: project.faviconPath, + }), + ); const FallbackIcon = input.fallbackIcon ?? FolderCodeIcon; if (!src || isProjectFaviconFallbackUrl(src)) { diff --git a/apps/web/src/components/ProjectPinButton.tsx b/apps/web/src/components/ProjectPinButton.tsx new file mode 100644 index 0000000000000..a8bd7d5ee8e99 --- /dev/null +++ b/apps/web/src/components/ProjectPinButton.tsx @@ -0,0 +1,67 @@ +import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { PinIcon, PinOffIcon } from "lucide-react"; +import type { KeyboardEvent } from "react"; + +import { resolveShortcutCommand } from "../keybindings"; + +import { cn } from "~/lib/utils"; +import { Button } from "./ui/button"; + +/** The thread pin shortcut, which pins the highlighted row in a project list. */ +export function isProjectPinShortcut( + event: KeyboardEvent, + keybindings: ResolvedKeybindingsConfig, +): boolean { + return ( + resolveShortcutCommand(event.nativeEvent, keybindings, { + platform: navigator.platform, + context: { modelPickerOpen: false, editableFocus: true }, + }) === "thread.pin" + ); +} + +/** + * Pin toggle for a project row in a menu or list. A pinned project always shows its pin; + * an unpinned one shows it only on the highlighted row. Keyboard users get a shortcut + * from the list that hosts it, so the button stays out of the tab order. + */ +export function ProjectPinButton(props: { + readonly name: string; + readonly pinned: boolean; + readonly onToggle: () => void; + readonly shortcutLabel?: string | null; +}) { + const action = `${props.pinned ? "Unpin" : "Pin"} ${props.name}`; + return ( + + + + ); +} diff --git a/apps/web/src/components/PullRequestThreadDialog.test.ts b/apps/web/src/components/PullRequestThreadDialog.test.ts index 1d601205dd72f..e800d588d6ba8 100644 --- a/apps/web/src/components/PullRequestThreadDialog.test.ts +++ b/apps/web/src/components/PullRequestThreadDialog.test.ts @@ -50,13 +50,13 @@ vi.mock("~/state/session", () => ({ ? state.canOperate : scope === AuthSourceControlWriteScope && state.canWriteSourceControl, })); -vi.mock("~/lib/sourceControlActions", async () => { +vi.mock("~/state/sourceControlActions", async () => { const { usePreparePullRequestThreadAction } = await vi.importActual< typeof import("~/state/sourceControlActions") >("~/state/sourceControlActions"); return { readCachedPullRequestResolution: () => null, - usePullRequestResolution: () => ({ + usePullRequestResolutionState: () => ({ data: { pullRequest: { number: 123, title: "Pull request", state: "open" } }, }), usePreparePullRequestThreadAction, diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 0ceda05792934..4ecce60109961 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -13,8 +13,8 @@ import { useRef, useState } from "react"; import { readCachedPullRequestResolution, usePreparePullRequestThreadAction, - usePullRequestResolution, -} from "~/lib/sourceControlActions"; + usePullRequestResolutionState, +} from "~/state/sourceControlActions"; import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "@t3tools/client-runtime/pull-request-link-input"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; @@ -92,7 +92,7 @@ export function PullRequestThreadDialog({ const parsedReference = parsePullRequestReference(reference); const parsedDebouncedReference = parsePullRequestReference(debouncedReference); - const pullRequestResolution = usePullRequestResolution({ + const pullRequestResolution = usePullRequestResolutionState({ environmentId, cwd, reference: open ? parsedDebouncedReference : null, diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx index 819455b68f3f7..94ecda194fe43 100644 --- a/apps/web/src/components/QuitHoldOverlay.tsx +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -22,7 +22,7 @@ function subscribeQuitHint(listener: () => void): () => void { listeners.add(listener); if (listeners.size === 1) { stopBridge = - window.desktopBridge?.onQuitShortcut?.((hint) => { + window.desktopBridge?.onQuitShortcut((hint) => { window.clearTimeout(hideTimer); if (hint.state === "down") { pressedMode = hint.mode; diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index f409ce48bfeba..ce187f5db5e96 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -44,6 +44,8 @@ import { import { toastManager } from "./ui/toast"; const PREVIEW_FORWARDED_LAYOUT_COMMANDS = [ + "workspace.focusNext", + "workspace.focusPrevious", "sidebar.toggle", "rightPanel.toggle", "rightPanel.toggleMaximized", @@ -89,6 +91,7 @@ export function ReopenClosedViewShortcut() { environmentKnown: true, catalogReady: true, ownerExists: true, + sideChatExists: true, shellLive: true, panel, }; @@ -97,6 +100,10 @@ export function ReopenClosedViewShortcut() { environmentKnown: catalog.entries.has(ref.environmentId), catalogReady: catalog.isReady, ownerExists: readThreadShell(ref) !== null || drafts.getDraftThreadByRef(ref) !== null, + sideChatExists: + entry.kind !== "panel-tab" || + entry.surface.kind !== "side-chat" || + readThreadShell(scopeThreadRef(ref.environmentId, entry.surface.threadId)) !== null, shellLive: appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live", panel, @@ -182,7 +189,7 @@ export function ReopenClosedViewShortcut() { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers forwarded shortcut chords with the desktop preview bridge and clears them on cleanup useEffect(() => { const preview = window.desktopBridge?.preview; - if (!preview?.setForwardedShortcuts) return; + if (!preview) return; const options = { context: { previewFocus: true, @@ -211,7 +218,7 @@ export function ReopenClosedViewShortcut() { ) .catch(() => undefined); return () => { - void preview.setForwardedShortcuts?.([]).catch(() => undefined); + void preview.setForwardedShortcuts([]).catch(() => undefined); }; }, [hasHistory, keybindings, terminalOpen]); diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index fa9cbf0141c3e..88793cce44850 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -26,8 +26,62 @@ describe("surface shortcuts", () => { const actions = [ { shortcut: "B", available: true, label: "Browser" }, { shortcut: "D", available: false, label: "Diff" }, + { shortcut: "T", available: true, label: "Terminal" }, + { shortcut: "M", available: true, label: "Device" }, ] as const; + it("falls back to the physical key when the layout types a non-Latin letter or mark", () => { + // Ukrainian layout: the B key types "ะธ". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u0438", { code: "KeyB" }))).toBe( + actions[0], + ); + // Greek layout: the B key types "ฮฒ". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u03b2", { code: "KeyB" }))).toBe( + actions[0], + ); + // Thai layout: the B key types the vowel sign "\u0e34", a combining mark. + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u0e34", { code: "KeyB" }))).toBe( + actions[0], + ); + // Arabic (101) layout: the B key types lam alef "\u0644\u0627", two code points. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u0644\u0627", { code: "KeyB" })), + ).toBe(actions[0]); + // Ukrainian layout: the D key types "\u0432", but the Diff surface is unavailable. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u0432", { code: "KeyD" })), + ).toBeNull(); + }); + + it("follows the typed letter on Latin layouts that move it", () => { + // Dvorak: the key at QWERTY B types "x", and "b" sits on QWERTY N. + expect(surfaceShortcutActionForKey(actions, shortcutEvent("x", { code: "KeyB" }))).toBeNull(); + // The same key with Shift or Caps Lock types "X". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("X", { code: "KeyB" }))).toBeNull(); + expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { code: "KeyN" }))).toBe( + actions[0], + ); + }); + + it("keeps accented Latin letters typed on a letter key", () => { + // Bรฉpo: the T key types "รจ". + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u00e8", { code: "KeyT" })), + ).toBeNull(); + }); + + it("keeps punctuation typed on a letter key", () => { + // French AZERTY: the M key types ",". + expect(surfaceShortcutActionForKey(actions, shortcutEvent(",", { code: "KeyM" }))).toBeNull(); + }); + + it("keeps a dead key on a letter key", () => { + // A dead key starts an accented letter on a Latin layout. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("Dead", { code: "KeyT" })), + ).toBeNull(); + }); + it("leaves modified, composing, and already-handled key events alone", () => { expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { metaKey: true }))).toBeNull(); expect( @@ -37,6 +91,21 @@ describe("surface shortcuts", () => { surfaceShortcutActionForKey(actions, shortcutEvent("b", { defaultPrevented: true })), ).toBeNull(); }); + + it("leaves AltGr text alone unless it is a plain letter", () => { + const getModifierState = (key: string) => key === "AltGraph"; + // German layout: AltGr+M types "\u00b5" and sets only AltGraph on Linux and Windows. + expect( + surfaceShortcutActionForKey( + actions, + shortcutEvent("\u00b5", { code: "KeyM", getModifierState }), + ), + ).toBeNull(); + // Finnish layout: AltGr+B types "b", which still matches, as it does in keybindings.ts. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("b", { code: "KeyB", getModifierState })), + ).toBe(actions[0]); + }); }); describe("surface shortcut typing contexts", () => { diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b833eec279d60..596bdee96ad1f 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,15 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + threadRuntimeIsActive, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { + useProjects, + useQuietThreadShell, + useServerConfigs, + useThreadShells, +} from "~/state/entities"; import { threadPullRequestKeysEqual, visibleThreadPullRequests, @@ -12,6 +21,7 @@ import type { ProjectId, PullRequestState, ResolvedKeybindingsConfig, + ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { @@ -33,6 +43,7 @@ import { FileDiff, Files, Globe2, + MessagesSquare, Plus, TerminalSquare, } from "lucide-react"; @@ -73,7 +84,7 @@ import { import { useBrowserDefaults } from "~/browser/browserDefaults"; import { ScrollArea } from "~/components/ui/scroll-area"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; -import { faviconUrlForOrigin } from "~/lib/favicon"; +import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { useTheme } from "~/hooks/useTheme"; import { useDeviceState } from "~/state/device"; import type { PreviewPanelInlineSize } from "~/hooks/usePreviewPanelInlineSize"; @@ -92,6 +103,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { observeResize } from "~/lib/observeResize"; +import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; export interface RightPanelTabsProps { mode: PreviewPanelMode; @@ -129,6 +141,8 @@ export interface RightPanelTabsProps { onAddPullRequest: () => void; onAddPullRequests: () => void; onAddDevice: () => void; + /** Absent where side chats are not offered. */ + onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -136,6 +150,9 @@ export interface RightPanelTabsProps { pullRequestAvailable: boolean; pullRequestsAvailable: boolean; deviceAvailable: boolean; + sideChatAvailable?: boolean; + /** Overrides the default reason while side chats are available but cannot start. */ + sideChatDisabledReason?: string | null; pullRequestStatusSeeds?: Readonly>; children: ReactNode; } @@ -162,6 +179,7 @@ const SURFACE_DISABLED_REASONS = { pullRequest: "This thread's branch has no pull request yet.", pullRequests: "No linked pull requests are available for this thread.", device: "Devices are only available from a thread.", + sideChat: "Side chats start after the first message.", } as const; // Closed keepMounted popups must stop blocking shortcuts once their exit transition ends. @@ -186,6 +204,7 @@ const SURFACE_UNAVAILABLE_HINTS = { pullRequest: "No pull request on this branch yet.", pullRequests: "No linked pull requests available.", device: "Available from a thread.", + sideChat: "Available after the first message.", } as const; type TabContextMenuAction = @@ -238,17 +257,17 @@ function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { type SurfaceShortcutEvent = Pick< KeyboardEvent, "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" ->; +> & + Partial>; export function surfaceShortcutActionForKey< const Action extends { available: boolean; shortcut: string }, >(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { if (event.defaultPrevented || event.isComposing) return null; if (event.metaKey || event.ctrlKey || event.altKey) return null; + const key = surfaceShortcutKey(event); return ( - actions.find( - (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(), - ) ?? null + actions.find((action) => action.available && action.shortcut.toLowerCase() === key) ?? null ); } @@ -303,6 +322,8 @@ function RightPanelEmptyState(props: { onAddPullRequest: () => void; onAddPullRequests: () => void; onAddDevice: () => void; + /** Absent where side chats are not offered. */ + onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -310,6 +331,8 @@ function RightPanelEmptyState(props: { pullRequestAvailable: boolean; pullRequestsAvailable: boolean; deviceAvailable: boolean; + sideChatAvailable: boolean; + sideChatDisabledReason: string | null; }) { const [highlight, setHighlight] = useState(-1); @@ -371,6 +394,19 @@ function RightPanelEmptyState(props: { disabledReason: SURFACE_UNAVAILABLE_HINTS.device, onClick: props.onAddDevice, }, + ...(props.onAddSideChat + ? [ + { + label: "Side chat", + description: "Ask a side question while this thread keeps working.", + icon: MessagesSquare, + shortcut: "S", + available: props.sideChatAvailable && !props.sideChatDisabledReason, + disabledReason: props.sideChatDisabledReason ?? SURFACE_UNAVAILABLE_HINTS.sideChat, + onClick: props.onAddSideChat, + }, + ] + : []), ] as const; type SurfaceAction = (typeof actions)[number]; @@ -436,7 +472,7 @@ function RightPanelEmptyState(props: { return (
; + case "side-chat": + return ; case "device": return surface.target?.platform === "ios" ? ( @@ -655,6 +695,41 @@ function SurfaceIcon({ } } +/** What a side chat tab's dot shows and its title's accessible name says. */ +function sideChatTabState(shell: EnvironmentThreadShell | null) { + if (shell === null) return null; + if (shell.hasPendingApprovals || shell.pendingSubagentRequest === "approval") { + return "needs approval"; + } + if (shell.hasPendingUserInput || shell.pendingSubagentRequest === "user_input") { + return "needs input"; + } + return threadRuntimeIsActive(shell.runtime) ? "running" : null; +} + +// Static dots: a side chat tab can stay open for a whole run. +function SideChatTabIcon(props: { environmentId: EnvironmentId | null; threadId: ThreadId }) { + const state = sideChatTabState( + useQuietThreadShell( + props.environmentId === null ? null : scopeThreadRef(props.environmentId, props.threadId), + ), + ); + return ( + <> + + {state === null ? null : ( + + )} + + ); +} + export function resolvePullRequestTabLink( threads: readonly Pick[], environmentId: EnvironmentId | null, @@ -712,17 +787,17 @@ function PullRequestSurfaceIcon({ ? undefined : configs.get(resolvedEnvironmentId)?.environment.capabilities; const linkedSnapshot = - capabilities?.threadPullRequests === true + capabilities !== undefined ? (resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface)?.snapshot ?? null) : null; const detail = useEnvironmentQuery( - resolvedEnvironmentId === null || capabilities?.pullRequests !== true || linkedSnapshot !== null + resolvedEnvironmentId === null || capabilities === undefined || linkedSnapshot !== null ? null : pullRequestEnvironment.detail({ environmentId: resolvedEnvironmentId, input: { projectId: surface.projectId as ProjectId, - ...(capabilities?.threadPullRequests === true && surface.host !== undefined + ...(capabilities !== undefined && surface.host !== undefined ? { host: surface.host } : {}), repository: surface.repository, @@ -748,10 +823,6 @@ function PullRequestSurfaceIcon({ return ; } -function focusOnMount(node: HTMLDivElement | null) { - node?.focus(); -} - function SortableTabList(props: { surfaces: readonly RightPanelSurface[]; onMove: ((surfaceId: string, toIndex: number) => void) | undefined; @@ -816,7 +887,6 @@ function RightPanelTab( ) { const { surface, - title, audio, audioRuntimeTabId, onActivate, @@ -830,6 +900,13 @@ function RightPanelTab( id: surface.id, disabled: props.dragDisabled, }); + const sideChatShell = useQuietThreadShell( + surface.kind === "side-chat" && props.environmentId !== null + ? scopeThreadRef(props.environmentId, surface.threadId) + : null, + ); + const title = sideChatShell?.title ?? props.title; + const sideChatState = sideChatTabState(sideChatShell); const content = ( <> onClose(surface)}> @@ -899,6 +976,7 @@ function RightPanelTab( if (surface.kind === "device" && props.canRename) onStartRename(surface.id); }} className="cursor-pointer flex min-w-0 items-center" + aria-label={sideChatState === null ? undefined : `${title}, ${sideChatState}`} onClick={() => onActivate(surface)} > {title} @@ -945,6 +1023,7 @@ function RightPanelTab( } export function RightPanelTabs(props: RightPanelTabsProps) { + const focusedSurface = props.surfaces.find((surface) => surface.id === props.activeSurfaceId); const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); @@ -1068,6 +1147,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.device, onClick: props.onAddDevice, }, + ...(props.onAddSideChat + ? [ + { + label: "Side chat", + icon: MessagesSquare, + shortcut: "S", + available: props.sideChatAvailable === true && !props.sideChatDisabledReason, + disabledReason: props.sideChatDisabledReason ?? SURFACE_DISABLED_REASONS.sideChat, + onClick: props.onAddSideChat, + }, + ] + : []), ] as const; const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { @@ -1226,6 +1317,13 @@ export function RightPanelTabs(props: RightPanelTabsProps) { return ( ) : ( props.children diff --git a/apps/web/src/components/ServerUpdateAction.browser.test.tsx b/apps/web/src/components/ServerUpdateAction.browser.test.tsx index 0062a69c09177..ed3a723652a56 100644 --- a/apps/web/src/components/ServerUpdateAction.browser.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.browser.test.tsx @@ -13,8 +13,8 @@ const testState = vi.hoisted(() => ({ updateServer: vi.fn(), toast: vi.fn(), clipboard: vi.fn(), - continueThreadsAfterServerUpdate: false, sessionAtom: null as unknown, + serverVersions: new Map(), })); vi.mock(import("~/hooks/useCopyToClipboard"), async (original) => ({ @@ -27,16 +27,6 @@ vi.mock(import("~/hooks/useCopyToClipboard"), async (original) => ({ isCopied: false, })) as never, })); -vi.mock(import("~/hooks/useSettings"), async (original) => ({ - ...(await original()), - useEnvironmentSettings: (( - _environmentId: EnvironmentId, - selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown, - ) => - selector({ - continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate, - })) as never, -})); vi.mock(import("~/state/session"), async (original) => { const actual = await original(); return { @@ -47,6 +37,16 @@ vi.mock(import("~/state/session"), async (original) => { }, }; }); +vi.mock(import("~/state/entities"), async (original) => ({ + ...(await original()), + useServerConfigs: (() => + new Map( + [...testState.serverVersions].map(([id, serverVersion]) => [ + id, + { environment: { serverVersion } }, + ]), + )) as never, +})); vi.mock(import("~/state/use-atom-command"), async (original) => ({ ...(await original()), useAtomCommand: (() => testState.updateServer) as never, @@ -95,7 +95,7 @@ function setup(): Disposable { testState.updateServer.mockReset(); testState.toast.mockReset(); testState.clipboard.mockReset(); - testState.continueThreadsAfterServerUpdate = false; + testState.serverVersions.clear(); sessionAtom = Atom.make(AsyncResult.success(currentSession)); testState.sessionAtom = sessionAtom; resetConfirmDialogForTests(); @@ -188,16 +188,12 @@ describe("ServerUpdatesAction", () => { serverLabel: "Laptop", selfUpdate: "boot-service", targetVersion: "0.0.31", - threadContinuation: true, - continueThreadsAfterServerUpdate: true, }, { environmentId: "batch-b" as EnvironmentId, serverLabel: "Office", selfUpdate: "respawn", targetVersion: "0.0.31", - threadContinuation: true, - continueThreadsAfterServerUpdate: false, }, { environmentId: "batch-c" as EnvironmentId, @@ -228,4 +224,17 @@ describe("ServerUpdatesAction", () => { ); expect(button(screen, "Update all")!.disabled).toBe(false); }); + + it("skips a machine whose update would cross Stable and Nightly", async () => { + using _state = setup(); + testState.serverVersions.set("batch-a", "0.0.30-nightly.20261001.1"); + testState.updateServer.mockResolvedValue(success()); + await using screen = await mount(); + await click(screen, "Update all"); + + expect(testState.updateServer).toHaveBeenCalledTimes(1); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ type: "success", title: "Office updated" }), + ); + }); }); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index c659d410b8581..141f139aefd7b 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -7,6 +7,7 @@ import { import type { AsyncResult } from "effect/reactivity"; import { environmentSession } from "~/state/session"; import type { + DesktopUpdateChannel, EnvironmentId, ServerInstallation, ServerSelfUpdateCapability, @@ -22,11 +23,12 @@ import { type ComponentProps, useRef, useState } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useEnvironmentSettings } from "~/hooks/useSettings"; import { serverEnvironment, updateOutdatedServer } from "~/state/server"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; +import { useServerConfigs } from "~/state/entities"; +import { crossesReleaseTrack } from "@t3tools/shared/cliRelease"; import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -42,6 +44,29 @@ export function serverUpdateStageLabel(stage: ServerUpdateStage): string { return UPDATE_STAGE_LABELS[stage]; } +const CROSS_TRACK_NOTE = "Switch tracks in About โ†’ Update track"; + +/** Hosts refuse a Stable/Nightly crossing outside a track switch, which checks the database first. */ +function refusedCrossing( + target: { + readonly selfUpdate: ServerSelfUpdateCapability | null; + readonly targetVersion: string; + readonly channel?: DesktopUpdateChannel | undefined; + }, + serverVersion: string | undefined, +): boolean { + return ( + target.channel === undefined && + target.selfUpdate !== null && + serverVersion !== undefined && + crossesReleaseTrack(serverVersion, target.targetVersion) + ); +} + +function CrossTrackNote() { + return {CROSS_TRACK_NOTE}; +} + function updateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } @@ -52,9 +77,13 @@ export interface ServerUpdateTarget { readonly selfUpdate: ServerSelfUpdateCapability | null; readonly installation?: ServerInstallation | undefined; readonly desktopAppUpdate?: boolean; - readonly threadContinuation?: boolean; readonly targetVersion: string; - readonly continueThreadsAfterServerUpdate?: boolean; + /** Set only by a track switch; the host's desktop app moves to this track. */ + readonly channel?: DesktopUpdateChannel; + /** A track switch's checked release migration; the host re-checks its own database. */ + readonly targetDatabaseMigration?: number; + /** The host already runs its track's newest build and only saves the track. */ + readonly trackOnly?: boolean; } type UpdateButtonProps = Pick, "variant" | "size" | "className"> & { @@ -84,9 +113,10 @@ export function useServerUpdate() { environmentId, input: { targetVersion, - ...(target.threadContinuation && target.continueThreadsAfterServerUpdate - ? { continueRunningThreads: true } - : {}), + ...(target.channel === undefined ? {} : { channel: target.channel }), + ...(target.targetDatabaseMigration === undefined + ? {} + : { targetDatabaseMigration: target.targetDatabaseMigration }), }, }); pendingUpdateEnvironmentIds.delete(environmentId); @@ -126,12 +156,14 @@ export function ServerUpdatesAction({ readonly targets: ReadonlyArray; }) { const update = useServerUpdate(); + const serverConfigs = useServerConfigs(); const pending = useRef(false); const [isPending, setIsPending] = useState(false); const eligible = targets.filter( (target) => target.selfUpdate !== null && - (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate), + (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate) && + !refusedCrossing(target, serverConfigs.get(target.environmentId)?.environment.serverVersion), ); const handleUpdate = async () => { if (pending.current) return; @@ -205,21 +237,18 @@ export function ServerUpdateAction({ selfUpdate, installation, desktopAppUpdate = false, - threadContinuation = false, targetVersion, + channel, label = "Update", variant = "outline", size = "xs", className, appearance = "button", -}: Omit & UpdateButtonProps) { +}: ServerUpdateTarget & UpdateButtonProps) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; + const serverVersion = useServerConfigs().get(environmentId)?.environment.serverVersion; const sessionStateAtom = environmentSession.sessionStateAtom(environmentId); const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom)); - const continueThreadsAfterServerUpdate = useEnvironmentSettings( - environmentId, - (settings) => settings.continueThreadsAfterServerUpdate, - ); const update = useServerUpdate(); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ target: installation?.kind === "npm-global" ? "update command" : "relaunch command", @@ -265,9 +294,7 @@ export function ServerUpdateAction({ serverLabel, selfUpdate, desktopAppUpdate, - threadContinuation, targetVersion, - continueThreadsAfterServerUpdate, }); }; @@ -279,6 +306,10 @@ export function ServerUpdateAction({ ); } + if (refusedCrossing({ selfUpdate, targetVersion, channel }, serverVersion)) { + return ; + } + const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null; const actionLabel = @@ -366,6 +397,9 @@ export function OutdatedServerUpdateAction({ description: `Reconnected on t3@${result.value.targetVersion}.`, }); }; + if (fromVersion !== undefined && crossesReleaseTrack(fromVersion, targetVersion)) { + return ; + } return ( +
+ )}
); }); @@ -145,6 +162,7 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { key: string | null; overrides: Record; }>({ key: null, overrides: {} }); + const syncTimelineLayout = useSyncLayout(); const treeNodes = buildTurnDiffTree(files); const directoryPathsKey = collectDirectoryPaths(treeNodes).join("\u0000"); const hasDirectoryNodes = directoryPathsKey.length > 0; @@ -165,6 +183,8 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { }, }; }); + // Measure before paint instead of waiting for LegendList's deferred shrink measurement. + syncTimelineLayout(); }; const rows = collectExpandedRows(treeNodes, expandedDirectories, allDirectoriesExpanded); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 1817b0542cc6f..75ae00d11fae9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,7 +1,12 @@ import { useComposerTypingGuard } from "./useComposerTypingGuard"; import { insertAttachmentReferencesForDraft } from "./composerAttachmentReferences"; import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; -import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig"; +import { BackgroundThreadContext } from "./backgroundThread"; +import { + resolveCompatibleRuntimeMode, + runtimeModeConfig, + runtimeModeOptions as runtimeModes, +} from "./runtimeModeConfig"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useRightPanelStore } from "~/rightPanelStore"; @@ -19,7 +24,6 @@ import type { ModelSelection, ProjectId, PreviewAnnotationPayload, - ProviderApprovalDecision, ThreadContextRecord, EnvironmentContextRecord, ProviderInteractionMode, @@ -48,6 +52,7 @@ import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model" import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { memo, + use, useEffect, useEffectEvent, useId, @@ -56,7 +61,7 @@ import { useRef, useState, } from "react"; -import { createPortal, flushSync } from "react-dom"; +import { flushSync } from "react-dom"; import { clampCollapsedComposerCursor, type ComposerTrigger, @@ -75,7 +80,6 @@ import { DEFAULT_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { deriveComposerSendState, getAntigravitySendBlockReason, - readFileAsDataUrl, resolveComposerInteractionMode, threadShellHasStarted, } from "../ChatView.logic"; @@ -83,15 +87,17 @@ import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, } from "./composerMentionDrag"; +import { serializeComposerImages } from "./composerImagePersistence"; import { isInsideCollapsedComposerControls, isInsideRestingComposerControlScope, + markOpenToEffort, + markOpenToPermissions, } from "./composerEventScope"; import { type ComposerFileAttachment, type ComposerImageAttachment, type DraftId, - type PersistedComposerImageAttachment, composerFileNeedsReattach, composerDraftHasUserContent, composerTargetKey, @@ -102,7 +108,6 @@ import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; import { useComposerTriggerState } from "./useComposerTriggerState"; import { useComposerMenuItems } from "./useComposerMenuItems"; -import { useComposerFocusState } from "./useComposerFocusState"; import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; import { reconcileAttachmentContextReferences, @@ -133,17 +138,19 @@ import { type TerminalContextDraft, type TerminalContextSelection, } from "../../lib/terminalContext"; -import { toKindScopedComposerContextId } from "@t3tools/shared/composerContextReferences"; +import { + threadContextRecord, + toKindScopedComposerContextId, +} from "@t3tools/shared/composerContextReferences"; import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, - shouldUseRestingComposerLayout, } from "../composerFooterLayout"; import { type ComposerCitationCommentRequest, type ComposerPromptEditorHandle, - ComposerPromptEditor, -} from "../ComposerPromptEditor"; + ComposerPromptEditorTiptap as ComposerPromptEditor, +} from "../ComposerPromptEditorTiptap"; import { ComposerContextActionsContext, composerContextRecordsFromDraft, @@ -167,7 +174,6 @@ import { reviewCommentContextRecord, terminalContextReference, terminalContextRecord, - threadContextRecord, threadContextReference, environmentContextRecord, environmentContextReference, @@ -175,6 +181,7 @@ import { import { rememberCopiedComposerAttachments } from "~/lib/copiedComposerAttachments"; import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag"; import { readThreadShell } from "~/state/entities"; +import { useLinkedEnvironments } from "~/state/peerLinks"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import type { ComposerContextRecord, ThreadPullRequestLink } from "@t3tools/contracts"; import { threadContextAttachment } from "~/lib/threadContextAttachment"; @@ -183,7 +190,6 @@ import { ComposerCommandMenu, composerSuggestionOptionId, } from "./ComposerCommandMenu"; -import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { ComposerAttachButton, ComposerAttachmentFileList, @@ -193,7 +199,6 @@ import { } from "./ComposerAttachmentStrip"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerFooterPrimaryActions } from "./ComposerFooterControls"; -import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; @@ -219,22 +224,11 @@ import { import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; import { pendingDraftWork } from "./pendingDraftWork"; import { importComposerThreadAttachment } from "./composerThreadImport"; -import { isTimelineScrollTarget } from "./timelineScrollTarget"; -import { - createComposerScrollGestureState, - recordComposerScrollGestureEvent, - shouldCollapseComposerForScrollKey, - resetComposerScrollGesture, - suppressActiveComposerScrollGesture, -} from "./composerScrollGesture"; import { observeResize } from "~/lib/observeResize"; const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError); - -const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24; -const COMPOSER_SCROLL_GESTURE_RESET_MS = 120; import { toastManager } from "../ui/toast"; -import { PlayIcon, ShieldIcon } from "lucide-react"; +import { PlayIcon } from "lucide-react"; import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText"; import { hasProviderSetup } from "./ProviderStatusBanner"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; @@ -247,7 +241,7 @@ import { type ThreadShell, } from "../../types"; import { type ComposerPromptHistoryPosition } from "./composerPromptHistory"; -import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; import type { LatestProposedPlanState, PendingApproval, @@ -295,8 +289,6 @@ const runtimeModeOptions = runtimeModes.map((mode) => ({ mode, ...runtimeModeCon export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; - restoreAfterTimelineReachedEnd: () => void; - collapseForTimelineScrollKey: (key: string) => void; addDroppedFiles: (files: File[]) => void; addDroppedFolders: (folders: File[]) => void; hasPendingAttachments: () => boolean; @@ -339,7 +331,6 @@ export interface ChatComposerHandle { threadContexts: ThreadContextRecord[]; environmentContexts: EnvironmentContextRecord[]; selectedPromptEffort: string | null; - selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; multipleModelSelections: ReadonlyArray | null; providerAvailable: boolean; @@ -372,18 +363,14 @@ export interface ChatComposerProps { >; activeThreadId: ThreadId | null; - activeThreadEnvironmentId: EnvironmentId | undefined; activeThread: Thread | undefined; activeThreadShell: ThreadShell | null; promptHistoryMessages: ReadonlyArray; - isServerThread: boolean; - isLocalDraftThread: boolean; forceExpandedOnMobile: boolean; projectSelectionRequired: boolean; phase: SessionPhase; canInterrupt: boolean; - isConnecting: boolean; isSendBusy: boolean; canResume: boolean; isRevertingCheckpoint?: boolean; @@ -395,6 +382,8 @@ export interface ChatComposerProps { keepFullHistory: boolean; onToggleKeepFullHistory: () => void; onUsageLimitsCommand?: (() => void) | undefined; + sideChatAvailable?: boolean; + sideChatDisabledReason?: string | null; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; @@ -402,6 +391,7 @@ export interface ChatComposerProps { activePendingApproval: PendingApproval | null; pendingApprovals: PendingApproval[]; + onHeldRequestIdsChange: (requestIds: ReadonlySet) => void; pendingUserInputs: PendingUserInput[]; activePendingProgress: { questionIndex: number; @@ -426,6 +416,7 @@ export interface ChatComposerProps { threadSyncPhase: ThreadSyncPhase | null; runtimeMode: RuntimeMode; + defaultRuntimeMode?: RuntimeMode; interactionMode: ProviderInteractionMode; lockedProvider: ProviderDriverKind | null; @@ -449,14 +440,9 @@ export interface ChatComposerProps { pullRequestProjectId: ProjectId | null; pullRequestRepository: string | null; threadPullRequests: ReadonlyArray | undefined; - restingControlsHost: HTMLDivElement | null; - restingControlsHaveLeadingContext: boolean; - onRestingControlsVisibilityChange: (visible: boolean) => void; - getTimelineScrollableNode: () => HTMLElement | null; - isTimelineAtLogicalEnd: () => boolean; - timelineOverflows: boolean; onComposerOverlayHeightChange: (height: number) => void; - onRestingChange: (resting: boolean) => void; + /** The new-thread strip names the permissions, so the controls row hides its chip. */ + permissionsShownInStrip: boolean; promptRef: React.RefObject; composerImagesRef: React.RefObject; @@ -478,10 +464,6 @@ export interface ChatComposerProps { onResume: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; - onRespondToApproval: ( - requestId: RuntimeRequestId, - decision: ProviderApprovalDecision, - ) => Promise; onSelectActivePendingUserInputOption: (questionId: string, optionValue: string) => void; onAdvanceActivePendingUserInput: () => void; onDismissActivePendingUserInput: (requestId: RuntimeRequestId) => void; @@ -531,16 +513,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) supportsMultipleModels, onMultipleModelSelectionsChange: setMultipleModelSelections, activeThreadId, - activeThreadEnvironmentId: _activeThreadEnvironmentId, activeThread, promptHistoryMessages, - isServerThread: _isServerThread, - isLocalDraftThread: _isLocalDraftThread, forceExpandedOnMobile, projectSelectionRequired, phase, canInterrupt, - isConnecting, isSendBusy, canResume, isRevertingCheckpoint = false, @@ -550,6 +528,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentUnavailable, activePendingApproval: incomingPendingApproval, pendingApprovals, + onHeldRequestIdsChange, pendingUserInputs: incomingPendingUserInputs, activePendingProgress: incomingPendingProgress, activePendingResolvedAnswers, @@ -580,14 +559,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pullRequestProjectId, pullRequestRepository, threadPullRequests, - restingControlsHost, - restingControlsHaveLeadingContext, - onRestingControlsVisibilityChange, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - timelineOverflows, onComposerOverlayHeightChange, - onRestingChange, promptRef, composerRef, composerImagesRef, @@ -601,7 +573,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onResume, onInterrupt, onImplementPlanInNewThread, - onRespondToApproval, onSelectActivePendingUserInputOption, onAdvanceActivePendingUserInput, onDismissActivePendingUserInput, @@ -632,6 +603,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ...pendingApprovals.map((request) => request.requestId), ...incomingPendingUserInputs.map((request) => request.requestId), ]); + // A layout effect so the timeline never paints a held approval as answerable. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes held requests to the timeline without lifting per-keystroke typing state + useLayoutEffect(() => { + onHeldRequestIdsChange(heldRequestIds); + }, [heldRequestIds, onHeldRequestIdsChange]); const activePendingApproval = incomingPendingApproval && !heldRequestIds.has(incomingPendingApproval.requestId) ? incomingPendingApproval @@ -724,7 +700,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { pendingSnapShotAnimations, pendingSnapShotIds, - pendingSnapShotIdSet, uncommittedSnapShotIds, standaloneComposerImages, } = useComposerImageGroups(attachmentDraftTarget, composerImages, composerPreviewAnnotations); @@ -922,16 +897,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : undefined; const resolvedCompactDisabledReason = compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null); - const supportedRuntimeModes = selectedProviderEntry?.snapshot.supportedRuntimeModes; - const compatibleRuntimeModeOptions = - supportedRuntimeModes && supportedRuntimeModes.length > 0 - ? runtimeModeOptions.filter((option) => supportedRuntimeModes.includes(option.mode)) - : runtimeModeOptions; - const compatibleRuntimeMode = compatibleRuntimeModeOptions.some( - (option) => option.mode === runtimeMode, - ) - ? runtimeMode - : (compatibleRuntimeModeOptions[0]?.mode ?? runtimeMode); + const { modes: compatibleRuntimeModes, mode: compatibleRuntimeMode } = + resolveCompatibleRuntimeMode( + runtimeMode, + selectedProviderEntry?.snapshot.supportedRuntimeModes, + ); + const compatibleRuntimeModeOptions = runtimeModeOptions.filter((option) => + compatibleRuntimeModes.includes(option.mode), + ); const providerSendBlockReason = getAntigravitySendBlockReason( selectedProviderEntry?.snapshot, @@ -1028,18 +1001,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) null, ); const [isDragOverComposer, setIsDragOverComposer] = useState(false); - const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const isMobileViewport = useMediaQuery("max-sm"); const isSoftwareKeyboardOpen = useSoftwareKeyboardOpen(); - const { - isComposerFocused, - setIsComposerFocused, - isComposerScrollCollapsed, - setIsComposerScrollCollapsed, - restoreAfterTimelineReachedEnd, - } = useComposerFocusState(); + const [isComposerFocused, setIsComposerFocused] = useState(false); const [composerSubmissionError, setComposerSubmissionError] = useState(null); const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( null, @@ -1069,10 +1035,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandFrameRef = useRef(null); const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); - const composerScrollCollapseTimeoutRef = useRef(null); - const composerScrollCollapseEligibleRef = useRef(false); - const windowRefocusInFlightRef = useRef(false); - const composerScrollGestureRef = useRef(createComposerScrollGestureState()); const pendingThreadImportsRef = useRef>(new Map()); const isRevertingCheckpointRef = useRef(isRevertingCheckpoint); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed checkpoint revert flag to a ref read by async handlers @@ -1080,14 +1042,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isRevertingCheckpointRef.current = isRevertingCheckpoint; }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to window keydown, blur and the desktop paste-as-text event to arm plain-text paste + const backgroundThread = use(BackgroundThreadContext); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to keydown, window blur and the desktop paste-as-text event to arm plain-text paste useEffect(() => { + // A background composer listens on its own form, so it never hears the main composer's keys. + const target: EventTarget | null = backgroundThread ? composerFormRef.current : window; + if (target === null) return; const armPasteAsTextShortcut = () => { // Electron's menu action can precede paste; browsers deliver keydown first. pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; }; - const onKeyDown = (event: KeyboardEvent) => { + const onKeyDown = (event: Event) => { if ( + event instanceof KeyboardEvent && event.target instanceof Node && composerFormRef.current?.contains(event.target) && isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) @@ -1105,22 +1072,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeElement.closest( 'input, textarea, select, button, a[href], summary, [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"], [role="button"], [role="menuitem"], [role="option"]', ) !== null; + // A background composer takes no paste-to-focus, so it arms only for its own focus. if ( (activeElement instanceof Node && composerFormRef.current?.contains(activeElement)) || - !blocksPasteToFocus + (!blocksPasteToFocus && !backgroundThread) ) { armPasteAsTextShortcut(); } }; - window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); - window.addEventListener("keydown", onKeyDown, true); - window.addEventListener("blur", onBlur); + target.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); + target.addEventListener("keydown", onKeyDown, true); + // An arm lasts a second, so a background composer can skip the window blur reset. + if (!backgroundThread) window.addEventListener("blur", onBlur); return () => { - window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); - window.removeEventListener("keydown", onKeyDown, true); + target.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); + target.removeEventListener("keydown", onKeyDown, true); window.removeEventListener("blur", onBlur); }; - }, []); + }, [backgroundThread]); const composerSendState = deriveComposerSendState({ prompt, @@ -1139,6 +1108,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContexts.length === 0 && composerPreviewAnnotations.length === 0 && composerReviewComments.length === 0; + const linkedEnvironments = useLinkedEnvironments(environmentId); const { items: composerMenuItems, isLoading: isComposerMenuLoading, @@ -1146,12 +1116,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } = useComposerMenuItems({ composerTrigger, environmentId, + linkedEnvironments, gitCwd, activeThreadId, pullRequestProjectId, pullRequestRepository, threadPullRequests, planModeUiEnabled, + sideChatAvailable: props.sideChatAvailable === true, + sideChatDisabledReason: props.sideChatDisabledReason ?? null, compactSlashCommandAvailable, selectedProvider, selectedProviderSkills, @@ -1180,8 +1153,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeComposerMenuItemRef.current = activeComposerMenuItem; }); - const nonPersistedComposerImageIdSet = new Set(nonPersistedComposerImageIds); - const isComposerApprovalState = activePendingApproval !== null; // A send declines the approvals shown here to redirect the agent, so it steers the waiting // turn instead of queueing behind it. Approvals the typing guard holds stay unseen and pending. @@ -1196,7 +1167,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isChoiceOnlyPendingQuestion = activePendingProgress?.activeQuestion?.allowCustomAnswer === false; const showComposerTopDrawer = - isComposerApprovalState || pendingUserInputs.length > 0 || (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null); const showCollapsedMobilePromptRow = isComposerCollapsedMobile; @@ -1234,7 +1204,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hiddenBlockCount: restingControlsHiddenBlockCount, iconOnlyBlockCount: restingControlsIconOnlyBlockCount, controlsVisible: restingControlsVisible, - } = useRestingComposerControlsLayout(restingControlsHost ?? inlineRestingControlsHost); + } = useRestingComposerControlsLayout(inlineRestingControlsHost); const { attachControls: attachExpandedComposerControls, hiddenBlockCount: expandedControlsHiddenBlockCount, @@ -1258,7 +1228,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) phase === "running" || isSendBusy || isSendDisabled || - isConnecting || noProviderAvailable || projectSelectionRequired || environmentUnavailable !== null || @@ -1379,6 +1348,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) importContextFragmentStable, } = useComposerContextImport({ environmentId, + linkedEnvironments, activeThread, activeThreadId, composerDraftTarget, @@ -1390,6 +1360,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerContextRecords, composerImages, composerFilesRef, + promptRef, pendingThreadImportsRef, addComposerFilesToDraft, }); @@ -1522,43 +1493,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerCursor(cursor); resetComposerTrigger(trigger); setIsDragOverComposer(false); - setIsComposerScrollCollapsed(false); } // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes composer form resizes to switch the footer and primary actions between compact layouts useLayoutEffect(() => { const composerForm = composerFormRef.current; if (!composerForm) return; - const measureComposerFormWidth = () => composerForm.clientWidth; - const measureFooterCompactness = () => { - const composerFormWidth = measureComposerFormWidth(); - const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, { - hasWideActions: composerFooterHasWideActions, - }); - const primaryActionsCompact = - footerCompact && - shouldUseCompactComposerPrimaryActions(composerFormWidth, { - hasWideActions: composerFooterHasWideActions, - }); - return { - primaryActionsCompact, - footerCompact, - }; + const measurePrimaryActionsCompact = () => { + const composerFormWidth = composerForm.clientWidth; + const options = { hasWideActions: composerFooterHasWideActions }; + return ( + shouldUseCompactComposerFooter(composerFormWidth, options) && + shouldUseCompactComposerPrimaryActions(composerFormWidth, options) + ); }; - const initialCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); - setIsComposerFooterCompact(initialCompactness.footerCompact); + setIsComposerPrimaryActionsCompact(measurePrimaryActionsCompact()); return observeResize(composerForm, () => { - const nextCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact((previous) => - previous === nextCompactness.primaryActionsCompact - ? previous - : nextCompactness.primaryActionsCompact, - ); - setIsComposerFooterCompact((previous) => - previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, - ); + setIsComposerPrimaryActionsCompact(measurePrimaryActionsCompact()); }); }, [composerFooterHasWideActions]); @@ -1574,31 +1526,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) getComposerDraft(attachmentDraftTarget)?.persistedAttachments ?? []; try { const currentPersistedAttachments = getPersistedAttachmentsForThread(); - const existingPersistedById = new Map( - currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), - ); - const stagedAttachmentById = new Map(); - await Promise.all( - composerImages.map(async (image) => { - try { - const dataUrl = await readFileAsDataUrl(image.file); - stagedAttachmentById.set(image.id, { - id: image.id, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl, - ...(image.source ? { source: image.source } : {}), - }); - } catch { - const existingPersisted = existingPersistedById.get(image.id); - if (existingPersisted) { - stagedAttachmentById.set(image.id, existingPersisted); - } - } - }), + const serialized = await serializeComposerImages( + composerImages, + currentPersistedAttachments, ); - const serialized = Array.from(stagedAttachmentById.values()); if (cancelled) return; syncComposerDraftPersistedAttachments(attachmentDraftTarget, serialized); } catch { @@ -1629,16 +1560,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) syncComposerDraftPersistedAttachments, ]); - const expandComposerForEditorChange = () => { - suppressActiveComposerScrollGesture( - composerScrollGestureRef.current, - window.performance.now(), - COMPOSER_SCROLL_GESTURE_RESET_MS, - ); - setIsComposerScrollCollapsed(false); - }; - const expandComposerForEditorChangeStable = useStableCallback(expandComposerForEditorChange); - const retainedContextPayloads = useRetainedContextPayloads(composerDraftTargetKey); const onPromptChange = ( @@ -1648,7 +1569,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cursorAdjacentToMention: boolean, contextIds: string[], ) => { - expandComposerForEditorChange(); refreshWorkspaceSnapshotOnTyping(); if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; @@ -1933,6 +1853,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.command === "side" || item.command === "btw") { + if (props.sideChatDisabledReason) return; + const applied = applyPromptReplacement( + trigger.rangeStart, + trigger.rangeEnd, + `/${item.command} `, + { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd) }, + ); + if (applied) setComposerHighlightedItemId(null); + return; + } if (!planModeUiEnabled) return; void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { @@ -2093,7 +2024,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if ( isSendBusy || isSendDisabled || - isConnecting || noProviderAvailable || environmentUnavailable !== null || phase === "running" @@ -2210,7 +2140,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingUserInputs.length > 0 || phase === "running" || isSendBusy || - isConnecting || !activeThreadId ) { return; @@ -2318,74 +2247,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) importContextRecords, expandMobileComposer: expandMobileComposerStable, }); - const hasBlockingComposerTopDrawer = - activePendingApproval !== null || pendingUserInputs.length > 0; - const hasImageAttachmentAttention = standaloneComposerImages.some((image) => { - const upload = uploadsByImageId[image.id]; - const failedInCurrentEnvironment = - supportsAttachmentUploads && - upload?.status === "failed" && - upload.environmentId === environmentId; - return nonPersistedComposerImageIdSet.has(image.id) && !failedInCurrentEnvironment; - }); - const composerHasExpandedChrome = - showComposerTopDrawer || - composerMenuOpen || - isStashMenuOpen || - isDragOverComposer || - isPreparingWorktree || - showProviderUnavailable || - projectSelectionRequired || - environmentUnavailable !== null || - composerSubmissionError !== null || - providerInputSubmissionError !== null || - hasImageAttachmentAttention || - preparingComposerImages.length > 0; - const isComposerResting = shouldUseRestingComposerLayout({ - isExistingThread: routeKind === "server" && activeThreadId !== null, - isMobileViewport, - isScrollCollapsed: isComposerScrollCollapsed, - hasExpandedChrome: composerHasExpandedChrome, - hasMultilinePrompt, - timelineOverflows, - }); - const expandedComposerImages = isComposerResting - ? standaloneComposerImages.filter((image) => pendingSnapShotIdSet.has(image.id)) - : standaloneComposerImages; - const composerControlsCollapsed = isComposerResting || isComposerCollapsedMobile; - const showInlineRestingControls = composerControlsCollapsed && restingControlsHost === null; - const composerControlsVisibleInStrip = - composerControlsCollapsed && restingControlsHost !== null && restingControlsVisible; - const composerControlsHidden = composerControlsCollapsed && !restingControlsVisible; + const hasBlockingComposerTopDrawer = pendingUserInputs.length > 0; + const composerControlsCollapsed = isComposerCollapsedMobile; + // The collapsed mobile web composer keeps a second controls row inside the card. + const showInlineRestingControls = isComposerCollapsedMobile; + const composerControlsHidden = showInlineRestingControls && !restingControlsVisible; if (composerControlsHidden && isComposerModelPickerOpen) { setIsComposerModelPickerOpen(false); } - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reports whether resting controls show in the strip to ChatView before paint - useLayoutEffect(() => { - onRestingControlsVisibilityChange(composerControlsVisibleInStrip); - }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reports the resting state to ChatView's ref before paint - useLayoutEffect(() => { - onRestingChange(isComposerResting); - }, [isComposerResting, onRestingChange]); - const [restingActionsElement, setRestingActionsElement] = useState(null); - const [restingActionsWidth, setRestingActionsWidth] = useState(null); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes resizes of the resting actions DOM node to measure its width - useLayoutEffect(() => { - if (!isComposerResting || !restingActionsElement) return; - const measure = () => setRestingActionsWidth(restingActionsElement.offsetWidth); - measure(); - return observeResize(restingActionsElement, measure); - }, [isComposerResting, restingActionsElement]); - const showMoreRestingImages = () => { - if (isComposerCollapsedMobile) { - expandMobileComposer(); - return; - } - setIsComposerScrollCollapsed(false); - setIsComposerFocused(true); - scheduleComposerFocus(); - }; const collapsedComposerImagePreviews = standaloneComposerImages.length > 0 ? ( ) : null; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const composerMainSurfaceRef = useComposerRestingTransition( composerControlsCollapsed, - isComposerResting, restingComposerControlsRef, onComposerOverlayHeightChange, panelAnimationsActive, panelAnimationDurationMs, ); - const canTrackComposerScrollGesture = - routeKind === "server" && activeThreadId !== null && !isMobileViewport; - const canScrollCollapseComposer = - canTrackComposerScrollGesture && - settings.composerCollapseOnScroll && - !hasMultilinePrompt && - !composerHasExpandedChrome; - // Clear rather than mask: once eligibility drops, the composer stays expanded until the next - // scroll gesture even if it becomes eligible again. - if (!canScrollCollapseComposer && isComposerScrollCollapsed) { - setIsComposerScrollCollapsed(false); - } - const composerScrollCollapseEligible = canScrollCollapseComposer && !isComposerResting; - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed scroll-collapse eligibility to a ref read by the wheel listener - useLayoutEffect(() => { - composerScrollCollapseEligibleRef.current = composerScrollCollapseEligible; - }); - - // Window activation re-fires focus; ignore that frame to keep the composer collapsed. - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener and animation frame that ignore activation focus while the composer is collapsed - useEffect(() => { - if (!isComposerScrollCollapsed) return; - let frame: number | null = null; - const onWindowFocus = () => { - windowRefocusInFlightRef.current = true; - if (frame !== null) window.cancelAnimationFrame(frame); - frame = window.requestAnimationFrame(() => { - frame = null; - windowRefocusInFlightRef.current = false; - }); - }; - window.addEventListener("focus", onWindowFocus); - return () => { - window.removeEventListener("focus", onWindowFocus); - if (frame !== null) window.cancelAnimationFrame(frame); - windowRefocusInFlightRef.current = false; - }; - }, [isComposerScrollCollapsed]); - - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a capture-phase document wheel listener that collapses the composer on timeline scroll - useEffect(() => { - if (!canTrackComposerScrollGesture) return; - - const finishScrollGesture = () => { - if (composerScrollCollapseTimeoutRef.current !== null) { - window.clearTimeout(composerScrollCollapseTimeoutRef.current); - } - composerScrollCollapseTimeoutRef.current = null; - resetComposerScrollGesture(composerScrollGestureRef.current); - }; - const handleTimelineWheel = (event: WheelEvent) => { - if (event.ctrlKey || !(event.target instanceof Element)) { - return; - } - - const scrollNode = getTimelineScrollableNode(); - if (!scrollNode) return; - const targetsTimeline = isTimelineScrollTarget(event.target, scrollNode, event.deltaY); - if ( - !scrollNode.contains(event.target) && - !composerScrollGestureRef.current.collapseSuppressed - ) - return; - - if (composerScrollCollapseTimeoutRef.current !== null) { - window.clearTimeout(composerScrollCollapseTimeoutRef.current); - } - composerScrollCollapseTimeoutRef.current = window.setTimeout( - finishScrollGesture, - COMPOSER_SCROLL_GESTURE_RESET_MS, - ); - - const canScrollInGestureDirection = - targetsTimeline && - (event.deltaY < 0 - ? scrollNode.scrollTop > 0 - : scrollNode.scrollTop < scrollNode.scrollHeight - scrollNode.clientHeight); - const deltaPx = - Math.abs(event.deltaY) * - (event.deltaMode === WheelEvent.DOM_DELTA_LINE - ? 16 - : event.deltaMode === WheelEvent.DOM_DELTA_PAGE - ? scrollNode.clientHeight - : 1); - const shouldCollapse = recordComposerScrollGestureEvent(composerScrollGestureRef.current, { - now: window.performance.now(), - deltaPx, - collapseThresholdPx: COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX, - collapseEligible: targetsTimeline && composerScrollCollapseEligibleRef.current, - canScrollInGestureDirection, - scrollsTowardLogicalEnd: event.deltaY > 0 && isTimelineAtLogicalEnd(), - }); - if (!shouldCollapse) { - return; - } - - setIsComposerScrollCollapsed(true); - }; - - document.addEventListener("wheel", handleTimelineWheel, { capture: true, passive: true }); - return () => { - document.removeEventListener("wheel", handleTimelineWheel, true); - finishScrollGesture(); - }; - }, [ - canTrackComposerScrollGesture, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - setIsComposerScrollCollapsed, - ]); - - const restingHiddenBlockCount = composerControlsCollapsed + const restingHiddenBlockCount = showInlineRestingControls ? restingControlsHiddenBlockCount - : expandedControlsHiddenBlockCount; - const iconOnlyBlockCount = composerControlsCollapsed + : composerControlsCollapsed + ? 0 + : expandedControlsHiddenBlockCount; + const iconOnlyBlockCount = showInlineRestingControls ? restingControlsIconOnlyBlockCount - : expandedControlsIconOnlyBlockCount; + : composerControlsCollapsed + ? 0 + : expandedControlsIconOnlyBlockCount; const composerControls = ( ); - // An open approval or question owns the space above the composer. Only a machine problem, + // An open question owns the space above the composer. Only a machine problem, // which is why an answer could not go through, stays beside it. const shelfItems = hasBlockingComposerTopDrawer ? props.bannerItems.filter( @@ -2598,7 +2356,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ): boolean => { if ( text.length === 0 || - isConnecting || activePendingIsResponding || (pendingUserInputs.length > 0 && !activePendingProgress?.activeQuestion) || projectSelectionRequired || @@ -2671,7 +2428,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingUserInputs, supportsQuestionAttachments, isLiteralPendingAnswer, - isConnecting, projectSelectionRequired, questionAttachmentTarget, attachmentTargetKey, @@ -2871,7 +2627,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useThreadContextDrop(composerFormRef, environmentId, attachmentTargetKey, (refs, isActive) => { const records = refs.flatMap((ref) => { - if (ref.environmentId !== environmentId) { + // The agent reads a linked machine's thread itself, like the `@` picker's; any other + // machine's comes in as its transcript. + if (ref.environmentId !== environmentId && !linkedEnvironments.has(ref.environmentId)) { void importDroppedThread( ref, () => isActive() && attachmentTargetKeyRef.current === attachmentTargetKey, @@ -2885,7 +2643,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (questionAttachmentTarget !== null) { const canAnswer = canOperateThread && - !isConnecting && !activePendingIsResponding && activePendingProgress?.activeQuestion?.allowCustomAnswer !== false; const text = `${records @@ -3005,7 +2762,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; } - setIsComposerScrollCollapsed(false); setIsComposerFocused(true); } setIsComposerModelPickerOpen(true); @@ -3021,23 +2777,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, - restoreAfterTimelineReachedEnd, - collapseForTimelineScrollKey: (key) => { - const scrollNode = getTimelineScrollableNode(); - if ( - composerScrollCollapseEligibleRef.current && - scrollNode && - shouldCollapseComposerForScrollKey({ - key, - scrollTop: scrollNode.scrollTop, - scrollHeight: scrollNode.scrollHeight, - clientHeight: scrollNode.clientHeight, - isAtLogicalEnd: isTimelineAtLogicalEnd(), - }) - ) { - setIsComposerScrollCollapsed(true); - } - }, addDroppedFiles: (files: File[]) => { void addComposerAttachments(files).then((inserted) => { if (!inserted) focusComposer(); @@ -3087,15 +2826,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) direction, }); if (!change) return; - if (change.modelOptions) { - useComposerDraftStore - .getState() - .setProviderModelOptions(composerDraftTarget, selectedProvider, change.modelOptions, { - instanceId: selectedInstanceId, - model: selectedModel, - persistSticky: true, - }); - } + useComposerDraftStore + .getState() + .setProviderModelOptions(composerDraftTarget, selectedProvider, change.modelOptions, { + instanceId: selectedInstanceId, + model: selectedModel, + persistSticky: true, + }); if (change.prompt !== promptRef.current) { applyPromptReplacementStable(0, promptRef.current.length, change.prompt, { expandedCursorAfterReplace: change.cursor, @@ -3114,10 +2851,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; } - flushSync(() => { - setIsComposerScrollCollapsed(false); - setIsComposerFocused(true); - }); + flushSync(() => setIsComposerFocused(true)); const shell = composerFormRef.current?.closest('[data-slot="composer-shell"]'); const trigger = Array.from( shell?.querySelectorAll( @@ -3129,6 +2863,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); if (!trigger) return; trigger.focus({ preventScroll: true }); + if (command === "composer.mode") markOpenToPermissions(trigger); + if (command === "composer.effort") markOpenToEffort(trigger); trigger.click(); }, compactContext: compactThreadContextStable, @@ -3208,7 +2944,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) threadContexts: composerThreadContexts, environmentContexts: composerEnvironmentContexts, selectedPromptEffort, - selectedModelOptionsForDispatch, selectedModelSelection, multipleModelSelections: routeKind === "draft" && multipleModelSelections !== null @@ -3267,7 +3002,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusComposer, environmentId, primaryEnvironmentId, - isConnecting, isChoiceOnlyPendingQuestion, isLiteralPendingAnswer, pendingUserInputs.length, @@ -3282,7 +3016,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedInstanceId, reportedModelSelection, settings.planModeEnabled, - selectedModelOptionsForDispatch, selectedModelSelection, multipleModelSelections, setMultipleModelSelections, @@ -3295,10 +3028,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode, planModeUiEnabled, compactThreadContextStable, - restoreAfterTimelineReachedEnd, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - setIsComposerScrollCollapsed, ], ); @@ -3307,11 +3036,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) !isComposerCollapsedMobile && (uncommittedSnapShotIds.length > 0 || composerVideos.length > 0 || - expandedComposerImages.length > 0 || + standaloneComposerImages.length > 0 || preparingComposerImages.length > 0) ? ( { - const target = event.target; - if (isInsideRestingComposerControlScope(target)) return; - if (isInsideCollapsedComposerControls(target)) return; - if (!(target instanceof Element)) return; - const isInteractive = Boolean( - target.closest('button, a, input, select, [role="button"], [role="menuitem"]'), - ); - if (isInteractive) return; - - setIsComposerScrollCollapsed(false); - if (isComposerResting && !target.closest('[data-testid="composer-editor"]')) { - event.preventDefault(); - setIsComposerFocused(true); - scheduleComposerFocus(); - } - }} onFocusCapture={(event) => { const activeElement = event.target; if ( @@ -3386,9 +3098,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (isInsideCollapsedComposerControls(activeElement)) { return; } - if (!windowRefocusInFlightRef.current) { - setIsComposerScrollCollapsed(false); - } if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; @@ -3424,61 +3133,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) data-chat-composer-form="true" {...threadContextDropTargetProps()} > - {composerControlsCollapsed && restingControlsHost - ? createPortal( -
- {composerControls} -
, - restingControlsHost, - ) - : null} {showComposerTopDrawer ? ( - - {activePendingApproval ? ( - - - - - - - - - - - - ) : pendingUserInputs.length > 0 ? ( + + {pendingUserInputs.length > 0 ? (
{isStashMenuOpen && !composerMenuOpen && ( @@ -3647,14 +3304,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {composerAttachmentFileList} -
+
{previewFile ? ( - {isComposerResting ? collapsedComposerImagePreviews : null} {showMobilePendingAnswerActions ? (
0 && "pt-2", - isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", showMobilePendingAnswerActions && "hidden sm:flex", - isComposerResting && - "absolute right-px z-10 h-12 w-auto gap-0 py-0 sm:gap-0 sm:py-0", - isComposerResting && - (showInlineRestingControls ? "bottom-[calc(2rem+1px)]" : "bottom-px"), )} > + {composerAttachButton ?
{composerAttachButton}
: null}
- {composerControlsCollapsed ? null : composerControls} -
- -
- {composerAttachButton} +
+ {composerControls} +
0} isSendBusy={isSendBusy} sendDisabledReason={sendDisabledReason} - isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || noProviderAvailable || @@ -3848,7 +3473,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} canResume={showResumeAction} - preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting} + preserveComposerFocusOnPointerDown={isMobileViewport} isEditingQueuedMessage={isEditingQueuedMessage} onSubmitMessage={handleSubmitMessageStable} onResume={onResume} @@ -3858,9 +3483,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compactBeforeSendTokens={props.resumeCompactionTokens} keepFullHistory={props.keepFullHistory} onToggleKeepFullHistory={props.onToggleKeepFullHistory} - compactDisabled={ - compactDisabled || noProviderAvailable || isSendBusy || isConnecting - } + compactDisabled={compactDisabled || noProviderAvailable || isSendBusy} compactDisabledReason={resolvedCompactDisabledReason} {...(compactCommandAvailable ? { onCompactContext: compactThreadContextStable } @@ -3876,7 +3499,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) className="absolute bottom-2 inset-x-4 min-w-0" >
void; onOpenProjectSettings?: (() => void) | undefined; + onFindInThread?: ((threadRef: ScopedThreadRef) => void) | undefined; } function resolveRenameCommit(input: { @@ -80,6 +82,7 @@ export const ChatHeader = memo(function ChatHeader({ rightPanelOpen, onNewThreadInProject, onOpenProjectSettings, + onFindInThread, }: ChatHeaderProps) { const activeProjectName = activeProject?.isScratch ? "No project" : activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; @@ -196,6 +199,7 @@ export const ChatHeader = memo(function ChatHeader({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, onStartRename: startRename, + onFind: onFindInThread, canShareWithGuest, }); const titleButtonRef = useRef(null); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 9d59e9fa76fdc..e5fbddf56c769 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,5 +1,5 @@ import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; -import { memo, type ReactNode } from "react"; +import { memo, useRef } from "react"; import { EllipsisIcon } from "lucide-react"; import { Menu, @@ -10,18 +10,16 @@ import { MenuTrigger, } from "../ui/menu"; import { ComposerControl, ComposerControlIcon } from "./ComposerControl"; -import { useComposerMenuProps } from "./composerEventScope"; +import { focusRequestedPermission, useComposerMenuProps } from "./composerEventScope"; import { useComposerMenuState } from "./useComposerMenuState"; +import { ComposerPermissionsSection, type RuntimeModeOption } from "./ComposerFooterControls"; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; - runtimeModeOptions: ReadonlyArray<{ - readonly mode: RuntimeMode; - readonly label: string; - }>; + runtimeModeOptions: ReadonlyArray; showInteractionModeToggle: boolean; - traitsMenuContent?: ReactNode; + showPermissions?: boolean; size?: "sm" | "xs"; /** Close the portaled popup when its trigger hides. */ hidden?: boolean; @@ -31,30 +29,28 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls const composerFloatingLayerProps = useComposerMenuProps(); const size = props.size ?? "sm"; const [open, setOpen] = useComposerMenuState(props.hidden); + const triggerRef = useRef(null); return ( - + focusRequestedPermission(isOpen, triggerRef.current)} + > } > - {props.traitsMenuContent ? ( - <> - {props.traitsMenuContent} - - - ) : null} {props.showInteractionModeToggle ? ( <>
Mode
@@ -68,23 +64,16 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls Chat Plan - + {props.showPermissions !== false ? : null} ) : null} -
Access
- { - if (!value || value === props.runtimeMode) return; - props.onRuntimeModeChange(value as RuntimeMode); - }} - > - {props.runtimeModeOptions.map((option) => ( - - {option.label} - - ))} - + {props.showPermissions === false ? null : ( + + )}
); diff --git a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx index 6680b809cfbd8..5767b2464b663 100644 --- a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx +++ b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx @@ -666,7 +666,8 @@ export function ComposerAttachButton({ /> } > - + {/* The clip's weight sits in its upper loop; 1px down reads as centered on the row. */} + Attach files diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index ef07497171ee2..0710f2534dada 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -33,27 +33,36 @@ const variantColors: Record = { "[--chat-composer-attached-outline:color-mix(in_srgb,var(--warning)_28%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--warning)_8%,transparent)]", }; +/** + * An attached surface tucks its bottom under the card below it. A detached one is a whole card + * of its own, for a notice that sits apart from the composer. + */ function Surface({ variant = "default", + detached = false, className, ...props }: ComponentProps<"div"> & { variant?: ComposerBannerVariant; + detached?: boolean; }) { return (
& { +}: ComponentProps & { density?: "default" | "comfortable" | "spacious"; - variant?: ComposerBannerVariant; width?: "fill" | "content"; }) { return ( diff --git a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx index dc23c4452b4f7..bf83b014b7f6f 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx @@ -1,11 +1,16 @@ import { InfoIcon } from "lucide-react"; -import { expect, it } from "vite-plus/test"; +import type { ReactNode } from "react"; +import { expect, it, vi } from "vite-plus/test"; import "~/index.css"; import { render } from "../../../test/render"; import { ComposerBanner } from "./ComposerBanner"; -import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack"; +import { + ComposerBannerStack, + NoticeText, + type ComposerBannerStackItem, +} from "./ComposerBannerStack"; function notice(title: string, group?: ComposerBannerStackItem["group"]): ComposerBannerStackItem { return { id: title, variant: "default", icon: , title, ...(group ? { group } : {}) }; @@ -33,3 +38,88 @@ it("leaves the next attachment its own top edge when there are no notices", asyn expect(surface.borderTopWidth).toBe("1px"); expect(surface.borderTopLeftRadius).not.toBe("0px"); }); + +function noticeRow(width: string, children: ReactNode) { + return ( +
+ + {children} + +
+ ); +} + +it("leaves out the details button when only a link's hit area reaches past the text", async () => { + // Phones lay out in device pixels, so a range can measure one float32 step (2^-17 px at + // this size) wider than its element. Headless Chromium's rects agree exactly. + const rangeRect = Range.prototype.getBoundingClientRect; + using _rangeRect = vi + .spyOn(Range.prototype, "getBoundingClientRect") + .mockImplementation(function (this: Range) { + const rect = rangeRect.call(this); + return new DOMRect(rect.x, rect.y, rect.width + 2 ** -17, rect.height); + }); + // The flicker is a details button that mounts and then unmounts, so record every insertion. + const inserted: Node[] = []; + using observing = new DisposableStack(); + const mutations = new MutationObserver((records) => + inserted.push(...records.flatMap((record) => [...record.addedNodes])), + ); + mutations.observe(document.body, { childList: true, subtree: true }); + observing.defer(() => mutations.disconnect()); + await using screen = await render( + noticeRow( + "20rem", + <> + Needs input{" "} + + , + ), + ); + + await expect.element(screen.getByText("Review")).toBeVisible(); + const details = [ + ...inserted, + ...mutations.takeRecords().flatMap((record) => [...record.addedNodes]), + ].filter( + (node) => + node instanceof Element && + (node.matches('[aria-label="Show details"]') || + node.querySelector('[aria-label="Show details"]')), + ); + expect(details).toEqual([]); +}); + +it("shows the details button while text or nested text is cut, until it fits again", async () => { + await using screen = await render(noticeRow("4rem", "Waiting on a subagent with a long name")); + const details = screen.getByRole("button", { name: "Show details" }); + await expect.element(details).toBeInTheDocument(); + + await screen.rerender(noticeRow("4rem", "Ready")); + await expect.element(details).not.toBeInTheDocument(); + + await screen.rerender(noticeRow("4rem", "Waiting on a subagent with a long name")); + await expect.element(details).toBeInTheDocument(); + + screen.getByTestId("row").element().style.width = "40rem"; + await expect.element(details).not.toBeInTheDocument(); + + await screen.rerender( + noticeRow( + "40rem", + A long nested name, + ), + ); + await expect.element(details).toBeInTheDocument(); + + await screen.rerender( + + {noticeRow("4rem", "Waiting on a subagent with a long name")} + , + ); + await expect.element(details).toBeInTheDocument(); + await screen.rerender({noticeRow("4rem", "Ready")}); + await expect.element(details).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 086826cde86db..61778f817dc37 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -7,7 +7,7 @@ import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner"; import { observeResize } from "~/lib/observeResize"; /** - * Where a notice sits above the composer. The machine comes first, then what needs you; the + * Where a notice sits in the stack. The machine comes first, then what needs you; the * order is fixed so rows never trade places as notices come and go. */ export type ComposerBannerGroup = "environment" | "attention"; @@ -27,28 +27,40 @@ export interface ComposerBannerStackItem { const GROUPS = ["environment", "attention"] as const; +const FADE_IN = + "transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none"; + /** * Notices about sending from this composer, each its own attached card. Consecutive cards join - * edge to edge, and the last joins the composer or the drawer below it. + * edge to edge, and the last joins the composer or the drawer below it. Detached cards stand + * apart instead, for layouts where the composer must not move when a notice appears. */ export function ComposerBannerStack({ items, + detached = false, }: { readonly items: ReadonlyArray; + readonly detached?: boolean; }) { - return GROUPS.flatMap((group) => + const ordered = GROUPS.flatMap((group) => items.filter((item) => (item.group ?? "attention") === group), - ).map((item) => ( - + ); + if (detached) { + return ordered.map((item) => ( +
+ +
+ )); + } + return ordered.map((item) => ( + )); } +const SUBPIXEL_ROUNDING = 0.001; + export function NoticeText({ children, className, @@ -60,27 +72,55 @@ export function NoticeText({ }) { const textRef = useRef(null); const detailsRef = useRef(null); - const [showDetails, setShowDetails] = useState(false); + const [detailsAtWidth, setDetailsAtWidth] = useState(null); + const showDetails = detailsAtWidth !== null; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures text overflow in the DOM via ResizeObserver and MutationObserver to show the details button useLayoutEffect(() => { const text = textRef.current; if (!text) return; + const banner = text.closest("[data-slot=composer-banner]"); + const range = document.createRange(); const measure = () => { // Ignore the space taken by the details button itself so it cannot // sustain its own overflow after the text would otherwise fit. const recoveredWidth = detailsRef.current ? detailsRef.current.offsetWidth + 4 : 0; const hidden = getComputedStyle(text).position === "absolute"; - setShowDetails( + const overflowing = hidden || - [text, ...text.querySelectorAll("*")].some( - (element) => element.scrollWidth > element.clientWidth + recoveredWidth, - ), + [text, ...text.querySelectorAll("*")].some((element) => { + // A range spans laid-out content only; scrollWidth also counts positioned layers + // such as a link's touch hit area, which overflow without cutting any text. + // The two rects can disagree by float rounding (~0.00001px on phones); real + // overflow is at least one layout unit, 1/64 of a device pixel. + range.selectNodeContents(element); + return ( + range.getBoundingClientRect().width > + element.getBoundingClientRect().width + recoveredWidth + SUBPIXEL_ROUNDING + ); + }); + // The button's width can wrap the banner's actions onto a second row, which gives + // the text room and removes the button, which unwraps the row again. Once shown, + // the button stays until the banner itself gets wider than where it appeared. + const width = banner?.clientWidth ?? text.clientWidth; + setDetailsAtWidth((shownAt) => + overflowing + ? (shownAt ?? width) + : banner && shownAt !== null && width <= shownAt + ? shownAt + : null, ); }; measure(); - const stopObserving = observeResize(text, measure); - const mutations = new MutationObserver(measure); + const stopObserving = observeResize([text, banner ?? text], measure); + let content = text.textContent; + const mutations = new MutationObserver(() => { + if (text.textContent !== content) { + content = text.textContent; + setDetailsAtWidth(null); + } + measure(); + }); mutations.observe(text, { childList: true, subtree: true, characterData: true }); return () => { stopObserving(); @@ -125,11 +165,18 @@ export function NoticeText({ ); } -function NoticeCard({ item }: { readonly item: ComposerBannerStackItem }) { +function NoticeCard({ + item, + detached = false, +}: { + readonly item: ComposerBannerStackItem; + readonly detached?: boolean; +}) { return ( @@ -162,7 +209,7 @@ function NoticeCard({ item }: { readonly item: ComposerBannerStackItem }) { ) : null} {item.actions || item.onDismiss ? ( - + {item.actions} {item.onDismiss ? (