{/* 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/ProviderUpdatesAction.browser.test.tsx b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx new file mode 100644 index 0000000000000..d6ca540686f0e --- /dev/null +++ b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx @@ -0,0 +1,153 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + AuthProvidersManageScope, + type AuthSessionState, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { useEffect } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; + +const state = vi.hoisted(() => ({ + environments: [] as { + environmentId: EnvironmentId; + label: string; + connection: { phase: "connected" | "disconnected" }; + }[], + providers: new Map>(), + requests: [] as EnvironmentId[], + sessions: new Map>>(), +})); + +vi.mock(import("~/state/environments"), () => ({ + useEnvironments: (() => ({ environments: state.environments })) as never, + useEnvironmentProviders: () => state.providers, +})); +vi.mock(import("@t3tools/client-runtime/state/session"), async (original) => { + const actual = await original(); + return { + ...actual, + createEnvironmentSessionAtoms: (( + ...args: Parameters + ) => ({ + ...actual.createEnvironmentSessionAtoms(...args), + sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, + })) as typeof actual.createEnvironmentSessionAtoms, + }; +}); +vi.mock(import("~/state/server"), () => ({ + serverEnvironment: { updateProvider: {} } as never, +})); +vi.mock(import("~/state/use-atom-command"), () => ({ + useAtomCommand: (() => + async ({ environmentId }: { environmentId: EnvironmentId }) => { + state.requests.push(environmentId); + return AsyncResult.success(undefined); + }) as never, +})); + +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { useProviderUpdates } from "./ProviderUpdatesAction"; + +const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-21T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + versionAdvisory: { + status: "behind_latest", + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateCommand: "npm install -g @openai/codex", + canUpdate: true, + checkedAt: "2026-10-21T00:00:00.000Z", + message: "Update available.", + }, +}; + +function session( + scopes: NonNullable, +): AsyncResult.AsyncResult { + return AsyncResult.success({ + authenticated: true, + scopes, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-access-token"], + sessionCookieName: "t3_session", + }, + }); +} + +function setup() { + state.environments = [ + { environmentId: EnvironmentId.make("read-only"), label: "Read only" }, + { environmentId: EnvironmentId.make("writable"), label: "Writable" }, + { environmentId: EnvironmentId.make("unresolved"), label: "Unresolved" }, + ].map((environment) => ({ ...environment, connection: { phase: "connected" } })); + state.providers = new Map( + state.environments.map(({ environmentId }) => [environmentId, [provider]]), + ); + state.requests = []; + const sessions = new Map>>( + [ + [state.environments[0]!.environmentId, Atom.make(session(["orchestration:read"]))], + [state.environments[1]!.environmentId, Atom.make(session([AuthProvidersManageScope]))], + [ + state.environments[2]!.environmentId, + Atom.make>( + AsyncResult.initial(), + ), + ], + ], + ); + state.sessions = sessions; + return { sessions }; +} + +it("bulk provider updates exclude connections without a confirmed management grant", async () => { + const fixture = setup(); + let updates!: ReturnType; + function Probe() { + const nextUpdates = useProviderUpdates(); + useEffect(() => { + updates = nextUpdates; + }, [nextUpdates]); + return null; + } + await using _screen = await render( + + + , + ); + + const writableId = state.environments[1]!.environmentId; + expect(updates.machines.map(({ environmentId }) => environmentId)).toEqual([writableId]); + await act(async () => { + await updates.updateAll({ quiet: true }); + }); + expect(state.requests).toEqual([writableId]); + + await act(() => { + appAtomRegistry.set(fixture.sessions.get(writableId)!, session(["orchestration:read"])); + }); + expect(updates.machines).toEqual([]); + state.requests = []; + await act(async () => { + await updates.updateAll({ quiet: true }); + }); + expect(state.requests).toEqual([]); +}); diff --git a/apps/web/src/components/ProviderUpdatesAction.tsx b/apps/web/src/components/ProviderUpdatesAction.tsx index 8d45d66b71213..57573806e45db 100644 --- a/apps/web/src/components/ProviderUpdatesAction.tsx +++ b/apps/web/src/components/ProviderUpdatesAction.tsx @@ -1,9 +1,10 @@ -import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; +import { AuthProvidersManageScope, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import { useRef, useState } from "react"; import { useEnvironmentProviders, useEnvironments } from "~/state/environments"; import { serverEnvironment } from "~/state/server"; +import { useEnvironmentsWithScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { canOneClickUpdateProviderCandidate, @@ -17,13 +18,17 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export function useProviderUpdates() { const { environments } = useEnvironments(); + const manageableIds = useEnvironmentsWithScope(environments, AuthProvidersManageScope); const providersByEnvironment = useEnvironmentProviders(); const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false, }); const machines = environments.flatMap((environment) => { const providers = providersByEnvironment.get(environment.environmentId) ?? []; - if (environment.connection.phase !== "connected") { + if ( + environment.connection.phase !== "connected" || + !manageableIds.has(environment.environmentId) + ) { return []; } const candidates = collectProviderUpdateCandidates(providers).filter((candidate) => 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.browser.test.tsx b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx index c388ec33af0b5..5dc65e57f1ca3 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx @@ -265,4 +265,11 @@ describe("root reopen shortcut", () => { expect(useClosedViewStore.getState().entries).toEqual([]); }, ); + it("forwards address-bar focus from a focused desktop page", async () => { + setup(); + await using _shortcut = await renderShortcut(); + expect(state.setShortcuts).toHaveBeenLastCalledWith( + expect.arrayContaining([expect.objectContaining({ command: "preview.focusUrl" })]), + ); + }); }); diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index f409ce48bfeba..77cc7b3d3068d 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -43,10 +43,13 @@ import { } from "../threadRoutes"; import { toastManager } from "./ui/toast"; -const PREVIEW_FORWARDED_LAYOUT_COMMANDS = [ +const PREVIEW_FORWARDED_COMMANDS = [ + "workspace.focusNext", + "workspace.focusPrevious", "sidebar.toggle", "rightPanel.toggle", "rightPanel.toggleMaximized", + "preview.focusUrl", ] as const; const isGlobalPullRequests = (ref: ScopedThreadRef) => @@ -89,6 +92,7 @@ export function ReopenClosedViewShortcut() { environmentKnown: true, catalogReady: true, ownerExists: true, + sideChatExists: true, shellLive: true, panel, }; @@ -97,6 +101,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 +190,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, @@ -198,7 +206,7 @@ export function ReopenClosedViewShortcut() { // Guest browser processes miss app keydown listeners; desktop forwards the chords as menu actions. const commands = [ ...(hasHistory ? (["view.reopenClosed"] as const) : []), - ...PREVIEW_FORWARDED_LAYOUT_COMMANDS, + ...PREVIEW_FORWARDED_COMMANDS, ]; void preview .setForwardedShortcuts( @@ -211,7 +219,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..1d82a7628a2d1 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -2,11 +2,13 @@ import { useAtomValue } from "@effect/atom-react"; import { AuthEnvironmentMaintainScope, type AuthSessionState, + managedUpdatesDenial, sessionGrantsScope, } from "@t3tools/contracts"; import type { AsyncResult } from "effect/reactivity"; import { environmentSession } from "~/state/session"; import type { + DesktopUpdateChannel, EnvironmentId, ServerInstallation, ServerSelfUpdateCapability, @@ -22,11 +24,15 @@ 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 { useServerConfigs } from "~/state/entities"; +import { useManagedPolicy } from "~/state/managedPolicy"; +import { environmentPresentations } from "~/state/presentation"; +import { ManagedControl } from "./ManagedPolicy"; import { manualServerUpdateCommand } from "~/versionSkew"; +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 +48,37 @@ export function serverUpdateStageLabel(stage: ServerUpdateStage): string { return UPDATE_STAGE_LABELS[stage]; } +/** Why the environment's policy refuses server updates, read outside render. */ +function readUpdatesDenial(environmentId: EnvironmentId): string | undefined { + return managedUpdatesDenial( + appAtomRegistry.get(environmentPresentations.serverConfigsAtom).get(environmentId) + ?.managedPolicy, + ); +} + +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 +89,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"> & { @@ -74,6 +115,17 @@ export function useServerUpdate() { { quiet = false }: { readonly quiet?: boolean } = {}, ): Promise => { const { environmentId, serverLabel, selfUpdate, targetVersion } = target; + const denial = readUpdatesDenial(environmentId); + if (denial !== undefined) { + if (!quiet) { + toastManager.add({ + type: "error", + title: `${serverLabel} not updated`, + description: denial, + }); + } + return { status: "failed", message: denial }; + } if ( !canUpdateServer(appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId))) || pendingUpdateEnvironmentIds.has(environmentId) @@ -84,9 +136,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 +179,15 @@ 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) && + managedUpdatesDenial(serverConfigs.get(target.environmentId)?.managedPolicy) === undefined && + !refusedCrossing(target, serverConfigs.get(target.environmentId)?.environment.serverVersion), ); const handleUpdate = async () => { if (pending.current) return; @@ -205,21 +261,19 @@ 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 updatesDenial = managedUpdatesDenial(useManagedPolicy(environmentId)); const update = useServerUpdate(); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ target: installation?.kind === "npm-global" ? "update command" : "relaunch command", @@ -265,12 +319,22 @@ export function ServerUpdateAction({ serverLabel, selfUpdate, desktopAppUpdate, - threadContinuation, targetVersion, - continueThreadsAfterServerUpdate, }); }; + if (updatesDenial !== undefined) { + return appearance === "icon" ? ( + + + + ) : ( + {updatesDenial} + ); + } + if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { return ( @@ -279,6 +343,10 @@ export function ServerUpdateAction({ ); } + if (refusedCrossing({ selfUpdate, targetVersion, channel }, serverVersion)) { + return ; + } + const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null; const actionLabel = @@ -366,6 +434,9 @@ export function OutdatedServerUpdateAction({ description: `Reconnected on t3@${result.value.targetVersion}.`, }); }; + if (fromVersion !== undefined && crossesReleaseTrack(fromVersion, targetVersion)) { + return ; + } return ( - ) : scopedProjectGroup ? ( - `No threads in ${scopedProjectGroup.displayName} yet` + ) : scopedProjectGroups.length > 1 ? ( + "No threads in these projects yet" + ) : scopedProjectGroups[0] ? ( + `No threads in ${scopedProjectGroups[0].displayName} yet` ) : ( "No threads yet" )} @@ -3270,9 +3640,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) : null} - + ); } diff --git a/apps/web/src/components/SourceControlAppAccessNotice.tsx b/apps/web/src/components/SourceControlAppAccessNotice.tsx new file mode 100644 index 0000000000000..4267b53150207 --- /dev/null +++ b/apps/web/src/components/SourceControlAppAccessNotice.tsx @@ -0,0 +1,68 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { + readHostCredentialSettings, + sourceControlClients, +} from "@t3tools/client-runtime/source-control-clients"; +import { ExternalLinkIcon } from "lucide-react"; + +import { useEnvironmentSettings } from "../hooks/useSettings"; +import { cn } from "../lib/utils"; +import { useEnvironmentQuery } from "../state/query"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { T3AppIcon } from "./T3AppIcon"; +import { InlineButton } from "./ui/button"; + +/** + * Says when the server's forge app sign-in cannot reach `repository`, so requests there fall back + * to the user's own login. Renders nothing for servers that do not use the app. + */ +export function SourceControlAppAccessNotice(props: { + readonly environmentId: EnvironmentId; + readonly provider: string | undefined; + readonly host: string | undefined; + /** `owner/name`. */ + readonly repository: string; + readonly className?: string; +}) { + const definition = props.provider ? sourceControlClients.find(props.provider) : undefined; + const appSignIn = definition?.credentials?.appSignIn; + const host = (props.host ?? definition?.publicHost ?? "").toLowerCase(); + const signedIn = useEnvironmentSettings(props.environmentId, (settings) => + definition === undefined + ? false + : (readHostCredentialSettings(settings, definition.kind).signIns[host] ?? "").length > 0, + ); + const access = useEnvironmentQuery( + definition !== undefined && appSignIn?.hosts.includes(host) && signedIn + ? sourceControlEnvironment.appAccess({ + environmentId: props.environmentId, + input: { provider: definition.kind, host, repository: props.repository }, + }) + : null, + ); + if (appSignIn === undefined || access.data?.status !== "not-covered") return null; + const owner = props.repository.split("/")[0] ?? props.repository; + + return ( +
+ +

+ {appSignIn.appName} isn't installed on {owner}, so + requests here use your own GitHub login and its lower rate limit. +

+ } + > + Install on {owner} + + +
+ ); +} diff --git a/apps/web/src/components/T3AppIcon.tsx b/apps/web/src/components/T3AppIcon.tsx new file mode 100644 index 0000000000000..7e3ebef5692b1 --- /dev/null +++ b/apps/web/src/components/T3AppIcon.tsx @@ -0,0 +1,6 @@ +// The production icon, as GitHub shows the T3 Code app, whatever this build's channel. +import appIconUrl from "../../../../assets/prod/t3-black-web-apple-touch-180.png"; + +export function T3AppIcon(props: { readonly className?: string }) { + return ; +} diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index 89128ff69ac55..8b5c2e0b43a01 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -260,3 +260,73 @@ it("holds a completion through a continuation gap and notifies once it settles", await vi.advanceTimersByTimeAsync(1); expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]); }); + +it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( + "notifies for offline completion and %s once across reconnects", + async (flag) => { + await using app = setup(); + const asking = { id: "asking", hasPendingApprovals: true }; + const offline = { id: "offline", [flag]: true }; + const done = { + id: "done", + latestTurn: { + turnId: "done-turn", + state: "completed", + completedAt: "2026-09-13T07:00:00.000Z", + }, + }; + state.shells.set("one", shell({ id: "working" }, { id: "asking" }, { id: "offline" }, done)); + app.start(); + using _timers = fakeTimers(); + state.shells.set("one", shell({ id: "working" }, asking, { id: "offline" }, done)); + state.shells.set("one", { + ...shell({ id: "working" }, asking, { id: "offline" }, done), + status: "cached", + }); + + const completed = { + id: "working", + latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:05:00.000Z" }, + }; + state.shells.set("one", { + ...shell(completed, asking, offline, done), + status: "synchronizing", + }); + state.shells.set("one", shell(completed, asking, offline, done)); + + expect(TestNotification.sent.map(({ title }) => title)).toEqual([ + "Approval needed", + flag === "hasPendingApprovals" ? "Approval needed" : "Input needed", + ]); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + state.shells.set("one", { ...shell(completed, asking, offline, done), status: "cached" }); + state.shells.set("one", shell(completed, asking, offline, done)); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(TestNotification.sent.map(({ title }) => title)).toEqual([ + "Approval needed", + flag === "hasPendingApprovals" ? "Approval needed" : "Input needed", + "Thread completed", + ]); + }, +); + +it.each([false, true])( + "rechecks a pending completion after reconnect (continued: %s)", + async (continued) => { + await using app = setup(); + app.start(); + using _timers = fakeTimers(); + const completed = shell({ + latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:05:00.000Z" }, + }); + state.shells.set("one", completed); + state.shells.set("one", { ...completed, status: "cached" }); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(TestNotification.sent).toEqual([]); + state.shells.set("one", continued ? shell() : completed); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(TestNotification.sent.map(({ title }) => title)).toEqual( + continued ? [] : ["Thread completed"], + ); + }, +); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 77edccfbe7741..fdbfb7ae1f49d 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -69,6 +69,9 @@ export function createThreadNotificationGate( for (const key of handled.keys()) if (!keys.has(key)) handled.delete(key); for (const key of lastSettled.keys()) if (!keys.has(key)) lastSettled.delete(key); }, + cancelPending(): void { + for (const key of pending.keys()) cancel(key); + }, clear(): void { for (const key of pending.keys()) cancel(key); handled.clear(); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 9513ec7e1826a..3af74e1d34139 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -47,6 +47,7 @@ interface NotificationState extends ThreadNotificationObservation { interface WatchedEnvironment { unsubscribe: () => void; previous: Map; + reconnecting: boolean; readonly gate: ReturnType>; } @@ -118,6 +119,8 @@ export function startThreadNotifications( ) : status === "approval" ? ( + ) : status === "limited" ? ( + ) : status === "failed" ? ( ) : ( @@ -156,7 +159,7 @@ export function startThreadNotifications( notification.addEventListener("click", () => { notification.close(); window.focus(); - void window.desktopBridge?.revealWindow?.().catch(() => undefined); + void window.desktopBridge?.revealWindow().catch(() => undefined); void router.navigate({ to: "/$environmentId/$threadId", params: { environmentId, threadId: thread.id }, @@ -192,7 +195,11 @@ export function startThreadNotifications( } const prior = previous.get(rawThread.id); const subagentRequest = subagentRequests.get(rawThread.id) ?? null; - if (prior?.raw === rawThread && prior.subagentRequest === subagentRequest) { + if ( + !entry.reconnecting && + prior?.raw === rawThread && + prior.subagentRequest === subagentRequest + ) { next.set(rawThread.id, prior); continue; } @@ -221,6 +228,7 @@ export function startThreadNotifications( next.set(thread.id, state); changed.push([prior, state]); } + entry.reconnecting = false; entry.previous = next; entry.gate.retain(new Set(next.keys())); for (const [prior, state] of changed) { @@ -234,6 +242,7 @@ export function startThreadNotifications( const watchEnvironment = (environmentId: EnvironmentId): WatchedEnvironment => { const entry: WatchedEnvironment = { previous: new Map(), + reconnecting: false, unsubscribe: () => {}, gate: createThreadNotificationGate((threadId, kind) => { const state = entry.previous.get(threadId); @@ -252,8 +261,8 @@ export function startThreadNotifications( observeThreads(environmentId, entry, threads); return; } - entry.gate.clear(); - entry.previous = new Map(); + entry.gate.cancelPending(); + entry.reconnecting = true; }, { immediate: true }, ); @@ -314,7 +323,7 @@ export function startThreadNotifications( clearPending(); const stops: Array<() => void> = []; if (hasDesktopNotifications(nextMode)) { - const unsubscribeBadgeClear = window.desktopBridge?.onNotificationBadgeClear?.(clearPending); + const unsubscribeBadgeClear = window.desktopBridge?.onNotificationBadgeClear(clearPending); window.addEventListener("focus", clearPending); stops.push(() => { unsubscribeBadgeClear?.(); diff --git a/apps/web/src/components/ThreadStatusIndicators.linkedPr.browser.test.tsx b/apps/web/src/components/ThreadStatusIndicators.linkedPr.browser.test.tsx new file mode 100644 index 0000000000000..2f690d88f5b9d --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.linkedPr.browser.test.tsx @@ -0,0 +1,66 @@ +import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; +import { makeThreadFixture } from "../test-fixtures"; +import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators"; + +const environmentId = EnvironmentId.make("environment-test"); + +vi.mock(import("../state/entities"), async (original) => ({ + ...(await original()), + useProject: () => null, + useServerConfigs: (() => + new Map([ + [environmentId, { environment: { capabilities: { threadPullRequests: true } } }], + ])) as never, +})); + +it("updates palette PR status from linked snapshots without a legacy link or branch PR", async () => { + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "example/repo", + number: 7, + url: "https://github.com/example/repo/pull/7", + source: "manual", + linkedAt: "2026-01-01T00:00:00Z", + stack: null, + snapshot: { + state: "merged", + title: "Linked change", + headBranch: "deleted-branch", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-01-03T00:00:00Z", + }, + }; + const row = (pullRequest: ThreadPullRequestLink) => ( + + + + ); + await using screen = await render(row(link)); + await expect.element(screen.getByLabelText("PR #7 - Merged: Linked change")).toBeVisible(); + await expect.element(screen.getByLabelText("PR #7, status pending")).not.toBeInTheDocument(); + + await screen.rerender(row({ ...link, snapshot: null })); + await expect.element(screen.getByLabelText("PR #7, status pending")).toBeVisible(); + await expect + .element(screen.getByLabelText("PR #7 - Merged: Linked change")) + .not.toBeInTheDocument(); + + await screen.rerender( + row({ ...link, snapshot: { ...link.snapshot!, state: "open", isDraft: true } }), + ); + await expect.element(screen.getByLabelText("PR #7 - Draft: Linked change")).toBeVisible(); + await expect.element(screen.getByLabelText("PR #7, status pending")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 8ef5d75269b13..caba939c26e01 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,17 +1,10 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { AtomRegistry } from "effect/reactivity"; import { - nextThreadChangeRequestSnapshot, resolveDisplayedThreadPr, resolveDisplayedThreadPrProvider, - resolveThreadPr, - threadChangeRequestSnapshotsEqual, - threadChangeRequestSnapshotsAtom, - type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; import { newestPullRequestSummary } from "../state/pullRequests"; @@ -49,14 +42,6 @@ function mergedFeaturePr(): NonNullable { }; } -function snapshotFor( - branch: string, - pr: NonNullable, - sourceControlProvider?: VcsStatusResult["sourceControlProvider"], -): ThreadChangeRequestSnapshot { - return { branch, pr, sourceControlProvider }; -} - function pullRequestSummary( state: PullRequestSummary["state"], updatedAt: string, @@ -91,18 +76,7 @@ describe("shared pull request state", () => { }); }); -describe("resolveThreadPr", () => { - it("hides PR indicators when a dedicated worktree has switched away from the thread branch", () => { - expect( - resolveThreadPr({ - threadBranch: "stack/base", - gitStatus: status(), - }), - ).toBeNull(); - }); -}); - -describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { +describe("resolveDisplayedThreadPr", () => { const featureBranch = "feature/current"; const mergedPr = mergedFeaturePr(); const linkedPullRequest = { @@ -118,162 +92,15 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { }; it("shows a linked pull request when the checkout has a different branch", () => { - const linkedPullRequestStatus = { - pr: mergedPr, - sourceControlProvider: provider, - }; - - expect( - resolveDisplayedThreadPr({ - threadBranch: "feature/other", - gitStatus: status({ refName: "feature/other", pr: null }), - snapshot: undefined, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus, - }), - ).toEqual(mergedPr); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: "feature/other", - gitStatus: status({ refName: "feature/other", pr: null }), - snapshot: undefined, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus, - }), - ).toEqual(provider); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: "feature/other", - gitStatus: status({ refName: "feature/other", pr: null }), - snapshot: undefined, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus, - }), - ).toEqual({ - branch: "feature/other", - pr: mergedPr, - sourceControlProvider: provider, - linkedPullRequest, - }); - }); - - it("keeps a matching linked pull request snapshot while its status reloads", () => { - const snapshot = { - ...snapshotFor(featureBranch, mergedPr, provider), - linkedPullRequest, - }; - - expect( - resolveDisplayedThreadPr({ - threadBranch: null, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus: null, - }), - ).toEqual(mergedPr); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: null, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus: null, - }), - ).toBeUndefined(); - }); - - it("clears an old snapshot when a different pull request is linked", () => { - const snapshot = { - ...snapshotFor(featureBranch, mergedPr, provider), - linkedPullRequest: { ...linkedPullRequest, number: 41 }, - }; - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: true, - linkedPullRequest, - linkedPullRequestStatus: null, - }), - ).toBeNull(); - }); - - it("removes a linked pull request snapshot after the link is cleared", () => { - const snapshot = { - ...snapshotFor(featureBranch, mergedPr, provider), + const input = { + threadBranch: "feature/other", + gitStatus: status({ refName: "feature/other", pr: null }), linkedPullRequest, + linkedPullRequestStatus: { pr: mergedPr, sourceControlProvider: provider }, }; - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("after caching a merged PR, resolves main status back to the cached feature PR", () => { - const matchingStatus = status({ - refName: featureBranch, - pr: mergedPr, - sourceControlProvider: provider, - }); - const cached = nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: matchingStatus, - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }); - expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider)); - - const mainStatus = status({ - refName: "main", - isDefaultRef: true, - pr: { - number: 99, - title: "Unrelated main PR", - url: "https://github.com/pingdotgg/t3code/pull/99", - baseRef: "main", - headRef: "main", - state: "open", - }, - sourceControlProvider: provider, - }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: mainStatus, - snapshot: cached as ThreadChangeRequestSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: featureBranch, - gitStatus: mainStatus, - snapshot: cached as ThreadChangeRequestSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(provider); + expect(resolveDisplayedThreadPr(input)).toEqual(mergedPr); + expect(resolveDisplayedThreadPrProvider(input)).toEqual(provider); }); it("never attaches a PR reported by main to the feature thread", () => { @@ -289,215 +116,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { resolveDisplayedThreadPr({ threadBranch: featureBranch, gitStatus: status({ refName: "main", pr: mainPr }), - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: mainPr }), - snapshot: undefined, - retainTerminalOnBranchMismatch: true, }), ).toBeNull(); }); - - it("does not show a cached open PR across a branch mismatch", () => { - const openSnapshot = snapshotFor(featureBranch, { - ...mergedPr, - state: "open", - title: "Still open", - }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: openSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("retains a cached closed PR across a branch mismatch", () => { - const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" }; - const closedSnapshot = snapshotFor(featureBranch, closedPr, provider); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: closedSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(closedPr); - }); - - it("does not retain or display a terminal PR when a worktree switches branches", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - const mismatchedStatus = status({ refName: "feature/other", pr: null }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: mismatchedStatus, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: false, - }), - ).toBeNull(); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: featureBranch, - gitStatus: mismatchedStatus, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: false, - }), - ).toBeUndefined(); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: mismatchedStatus, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: false, - }), - ).toBeNull(); - }); - - it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: "main", - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeUndefined(); - expect( - resolveDisplayedThreadPr({ - threadBranch: "main", - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - }); - - it("clears an open snapshot when a local thread moves to a branch with no PR", () => { - const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: "main", - gitStatus: status({ refName: "main", pr: null }), - snapshot: openSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("clears a retained snapshot when the thread branch is cleared", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: null, - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - resolveDisplayedThreadPr({ - threadBranch: null, - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: null, - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeUndefined(); - }); - - it("does not erase a terminal snapshot when VCS data is missing", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: null, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeUndefined(); - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: null, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - }); - - it("shows the last matching PR snapshot while its live status lease is released", () => { - const openPr = { ...mergedPr, state: "open" as const }; - const input = { - threadBranch: featureBranch, - gitStatus: null, - snapshot: snapshotFor(featureBranch, openPr, provider), - retainTerminalOnBranchMismatch: false, - }; - expect(resolveDisplayedThreadPr(input)).toEqual(openPr); - expect(resolveDisplayedThreadPrProvider(input)).toEqual(provider); - expect(resolveDisplayedThreadPr({ ...input, threadBranch: "feature/other" })).toBeNull(); - expect( - resolveDisplayedThreadPr({ - ...input, - snapshot: { ...input.snapshot, linkedPullRequest }, - }), - ).toBeNull(); - }); - - it("refreshes a cached snapshot when a pull request becomes ready", () => { - const readyPr = { ...mergedPr, state: "open" as const }; - const draftPr = { ...readyPr, isDraft: true }; - - expect( - threadChangeRequestSnapshotsEqual( - snapshotFor(featureBranch, draftPr), - snapshotFor(featureBranch, readyPr), - ), - ).toBe(false); - }); -}); - -describe("threadChangeRequestSnapshotsAtom", () => { - it.effect("retains snapshots while sidebar and chat consumers are unmounted", () => - Effect.gen(function* () { - const registry = AtomRegistry.make(); - const threadKey = "environment-1:thread-1"; - const snapshot = snapshotFor("feature/current", mergedFeaturePr()); - - const unmount = registry.mount(threadChangeRequestSnapshotsAtom); - registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]])); - unmount(); - - yield* Effect.yieldNow; - - const remount = registry.mount(threadChangeRequestSnapshotsAtom); - expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); - - remount(); - registry.dispose(); - }), - ); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 78b2a247ba5e5..ea49b01665b64 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { scopeProjectRef, scopedThreadKey, @@ -15,7 +16,6 @@ import { type ThreadPullRequestLink, type VcsStatusResult, } from "@t3tools/contracts"; -import { Atom } from "effect/reactivity"; import { FolderGit2Icon, TerminalIcon } from "lucide-react"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; @@ -326,8 +326,10 @@ export function ThreadPullRequestsMiniList({ pullRequests: ReadonlyArray; onOpenPullRequest?: (event: MouseEvent, url: string) => void; }) { - const lines = pullRequestListLines( - resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests)), + const lines = pipe( + visibleThreadPullRequests(pullRequests), + resolveThreadPullRequestChains, + pullRequestListLines, ); if (lines.length === 0) return null; return ( @@ -441,7 +443,7 @@ export function ChangeRequestStatusIcon({ return ; } -export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { +function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { return ( {status.tooltipLead} @@ -451,165 +453,15 @@ export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator } ); } -export function resolveThreadPr(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; -}): ThreadPr | null { - const { threadBranch, gitStatus } = input; - if (gitStatus === null) { - return null; - } - - if (threadBranch === null || gitStatus.refName !== threadBranch) { - return null; - } - - return gitStatus.pr ?? null; -} - -/** Hold terminal PR metadata above rows because settlement moves remount them. */ -export interface ThreadChangeRequestSnapshot { - readonly branch: string; - readonly pr: NonNullable; - readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; - readonly linkedPullRequest?: ThreadLinkedPullRequest; -} - -export const threadChangeRequestSnapshotsAtom = Atom.make< - ReadonlyMap ->(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); - -function isTerminalChangeRequestState( - state: NonNullable["state"], -): state is "merged" | "closed" { - return state === "merged" || state === "closed"; -} - -function sourceControlProvidersEqual( - left: VcsStatusResult["sourceControlProvider"] | undefined, - right: VcsStatusResult["sourceControlProvider"] | undefined, -): boolean { - if (left === right) return true; - if (left == null || right == null) return left == null && right == null; - return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; -} - -function linkedPullRequestsEqual( - left: ThreadLinkedPullRequest | null | undefined, - right: ThreadLinkedPullRequest | null | undefined, -): boolean { - if (left == null || right == null) return left == null && right == null; - return ( - left.projectId === right.projectId && - left.repository === right.repository && - left.number === right.number && - left.url === right.url - ); -} - -export function threadChangeRequestSnapshotsEqual( - left: ThreadChangeRequestSnapshot, - right: ThreadChangeRequestSnapshot, -): boolean { - return ( - left.branch === right.branch && - left.pr.number === right.pr.number && - left.pr.title === right.pr.title && - left.pr.url === right.pr.url && - left.pr.baseRef === right.pr.baseRef && - left.pr.headRef === right.pr.headRef && - left.pr.state === right.pr.state && - left.pr.isDraft === right.pr.isDraft && - (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && - sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && - linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) - ); -} - -export function nextThreadChangeRequestSnapshot(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; - snapshot: ThreadChangeRequestSnapshot | null | undefined; - retainTerminalOnBranchMismatch: boolean; - linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; - linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; -}): ThreadChangeRequestSnapshot | null | undefined { - const { - threadBranch, - gitStatus, - snapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest, - linkedPullRequestStatus, - } = input; - if (linkedPullRequest != null) { - if (linkedPullRequestStatus === null || linkedPullRequestStatus === undefined) { - return linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) - ? undefined - : null; - } - return { - branch: threadBranch ?? linkedPullRequestStatus.pr.headRef, - pr: linkedPullRequestStatus.pr, - sourceControlProvider: linkedPullRequestStatus.sourceControlProvider, - linkedPullRequest, - }; - } - if (gitStatus === null) { - return snapshot?.linkedPullRequest === undefined ? undefined : null; - } - if (threadBranch === null) { - return null; - } - if (gitStatus.refName !== threadBranch) { - return retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ? undefined - : null; - } - if (gitStatus.pr == null) { - if ( - retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ) { - return undefined; - } - return null; - } - return { - branch: threadBranch, - pr: gitStatus.pr, - sourceControlProvider: gitStatus.sourceControlProvider, - }; -} - export function resolveDisplayedThreadPr(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; - snapshot: ThreadChangeRequestSnapshot | null | undefined; - retainTerminalOnBranchMismatch: boolean; linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; }): ThreadPr | null { - const { - threadBranch, - gitStatus, - snapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest, - linkedPullRequestStatus, - } = input; + const { threadBranch, gitStatus, linkedPullRequest, linkedPullRequestStatus } = input; if (linkedPullRequest != null) { - return ( - linkedPullRequestStatus?.pr ?? - (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) - ? (snapshot?.pr ?? null) - : null) - ); + return linkedPullRequestStatus?.pr ?? null; } if ( threadBranch !== null && @@ -620,51 +472,18 @@ export function resolveDisplayedThreadPr(input: { return gitStatus.pr; } - if ( - gitStatus === null && - threadBranch !== null && - snapshot?.branch === threadBranch && - snapshot.linkedPullRequest === undefined - ) { - return snapshot.pr; - } - - if ( - threadBranch !== null && - retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ) { - return snapshot.pr; - } - return null; } export function resolveDisplayedThreadPrProvider(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; - snapshot: ThreadChangeRequestSnapshot | null | undefined; - retainTerminalOnBranchMismatch: boolean; linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; }): VcsStatusResult["sourceControlProvider"] | undefined { - const { - threadBranch, - gitStatus, - snapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest, - linkedPullRequestStatus, - } = input; + const { threadBranch, gitStatus, linkedPullRequest, linkedPullRequestStatus } = input; if (linkedPullRequest != null) { - return ( - linkedPullRequestStatus?.sourceControlProvider ?? - (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) - ? snapshot?.sourceControlProvider - : undefined) - ); + return linkedPullRequestStatus?.sourceControlProvider; } if ( threadBranch !== null && @@ -675,25 +494,6 @@ export function resolveDisplayedThreadPrProvider(input: { return gitStatus.sourceControlProvider; } - if ( - gitStatus === null && - threadBranch !== null && - snapshot?.branch === threadBranch && - snapshot.linkedPullRequest === undefined - ) { - return snapshot.sourceControlProvider; - } - - if ( - threadBranch !== null && - retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ) { - return snapshot.sourceControlProvider; - } - return undefined; } @@ -803,7 +603,7 @@ export function ThreadWorktreeIndicator({ ); } -export function ThreadStatusLabel({ +function ThreadStatusLabel({ status, compact = false, }: { @@ -856,13 +656,7 @@ export function ThreadStatusLabel({ ); } -export function ThreadRowLeadingStatus({ - thread, - snapshot, -}: { - thread: SidebarThreadSummary; - snapshot?: ThreadChangeRequestSnapshot | undefined; -}) { +export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(false); // A new ref callback would detach and reattach the row, and each reattach sets lease state. const statusRef = useStableCallback((node: HTMLSpanElement | null) => @@ -880,6 +674,8 @@ export function ThreadRowLeadingStatus({ thread.environmentId, thread.linkedPullRequest, leaseLiveStatus, + thread.pullRequests, + thread.branchPullRequest, ); const gitStatus = useEnvironmentQuery( leaseLiveStatus && @@ -899,13 +695,14 @@ export function ThreadRowLeadingStatus({ const displayedPrInput = { threadBranch: thread.branch, gitStatus: visibleGitStatus, - snapshot, - retainTerminalOnBranchMismatch: thread.worktreePath === null, linkedPullRequest: thread.linkedPullRequest, linkedPullRequestStatus: linkedPullRequest, }; - const pr = resolveDisplayedThreadPr(displayedPrInput); - const prStatus = prStatusIndicator(pr, resolveDisplayedThreadPrProvider(displayedPrInput)); + const pr = linkedPullRequest?.pr ?? resolveDisplayedThreadPr(displayedPrInput); + const prStatus = prStatusIndicator( + pr, + linkedPullRequest?.sourceControlProvider ?? resolveDisplayedThreadPrProvider(displayedPrInput), + ); const threadStatus = resolveThreadStatusPill({ thread: { ...thread, diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx new file mode 100644 index 0000000000000..a99434f6659c6 --- /dev/null +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -0,0 +1,129 @@ +import { EMPTY_TERMINAL_SESSION_STATE } from "@t3tools/client-runtime/state/terminal"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { act, fakeTimers, render } from "../../test/render"; +import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; + +const fixture = vi.hoisted(() => ({ + statusByThreadId: new Map(), + pendingSurfaces: [] as Array<() => void>, +})); + +// Every surface method is a no-op; `then` stays undefined so awaiting the surface resolves to it. +const fakeSurface: object = new Proxy( + { input: document.createElement("textarea") }, + { + get: (target, key) => + key === "then" ? undefined : key in target ? Reflect.get(target, key) : () => undefined, + }, +); +vi.mock("~/terminal/ghostty/surface", () => ({ + GhosttyTerminalSurface: { + create: () => + new Promise((resolve) => { + fixture.pendingSurfaces.push(() => resolve(fakeSurface)); + }), + }, +})); +vi.mock("../state/terminalSessions", async () => { + const Atom = await import("effect/reactivity/Atom"); + const sessions = Atom.family((_environmentId: EnvironmentId) => + Atom.family((threadId: ThreadId) => + Atom.make({ + ...EMPTY_TERMINAL_SESSION_STATE, + status: fixture.statusByThreadId.get(threadId) ?? "closed", + version: 1, + }), + ), + ); + return { + attachedTerminalSessionAtom: (environmentId: EnvironmentId, terminal: { threadId: ThreadId }) => + sessions(environmentId)(terminal.threadId), + }; +}); +vi.mock("../state/session", () => ({ + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: () => async () => ({ _tag: "Success" }), +})); +// The commands are replaced above; importing their runtime would connect a client. +vi.mock("../state/server", async () => { + const Atom = await import("effect/reactivity/Atom"); + const config = Atom.make(null); + return { serverEnvironment: { configValueAtom: () => config } }; +}); +vi.mock("../state/terminal", () => ({ terminalEnvironment: { write: {}, resize: {} } })); +vi.mock("../state/preview", () => ({ previewEnvironment: { open: {} } })); +vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => () => {} })); +vi.mock("./preview/openTerminalLinkInPreview", () => ({ openTerminalLinkInPreview: () => {} })); +vi.mock("~/localApi", () => ({ readLocalApi: () => null })); +vi.mock("../hooks/useSettings", async () => { + const { DEFAULT_CLIENT_SETTINGS } = await import("@t3tools/contracts/settings"); + return { + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => T) => + select(DEFAULT_CLIENT_SETTINGS), + }; +}); + +function Drawer(props: { + threadRef: ScopedThreadRef; + onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; +}) { + return ( + {}} + onSplitTerminalVertical={() => {}} + onNewTerminal={() => {}} + onActiveTerminalChange={() => {}} + onCloseTerminal={props.onCloseTerminal} + onHeightChange={() => {}} + onAddTerminalContext={() => {}} + keybindings={[]} + /> + ); +} + +function setup() { + const original: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), + }; + const other: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-b"), + threadId: ThreadId.make("thread-b"), + }; + fixture.statusByThreadId = new Map([ + [original.threadId, "exited"], + [other.threadId, "running"], + ]); + fixture.pendingSurfaces = []; + return { original, other }; +} + +it("closes an exited terminal on its own thread after navigating to one reusing its ID", async () => { + const { original, other } = setup(); + const onCloseTerminal = vi.fn(); + await using screen = await render( + , + ); + using _timers = fakeTimers(); + // The exit is seen once the surface loads, and its close waits for the next task. + await act(async () => { + fixture.pendingSurfaces.shift()?.(); + }); + await screen.rerender(); + await act(async () => { + vi.runOnlyPendingTimers(); + }); + expect(onCloseTerminal.mock.calls).toEqual([["term-1", original]]); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 9979d3563ff88..83358c39d8260 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -40,7 +40,13 @@ describe("terminal selection actions", () => { }); it("retains visible output and selection until a replacement subscription receives its snapshot", () => { - const terminal = { resetAndWrite: vi.fn(), write: vi.fn(), clearSelection: vi.fn() }; + const terminal = { + resetAndWrite: vi.fn(), + write: vi.fn(), + getSelection: vi.fn(() => "selected"), + getSelectionCellCount: vi.fn(() => 8), + clearSelection: vi.fn(), + }; const snapshot = { threadId: "thread", terminalId: "term-1", @@ -90,7 +96,13 @@ it("retains visible output and selection until a replacement subscription receiv }); it("reports an attach failure that leaves the output unchanged", () => { - const terminal = { resetAndWrite: vi.fn(), write: vi.fn(), clearSelection: vi.fn() }; + const terminal = { + resetAndWrite: vi.fn(), + write: vi.fn(), + getSelection: vi.fn(() => "selected"), + getSelectionCellCount: vi.fn(() => 8), + clearSelection: vi.fn(), + }; const previous = { ...EMPTY_TERMINAL_BUFFER_STATE, error: null }; const failed = { ...previous, error: "Terminal attach failed" }; @@ -99,5 +111,7 @@ it("reports an attach failure that leaves the output unchanged", () => { ).toBe(INITIAL_TERMINAL_OUTPUT_CURSOR); expect(terminal.write).toHaveBeenCalledWith("\r\n[terminal] Terminal attach failed\r\n"); expect(terminal.resetAndWrite).not.toHaveBeenCalled(); + expect(terminal.getSelection).not.toHaveBeenCalled(); + expect(terminal.getSelectionCellCount).not.toHaveBeenCalled(); expect(terminal.clearSelection).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6bb6dc551c15e..88a945becc985 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,3 +1,4 @@ +import { workspaceAreaProps } from "../lib/workspaceFocus"; import { useAtomMount, useAtomValue } from "@effect/atom-react"; import { isAtomCommandInterrupted, @@ -164,21 +165,33 @@ export function writeTerminalOutputUpdate( } } +// Avoid formatting unbounded scrollback selections on every output update. +const MAX_COMPARED_SELECTION_CELLS = 3_000; + export function synchronizeTerminalOutput( - terminal: Pick, + terminal: Pick< + GhosttyTerminalSurface, + "resetAndWrite" | "write" | "getSelection" | "getSelectionCellCount" | "clearSelection" + >, session: Pick, cursor: TerminalOutputCursor, ): TerminalOutputCursor { if (session.version === 0) return cursor; const update = readTerminalOutputUpdate(session.output, cursor); + if (update.type === "none") return update.cursor; + const cells = terminal.getSelectionCellCount(); + const compared = cells <= MAX_COMPARED_SELECTION_CELLS; + const selection = cells > 0 && compared ? terminal.getSelection() : ""; writeTerminalOutputUpdate(terminal, update); - terminal.clearSelection(); + if (!compared || (cells > 0 && terminal.getSelection() !== selection)) { + terminal.clearSelection(); + } return update.cursor; } /** Writes what changed between two session notifications: new output, then a new error. */ export function synchronizeTerminalSession( - terminal: Pick, + terminal: Parameters[0], previous: Pick, session: Pick, cursor: TerminalOutputCursor, @@ -686,6 +699,7 @@ function TerminalSessionViewport({ terminal.setTheme(terminalThemeFromApp(mount)); setupTerminal = terminal; terminalRef.current = terminal; + terminal.input.setAttribute("data-workspace-focus-default", ""); terminal.input.readOnly = !hasTerminalWriteAccess(); // Reapply font settings hydrated during loading, when terminalRef was null. const currentFont = terminalFontRef.current; @@ -693,6 +707,7 @@ function TerminalSessionViewport({ void terminal.setFont(terminalFontOptions(currentFont.family, currentFont.size)); } const latestSession = readTerminalSession(); + terminal.setSharedGrid(latestSession.grid); const initialOutput = readTerminalOutputUpdate( latestSession.output, INITIAL_TERMINAL_OUTPUT_CURSOR, @@ -718,6 +733,7 @@ function TerminalSessionViewport({ } const previous = previousSession; previousSession = session; + terminal.setSharedGrid(session.grid); synchronizeTerminalStatus(terminal, session.status, session.version); outputCursorRef.current = synchronizeTerminalSession( terminal, @@ -786,8 +802,12 @@ function TerminalSessionViewport({ }; }; - const addSelectionToChat = (selection: TerminalContextSelection) => { - handleAddTerminalContext(selection); + const addSelectionToChat = (clipboardText: string) => { + // Output keeps moving the selected rows while a menu is open, so take + // the line numbers now, and only if the same text is still selected. + const current = readSelectionAction(); + if (current?.clipboardText !== clipboardText) return; + handleAddTerminalContext(current.selection); terminalRef.current?.clearSelection(); terminalRef.current?.focus(); }; @@ -860,7 +880,7 @@ function TerminalSessionViewport({ switch (clicked) { case "add-to-chat": if (selectionAction && canAddSelectionToChat()) { - addSelectionToChat(selectionAction.selection); + addSelectionToChat(selectionAction.clipboardText); } return; case "copy": @@ -910,7 +930,7 @@ function TerminalSessionViewport({ } switch (clicked) { case "add-to-chat": - if (canAddSelectionToChat()) addSelectionToChat(nextAction.selection); + if (canAddSelectionToChat()) addSelectionToChat(nextAction.clipboardText); return; case "copy": await copySelection(nextAction.clipboardText, requestId); @@ -1174,7 +1194,7 @@ interface ThreadTerminalDrawerProps { newShortcutLabel?: string | undefined; closeShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; + onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; onHeightChange: (height: number) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; keybindings: ResolvedKeybindingsConfig; @@ -1247,7 +1267,7 @@ function TerminalSplitPanes({ terminalLabelById: ReadonlyMap; resolveLaunchLocation: (terminalId: string) => TerminalLaunchLocation; onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; + onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; advancedTypography: boolean; threadRef: ScopedThreadRef; threadId: ThreadId; @@ -1298,7 +1318,7 @@ function TerminalSplitPanes({ {...(terminalLaunchLocation.runtimeEnv ? { runtimeEnv: terminalLaunchLocation.runtimeEnv } : {})} - onSessionExited={() => onCloseTerminal(terminalId)} + onSessionExited={() => onCloseTerminal(terminalId, threadRef)} onAddTerminalContext={onAddTerminalContext} visible={visible} keybindings={keybindings} @@ -1438,6 +1458,7 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); + const terminalArea = workspaceAreaProps("terminal", 5, "Terminal"); const isPanel = mode === "panel"; const [advancedTypography] = useLocalStorage( TYPOGRAPHY_ADVANCED_STORAGE_KEY, @@ -1621,7 +1642,7 @@ export default function ThreadTerminalDrawer({ const label = terminalLabelById.get(terminalId) ?? getTerminalLabel(terminalId); void confirmTerminalClose([label]).then((confirmed) => { if (confirmed && readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) { - onCloseTerminal(terminalId); + onCloseTerminal(terminalId, threadRef); } }); }; @@ -1722,6 +1743,7 @@ export default function ThreadTerminalDrawer({ return (
); } diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index d9fd60645618e..0a8357bb9baba 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -1,5 +1,6 @@ import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; import { useState, type Ref } from "react"; +import { textDirection } from "~/textDirection"; import { Button } from "../ui/button"; @@ -51,6 +52,7 @@ export function AssistantCitationCommentEditor({ aria-invalid={commentTooLong || undefined} placeholder="Add an optional comment..." rows={2} + dir={textDirection(comment)} className="field-sizing-content block max-h-40 min-h-16 w-full resize-none bg-transparent px-1 py-1.5 text-base outline-none placeholder:text-muted-foreground sm:text-sm" value={comment} onChange={(event) => { diff --git a/apps/web/src/components/chat/AssistantCitationSource.tsx b/apps/web/src/components/chat/AssistantCitationSource.tsx index ad04cb1fdf738..cf2e950e96abd 100644 --- a/apps/web/src/components/chat/AssistantCitationSource.tsx +++ b/apps/web/src/components/chat/AssistantCitationSource.tsx @@ -99,7 +99,7 @@ export interface AssistantCitationTarget extends AssistantCitationRequest { onComplete: () => void; } -export function observeAssistantCitationSource({ +function observeAssistantCitationSource({ root, itemKey, request, diff --git a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx index 9280b9cbc065c..6eed862ed7a03 100644 --- a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx +++ b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx @@ -5,7 +5,7 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import { QuoteIcon } from "lucide-react"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { use, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { captureAssistantTextSelection, @@ -18,6 +18,7 @@ import { type SelectionActionPoint, } from "~/lib/selectionActions"; import { Button } from "../ui/button"; +import { BackgroundThreadContext } from "./backgroundThread"; export function AssistantSelectionToolbar({ viewport, @@ -35,6 +36,7 @@ export function AssistantSelectionToolbar({ } | null>(null); const toolbarRef = useRef(null); const actionsRef = useRef | null>(null); + const backgroundThread = use(BackgroundThreadContext); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the toolbar DOM node and clamps its position inside the window before paint useLayoutEffect(() => { @@ -163,7 +165,8 @@ export function AssistantSelectionToolbar({ event.stopPropagation(); toolbar.focus({ preventScroll: true }); }; - document.addEventListener("keydown", focusActions, true); + // Tab belongs to the foreground chat; a background timeline only follows its selection. + if (!backgroundThread) document.addEventListener("keydown", focusActions, true); document.addEventListener("selectionchange", actions.selectionChanged); return () => { document.removeEventListener("keydown", focusActions, true); @@ -171,7 +174,7 @@ export function AssistantSelectionToolbar({ actions.dispose(); actionsRef.current = null; }; - }, [threadRef, viewport]); + }, [backgroundThread, threadRef, viewport]); if (!selection) return null; const tooLong = selection.citation.text.length > ASSISTANT_CITATION_MAX_TEXT_LENGTH; diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index ca10db6450242..6b49b81401f33 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -1,11 +1,8 @@ -import { type RunId } from "@t3tools/contracts"; +import { useSyncLayout } from "@legendapp/list/react"; +import { type OrchestrationV2CheckpointFileTotals, type RunId } from "@t3tools/contracts"; import { type MouseEvent, type ReactNode, memo, useLayoutEffect, useRef, useState } from "react"; import { type TurnDiffFileChange } from "../../types"; -import { - buildTurnDiffTree, - summarizeTurnDiffStats, - type TurnDiffTreeNode, -} from "../../lib/turnDiffTree"; +import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; import { ChevronRightIcon, FileDiffIcon } from "lucide-react"; import { ChevronsDownUp, ChevronsUpDown, Folder, FolderClosed } from "lucide"; import { cn } from "~/lib/utils"; @@ -26,11 +23,13 @@ interface ChangedFileRow { depth: number; } -export type ChangedFileContextMenuHandler = (filePath: string, event: MouseEvent) => void; +type ChangedFileContextMenuHandler = (filePath: string, event: MouseEvent) => void; export const ChangedFilesCard = memo(function ChangedFilesCard(props: { runId: RunId; + /** May be a prefix of the changed files; `totals` covers all of them. */ files: ReadonlyArray; + totals: OrchestrationV2CheckpointFileTotals; allDirectoriesExpanded: boolean; resolvedTheme: "light" | "dark"; onToggleAllDirectories: () => void; @@ -40,13 +39,15 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { const { runId, files, + totals, allDirectoriesExpanded, resolvedTheme, onToggleAllDirectories, onOpenTurnDiff, onFileContextMenu, } = props; - const summaryStat = summarizeTurnDiffStats(files); + const syncTimelineLayout = useSyncLayout(); + const hiddenFileCount = totals.count - files.length; const hasDirectories = files.some((file) => /[/\\]/.test(file.path)); return ( @@ -60,12 +61,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { >
- {files.length} changed file{files.length === 1 ? "" : "s"} + {totals.count.toLocaleString()} changed file{totals.count === 1 ? "" : "s"} - {hasNonZeroStat(summaryStat) && ( + {hasNonZeroStat(totals) && ( @@ -84,7 +85,10 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders" } data-scroll-anchor-ignore - onClick={onToggleAllDirectories} + onClick={() => { + onToggleAllDirectories(); + syncTimelineLayout(); + }} /> } > @@ -126,6 +130,19 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff={onOpenTurnDiff} onFileContextMenu={onFileContextMenu} /> + {hiddenFileCount > 0 && ( +
+ +
+ )}
); }); @@ -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 71a93bf86b217..7d1f0ade71081 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"; @@ -20,7 +25,6 @@ import type { ModelSelection, ProjectId, PreviewAnnotationPayload, - ProviderApprovalDecision, ThreadContextRecord, EnvironmentContextRecord, ProviderInteractionMode, @@ -34,6 +38,8 @@ import type { import { EnvironmentRequestInvalidError, AuthOrchestrationOperateScope, + clampManagedRuntimeMode, + managedRuntimeModeDenial, ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -49,6 +55,7 @@ import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model" import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { memo, + use, useEffect, useEffectEvent, useId, @@ -57,7 +64,7 @@ import { useRef, useState, } from "react"; -import { createPortal, flushSync } from "react-dom"; +import { flushSync } from "react-dom"; import { clampCollapsedComposerCursor, type ComposerTrigger, @@ -76,7 +83,6 @@ import { DEFAULT_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { deriveComposerSendState, getAntigravitySendBlockReason, - readFileAsDataUrl, resolveComposerInteractionMode, threadShellHasStarted, } from "../ChatView.logic"; @@ -84,15 +90,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, @@ -103,7 +111,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, @@ -134,17 +141,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, @@ -168,7 +177,6 @@ import { reviewCommentContextRecord, terminalContextReference, terminalContextRecord, - threadContextRecord, threadContextReference, environmentContextRecord, environmentContextReference, @@ -176,6 +184,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"; @@ -184,7 +193,6 @@ import { ComposerCommandMenu, composerSuggestionOptionId, } from "./ComposerCommandMenu"; -import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { ComposerAttachButton, ComposerAttachmentFileList, @@ -195,7 +203,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"; @@ -221,22 +228,13 @@ 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 { BuildingIcon, PlayIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { useManagedPolicy } from "../../state/managedPolicy"; import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText"; import { hasProviderSetup } from "./ProviderStatusBanner"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; @@ -249,7 +247,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, @@ -297,8 +295,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; @@ -341,7 +337,6 @@ export interface ChatComposerHandle { threadContexts: ThreadContextRecord[]; environmentContexts: EnvironmentContextRecord[]; selectedPromptEffort: string | null; - selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; multipleModelSelections: ReadonlyArray | null; providerAvailable: boolean; @@ -374,18 +369,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; @@ -397,6 +388,8 @@ export interface ChatComposerProps { keepFullHistory: boolean; onToggleKeepFullHistory: () => void; onUsageLimitsCommand?: (() => void) | undefined; + sideChatAvailable?: boolean; + sideChatDisabledReason?: string | null; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; @@ -404,6 +397,7 @@ export interface ChatComposerProps { activePendingApproval: PendingApproval | null; pendingApprovals: PendingApproval[]; + onHeldRequestIdsChange: (requestIds: ReadonlySet) => void; pendingUserInputs: PendingUserInput[]; activePendingProgress: { questionIndex: number; @@ -428,6 +422,7 @@ export interface ChatComposerProps { threadSyncPhase: ThreadSyncPhase | null; runtimeMode: RuntimeMode; + defaultRuntimeMode?: RuntimeMode; interactionMode: ProviderInteractionMode; lockedProvider: ProviderDriverKind | null; @@ -451,14 +446,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; @@ -484,10 +474,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; @@ -537,16 +523,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, @@ -556,6 +538,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentUnavailable, activePendingApproval: incomingPendingApproval, pendingApprovals, + onHeldRequestIdsChange, pendingUserInputs: incomingPendingUserInputs, activePendingProgress: incomingPendingProgress, activePendingResolvedAnswers, @@ -586,14 +569,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pullRequestProjectId, pullRequestRepository, threadPullRequests, - restingControlsHost, - restingControlsHaveLeadingContext, - onRestingControlsVisibilityChange, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - timelineOverflows, onComposerOverlayHeightChange, - onRestingChange, promptRef, composerRef, composerImagesRef, @@ -607,7 +583,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onResume, onInterrupt, onImplementPlanInNewThread, - onRespondToApproval, onSelectActivePendingUserInputOption, onAdvanceActivePendingUserInput, onDismissActivePendingUserInput, @@ -639,6 +614,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 @@ -732,7 +712,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { pendingSnapShotAnimations, pendingSnapShotIds, - pendingSnapShotIdSet, uncommittedSnapShotIds, standaloneComposerImages, } = useComposerImageGroups(attachmentDraftTarget, composerImages, composerPreviewAnnotations); @@ -930,16 +909,25 @@ 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, + ); + // Modes above an administrator's ceiling stay listed, disabled with the reason. + const managedPolicy = useManagedPolicy(environmentId); + const compatibleRuntimeModeOptions = runtimeModeOptions + .filter((option) => compatibleRuntimeModes.includes(option.mode)) + .map((option) => { + const disabledReason = managedRuntimeModeDenial(managedPolicy, option.mode); + return disabledReason === undefined ? option : { ...option, disabledReason }; + }); + // A thread or draft already above the ceiling would be refused; offer the broadest mode left. + const runtimeModeCeilingDenial = managedRuntimeModeDenial(managedPolicy, compatibleRuntimeMode); + const runtimeModeCeilingFallback = + runtimeModeCeilingDenial === undefined + ? undefined + : clampManagedRuntimeMode(managedPolicy, compatibleRuntimeMode, compatibleRuntimeModes); const providerSendBlockReason = getAntigravitySendBlockReason( selectedProviderEntry?.snapshot, @@ -951,6 +939,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (activePendingProgress ? attachmentBlockReason : (attachmentBlockReason ?? + runtimeModeCeilingDenial ?? (multipleModelSelections === null ? providerSendBlockReason : null))); const isSendDisabled = sendDisabledReason !== null; const selectedProviderStatus = selectedProviderEntry?.snapshot ?? null; @@ -961,11 +950,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedProviderSlashCommands = selectedProviderStatus ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, gitCwd) : NO_PROVIDER_SLASH_COMMANDS; - const refreshWorkspaceSnapshotOnTyping = useProviderWorkspaceSnapshotRefresh({ - environmentId, - gitCwd, - instanceId: selectedProviderEntry?.instanceId, - }); + const { refresh: refreshWorkspaceSnapshotOnTyping, scanning: workspaceSkillsScanning } = + useProviderWorkspaceSnapshotRefresh({ + environmentId, + gitCwd, + instanceId: selectedProviderEntry?.instanceId, + }); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; @@ -1036,18 +1026,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, @@ -1077,10 +1060,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 @@ -1088,14 +1067,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)) @@ -1113,26 +1097,29 @@ 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, imageCount: composerImages.length + composerFiles.length, + existingAttachmentCount: editingQueuedAttachments?.length ?? 0, terminalContexts: composerTerminalContexts, elementContextCount: composerPreviewAnnotations.length + composerReviewComments.length, }); @@ -1147,6 +1134,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, @@ -1154,17 +1142,21 @@ 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, selectedProviderSlashCommands, showSkillsInSlashMenu: settings.showSkillsInSlashMenu, + workspaceSkillsScanning, questionAttachmentTarget, }); @@ -1188,8 +1180,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. @@ -1204,7 +1194,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; @@ -1242,7 +1231,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, @@ -1266,7 +1255,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) phase === "running" || isSendBusy || isSendDisabled || - isConnecting || noProviderAvailable || projectSelectionRequired || environmentUnavailable !== null || @@ -1387,6 +1375,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) importContextFragmentStable, } = useComposerContextImport({ environmentId, + linkedEnvironments, activeThread, activeThreadId, composerDraftTarget, @@ -1398,6 +1387,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerContextRecords, composerImages, composerFilesRef, + promptRef, pendingThreadImportsRef, addComposerFilesToDraft, }); @@ -1530,43 +1520,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]); @@ -1582,31 +1553,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 { @@ -1637,16 +1587,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 = ( @@ -1656,7 +1596,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; @@ -1941,6 +1880,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, "", { @@ -2101,7 +2051,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if ( isSendBusy || isSendDisabled || - isConnecting || noProviderAvailable || environmentUnavailable !== null || phase === "running" @@ -2218,7 +2167,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingUserInputs.length > 0 || phase === "running" || isSendBusy || - isConnecting || !activeThreadId ) { return; @@ -2326,74 +2274,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 bannerItems: readonly ComposerBannerStackItem[] = + runtimeModeCeilingDenial === undefined || activePendingProgress + ? props.bannerItems + : [ + ...props.bannerItems, + { + id: "managed-runtime-mode", + variant: "warning", + icon: , + title: runtimeModeCeilingDenial, + ...(runtimeModeCeilingFallback === undefined + ? {} + : { + actions: ( + + ), + }), + }, + ]; const shelfItems = hasBlockingComposerTopDrawer - ? props.bannerItems.filter( + ? bannerItems.filter( (item) => item.group === "environment" && (item.variant === "error" || item.variant === "warning"), ) - : props.bannerItems; + : bannerItems; const insertComposerText = ( text: string, @@ -2606,7 +2408,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ): boolean => { if ( text.length === 0 || - isConnecting || activePendingIsResponding || (pendingUserInputs.length > 0 && !activePendingProgress?.activeQuestion) || projectSelectionRequired || @@ -2679,7 +2480,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingUserInputs, supportsQuestionAttachments, isLiteralPendingAnswer, - isConnecting, projectSelectionRequired, questionAttachmentTarget, attachmentTargetKey, @@ -2879,7 +2679,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, @@ -2893,7 +2695,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (questionAttachmentTarget !== null) { const canAnswer = canOperateThread && - !isConnecting && !activePendingIsResponding && activePendingProgress?.activeQuestion?.allowCustomAnswer !== false; const text = `${records @@ -3013,7 +2814,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; } - setIsComposerScrollCollapsed(false); setIsComposerFocused(true); } setIsComposerModelPickerOpen(true); @@ -3029,23 +2829,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(); @@ -3095,15 +2878,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, @@ -3122,10 +2903,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( @@ -3137,6 +2915,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, @@ -3216,7 +2996,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) threadContexts: composerThreadContexts, environmentContexts: composerEnvironmentContexts, selectedPromptEffort, - selectedModelOptionsForDispatch, selectedModelSelection, multipleModelSelections: routeKind === "draft" && multipleModelSelections !== null @@ -3275,7 +3054,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusComposer, environmentId, primaryEnvironmentId, - isConnecting, isChoiceOnlyPendingQuestion, isLiteralPendingAnswer, pendingUserInputs.length, @@ -3290,7 +3068,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedInstanceId, reportedModelSelection, settings.planModeEnabled, - selectedModelOptionsForDispatch, selectedModelSelection, multipleModelSelections, setMultipleModelSelections, @@ -3303,10 +3080,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode, planModeUiEnabled, compactThreadContextStable, - restoreAfterTimelineReachedEnd, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - setIsComposerScrollCollapsed, ], ); @@ -3315,11 +3088,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 ( @@ -3394,9 +3150,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; @@ -3432,61 +3185,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 && ( @@ -3631,7 +3332,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? isComposerMenuLoading ? composerTriggerKind === "pull-request" ? "Finding pull request..." - : "Searching workspace files..." + : composerTriggerKind === "skill" || composerTriggerKind === "slash-command" + ? "Searching workspace skills..." + : "Searching workspace files..." : composerMenuEmptyState : ""}
@@ -3665,14 +3368,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 || @@ -3866,7 +3537,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} @@ -3876,9 +3547,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 } @@ -3894,7 +3563,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) className="absolute bottom-2 inset-x-4 min-w-0" >
({ title: "" })); +vi.mock(import("../../state/session"), async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock(import("../../state/use-orchestration-command"), async (importOriginal) => ({ + ...(await importOriginal()), + useOrchestrationCommand: (() => + async ({ input }: { input: { title: string } }) => { + persisted.title = input.title; + return { _tag: "Success" }; + }) as never, +})); +vi.mock(import("~/hooks/useThreadActionMenu"), () => ({ + useThreadActionMenu: () => ({ openMenu: () => {}, closeMenu: () => {} }), +})); + +import { render } from "../../../test/render"; +import { ChatHeader } from "./ChatHeader"; + +function header(title: string) { + return ( + <> + {}} + rightPanelOpen={false} + onNewThreadInProject={() => {}} + /> +
Outside rename
+ + ); +} + +it.each(["blur", "Enter"] as const)( + "preserves a concurrent title update when an untouched rename ends with %s", + async (finish) => { + persisted.title = "New thread"; + await using screen = await render(header(persisted.title)); + await screen.getByRole("button", { name: "Thread actions for New thread" }).dblClick(); + const input = screen.getByRole("textbox", { name: "Thread title" }); + await expect.element(input).toHaveValue("New thread"); + + persisted.title = "Fix login redirect"; + await screen.rerender(header(persisted.title)); + if (finish === "Enter") await userEvent.keyboard("{Enter}"); + else await screen.getByText("Outside rename").click(); + + await expect.element(input).not.toBeInTheDocument(); + expect(persisted.title).toBe("Fix login redirect"); + + await screen.rerender(header(persisted.title)); + await screen.getByRole("button", { name: "Thread actions for Fix login redirect" }).dblClick(); + await screen.getByRole("textbox", { name: "Thread title" }).fill("My edited title"); + persisted.title = "Another concurrent title"; + await screen.rerender(header(persisted.title)); + await userEvent.keyboard("{Enter}"); + await expect + .element(screen.getByRole("textbox", { name: "Thread title" })) + .not.toBeInTheDocument(); + expect(persisted.title).toBe("My edited title"); + }, +); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ec92540efd1b0..49f21a1ddc829 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -1,6 +1,7 @@ import { AuthOrchestrationOperateScope, type EnvironmentId, + type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; @@ -54,6 +55,7 @@ interface ChatHeaderProps { rightPanelOpen: boolean; onNewThreadInProject: () => 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; @@ -172,7 +175,10 @@ export const ChatHeader = memo(function ChatHeader({ const commitRename = (title: string) => { setRenaming(null); if (!readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope)) return; - const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); + const resolution = resolveRenameCommit({ + title, + originalTitle: renamingTitle ?? activeThreadTitle, + }); if (resolution.action === "reject-empty") { toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); return; @@ -196,6 +202,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/ChildThreadApprovals.browser.test.tsx b/apps/web/src/components/chat/ChildThreadApprovals.browser.test.tsx new file mode 100644 index 0000000000000..ef136a1760137 --- /dev/null +++ b/apps/web/src/components/chat/ChildThreadApprovals.browser.test.tsx @@ -0,0 +1,52 @@ +import type { ChildThreadApproval } from "@t3tools/client-runtime/state/thread-requests"; +import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../../test/render"; + +const state = vi.hoisted(() => ({ respond: vi.fn(), approvalsAtom: null as unknown })); + +vi.mock("../../state/session", () => ({ useEnvironmentScope: () => true })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.respond })); +vi.mock("../../state/threads", () => ({ + childThreadApprovalsAtom: () => state.approvalsAtom, + threadEnvironment: { respondToApproval: {} }, +})); + +import { ChildThreadApprovals } from "./ChildThreadApprovals"; + +function setup() { + state.respond.mockReset(); + state.respond + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Child environment offline")))) + .mockResolvedValueOnce(AsyncResult.success(undefined)); + const thread = { + environmentId: EnvironmentId.make("child-env"), + threadId: ThreadId.make("child"), + }; + state.approvalsAtom = Atom.make>([ + { + thread, + title: "Compile task", + approval: { + requestId: RuntimeRequestId.make("approval"), + requestKind: "command", + detail: "Run compiler", + createdAt: "2026-11-01T00:00:00.000Z", + responseCapability: "live", + }, + }, + ]); + return thread; +} + +it("shows a failed decision and clears the error when the user retries", async () => { + const thread = setup(); + await using screen = await render(); + await screen.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.element(screen.getByRole("alert")).toHaveTextContent("Child environment offline"); + await screen.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.element(screen.getByRole("alert")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/ChildThreadApprovals.tsx b/apps/web/src/components/chat/ChildThreadApprovals.tsx new file mode 100644 index 0000000000000..5a7cb3074b4a6 --- /dev/null +++ b/apps/web/src/components/chat/ChildThreadApprovals.tsx @@ -0,0 +1,95 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AuthOrchestrationOperateScope, type ScopedThreadRef } from "@t3tools/contracts"; +import type { ChildThreadApproval } from "@t3tools/client-runtime/state/thread-requests"; +import { fullAccessOverrideExplanation } from "@t3tools/client-runtime/state/thread-requests"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useState } from "react"; +import { useEnvironmentScope } from "../../state/session"; +import { childThreadApprovalsAtom, threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; + +function ChildApproval({ entry }: { entry: ChildThreadApproval }) { + const canRespond = useEnvironmentScope(entry.thread.environmentId, AuthOrchestrationOperateScope); + const respond = useAtomCommand(threadEnvironment.respondToApproval, { reportFailure: false }); + const [responding, setResponding] = useState(false); + const [error, setError] = useState(null); + const { approval } = entry; + return ( +
+

Approval needed from {entry.title}

+
+        {approval.detail || approval.requestKind}
+      
+ {approval.appName ?

{approval.appName}

: null} + {approval.responseCapability !== "live" ? ( +

+ Provider process is gone. Interrupt or restart the child thread to respond. +

+ ) : null} + {approval.reason ?

{approval.reason}

: null} + {approval.fullAccessOverride ? ( +

+ {fullAccessOverrideExplanation(approval.fullAccessOverride)} +

+ ) : null} +
+ { + if (!canRespond || responding) return; + setResponding(true); + setError(null); + const result = await respond({ + environmentId: entry.thread.environmentId, + input: { threadId: entry.thread.threadId, requestId, decision }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError( + failure instanceof Error ? failure.message : "Failed to submit approval decision.", + ); + } + setResponding(false); + return result; + }} + /> +
+ {error !== null ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +export function ChildThreadApprovals({ threadRef }: { threadRef: ScopedThreadRef | null }) { + const approvals = useAtomValue(childThreadApprovalsAtom(threadRef)); + if (approvals.length === 0) return null; + return ( +
+ {approvals.map((entry) => ( + + ))} +
+ ); +} 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 2a0c0f7b1a1ad..e3560312925e5 100644 --- a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx +++ b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx @@ -707,7 +707,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..4c9702abe5f31 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx @@ -1,11 +1,17 @@ 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 { page, userEvent } from "vite-plus/test/browser"; 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 +39,113 @@ 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(); +}); + +it("opens the details of a long notice right above its button", async () => { + const viewport = { width: window.innerWidth, height: window.innerHeight }; + await page.viewport(1280, 800); + await using _viewport = { + [Symbol.asyncDispose]: () => page.viewport(viewport.width, viewport.height), + }; + const script = Array.from({ length: 400 }, (_, line) => `echo "line ${line}"`).join("\n"); + const container = document.createElement("div"); + container.style.cssText = "position: fixed; bottom: 4rem; right: 4rem;"; + await using screen = await render(noticeRow("40rem", script), { container }); + const details = screen.getByRole("button", { name: "Show details" }); + await userEvent.hover(details); + const popup = page.getByLabelText("Notice details"); + await expect.element(popup).toBeVisible(); + + // A positioner sized to the unscrolled text flips beside the button and covers it. + await vi.waitFor(() => { + const gap = + details.element().getBoundingClientRect().top - + popup.element().getBoundingClientRect().bottom; + expect(gap).toBeGreaterThanOrEqual(0); + expect(gap).toBeLessThan(8); + }); +}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 086826cde86db..ee602dcdac1eb 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(); @@ -115,9 +155,7 @@ export function NoticeText({ side="top" className="max-w-[min(30rem,calc(100vw-2rem))] whitespace-normal wrap-anywhere [--inline-button-text-align:start] [--inline-button-white-space:normal] [&_[data-slot=inline-button]]:max-w-full" > - - {children} - + {children} ) : null} @@ -125,11 +163,18 @@ export function NoticeText({ ); } -function NoticeCard({ item }: { readonly item: ComposerBannerStackItem }) { +function NoticeCard({ + item, + detached = false, +}: { + readonly item: ComposerBannerStackItem; + readonly detached?: boolean; +}) { return ( @@ -162,7 +207,7 @@ function NoticeCard({ item }: { readonly item: ComposerBannerStackItem }) { ) : null} {item.actions || item.onDismiss ? ( - + {item.actions} {item.onDismiss ? (

{props.isLoading - ? props.triggerKind === "skill" + ? props.triggerKind === "skill" || props.triggerKind === "slash-command" ? "Searching workspace skills..." : props.triggerKind === "pull-request" ? "Finding pull request..." diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx index 6f1824fd72642..9c1e92d93641f 100644 --- a/apps/web/src/components/chat/ComposerControl.tsx +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -1,11 +1,9 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { Select as SelectPrimitive } from "@base-ui/react/select"; import { useRender } from "@base-ui/react/use-render"; -import type { ComponentProps, ComponentType, SVGProps } from "react"; -import { ChevronDownIcon } from "lucide-react"; +import type { ComponentType, SVGProps } from "react"; import { cn } from "~/lib/utils"; -import { Separator } from "../ui/separator"; export type ComposerControlSize = "sm" | "xs"; @@ -14,7 +12,7 @@ function composerControlClassName(size: ComposerControlSize, className?: string) "relative inline-flex shrink-0 cursor-pointer items-center justify-center whitespace-nowrap rounded-(--control-radius) border border-transparent text-base outline-none hover:bg-accent data-pressed:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&:active:not([aria-haspopup])]:scale-[0.97] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:-mx-0.5 [&_svg[data-composer-control-icon]]:mx-0", "[&_:is([data-composer-control-label],[data-chat-provider-model-picker-label])]:[text-box:trim-both_cap_alphabetic] supports-[text-box:trim-both_cap_alphabetic]:[&_:is([data-composer-control-label],[data-chat-provider-model-picker-label])]:py-1.5", size === "xs" - ? "h-7 gap-1 px-1.75 font-normal text-foreground/70 text-sm hover:text-foreground dark:text-muted-foreground sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5 [&_svg[data-composer-control-chevron]]:ms-0 [&_svg[data-composer-control-chevron]]:-me-1" + ? "h-7 gap-1 has-[[data-composer-label]]:gap-1.5 px-1.75 font-normal text-foreground/70 text-sm hover:text-foreground dark:text-muted-foreground sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5 [&_svg]:mx-0 [&_svg[data-composer-control-chevron]]:ms-0 [&_svg[data-composer-control-chevron]]:-me-1" : "h-7 gap-1.5 px-2 font-medium text-foreground/80 [&_svg:not([class*='text-'])]:text-muted-foreground hover:text-foreground sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4", "aria-pressed:bg-accent aria-pressed:text-accent-foreground aria-pressed:hover:bg-accent/80", className, @@ -66,43 +64,6 @@ export function ComposerControlIcon({ ); } -export function ComposerControlChevron({ - className, - size = "sm", -}: { - className?: string; - size?: ComposerControlSize; -} = {}) { - return ( -

+ focusRequestedPermission(isOpen, permissionsChipRef.current) + } + > - - {runtimeModeOption.description} + {runtimeModeOption.label} + + + {permissionsTooltip} + + + + + ); + return ( + <> + {accessDisplay === "hidden" ? null : permissionsChip} {interactionModeToggle} ); }); +/** + * Permission modes as one horizontal toggle, with the shown mode named below. + * Each segment is a real menu radio item, so arrow keys reach it from the rest of the menu + * and a pick closes the menu. Lives inside a Menu: the permissions chip, the model picker, + * or the overflow menu. + */ +export function ComposerPermissionsSection(props: { + runtimeMode: RuntimeMode; + runtimeModeOptions: ReadonlyArray; + onRuntimeModeChange: (mode: RuntimeMode) => void; +}) { + // Hover or keyboard focus previews a mode's name and description before a pick. + const [previewMode, setPreviewMode] = useState(null); + const shownMode = previewMode ?? props.runtimeMode; + const shown = + props.runtimeModeOptions.find((option) => option.mode === shownMode) ?? + supervisedRuntimeModeOption; + const descriptionId = useId(); + return ( +
+
Permissions
+ { + if (value !== props.runtimeMode) props.onRuntimeModeChange(value); + }} + className="flex rounded-lg bg-input/40 p-0.5" + // Menus navigate with Up/Down only; the row reads horizontally, so Left/Right move too. + onKeyDown={(event) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + const items = Array.from( + event.currentTarget.querySelectorAll('[role="menuitemradio"]'), + ); + const index = items.findIndex((item) => item === document.activeElement); + const next = items[index + (event.key === "ArrowRight" ? 1 : -1)]; + if (index < 0 || !next) return; + event.preventDefault(); + event.stopPropagation(); + next.focus(); + }} + > + {props.runtimeModeOptions.map((option) => { + const OptionIcon = option.icon; + return ( + setPreviewMode(option.mode)} + onPointerLeave={() => setPreviewMode(null)} + onFocus={() => setPreviewMode(option.mode)} + onBlur={() => setPreviewMode(null)} + className="flex h-6 flex-1 cursor-pointer items-center justify-center text-muted-foreground outline-none first:rounded-l-md last:rounded-r-md data-disabled:cursor-not-allowed data-disabled:opacity-40 data-checked:bg-input/72 data-checked:text-foreground data-highlighted:text-foreground not-data-checked:data-highlighted:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" + > + + ); + })} + + {/* Static per-mode descriptions for screen readers; the preview below is visual only. */} + {props.runtimeModeOptions.map((option) => ( + + {option.disabledReason ?? option.description} + + ))} + +
+ ); +} + export const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; canOperateThread: boolean; @@ -160,7 +268,6 @@ export const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryA promptHasText: boolean; isSendBusy: boolean; sendDisabledReason: string | null; - isConnecting: boolean; isEnvironmentUnavailable: boolean; hasSendableContent: boolean; canResume: boolean; @@ -204,7 +311,6 @@ export const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryA promptHasText={props.promptHasText} isSendBusy={props.isSendBusy} sendDisabledReason={props.sendDisabledReason} - isConnecting={props.isConnecting} isEnvironmentUnavailable={props.isEnvironmentUnavailable} isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} diff --git a/apps/web/src/components/chat/ComposerImageThumbnail.tsx b/apps/web/src/components/chat/ComposerImageThumbnail.tsx index e9de2aa93b302..68158c7151b2f 100644 --- a/apps/web/src/components/chat/ComposerImageThumbnail.tsx +++ b/apps/web/src/components/chat/ComposerImageThumbnail.tsx @@ -1,24 +1,15 @@ -import { memo, Suspense, use, type ReactNode } from "react"; +import { memo, useLayoutEffect, useState, type ReactNode } from "react"; + +import { cn } from "~/lib/utils"; import { createComposerImageThumbnail, + isHeicImageFile, + MAX_COMPRESSIBLE_SOURCE_BYTES, readComposerImageThumbnail, } from "../../lib/imageCompression"; -export const ComposerImageThumbnail = memo(function ComposerImageThumbnail(props: { - file: File; - alt: string; - className: string; - fallback: ReactNode; -}) { - return ( - - - - ); -}); - -function ThumbnailImage({ +export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({ file, alt, className, @@ -29,8 +20,63 @@ function ThumbnailImage({ className: string; fallback: ReactNode; }) { - // A settled thumbnail paints synchronously instead of suspending for a tick. - const settled = readComposerImageThumbnail(file); - const src = settled === undefined ? use(createComposerImageThumbnail(file)) : settled; - return src ? {alt} : fallback; -} + const [preview, setPreview] = useState(() => ({ + file, + src: readComposerImageThumbnail(file), + })); + const [loadedSrc, setLoadedSrc] = useState(null); + const [failedSrc, setFailedSrc] = useState(null); + const src = preview.file === file ? preview.src : readComposerImageThumbnail(file); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- owns the local blob URL and decodes its replacement before swapping the visible image + useLayoutEffect(() => { + let disposed = false; + let localUrl: string | undefined; + const cached = readComposerImageThumbnail(file); + if ( + file.size <= MAX_COMPRESSIBLE_SOURCE_BYTES && + !isHeicImageFile(file) && + cached === undefined + ) { + localUrl = URL.createObjectURL(file); + // oxlint-disable-next-line react/set-state-in-effect -- the blob URL is created and revoked by this effect + setPreview({ file, src: localUrl }); + } + if (cached === undefined) { + void createComposerImageThumbnail(file).then(async (thumbnail) => { + if (disposed) return; + if (!thumbnail) { + if (!localUrl) setPreview({ file, src: null }); + return; + } + const image = new Image(); + image.src = thumbnail; + try { + await image.decode(); + if (!disposed) setPreview({ file, src: thumbnail }); + } catch { + // Keep the local preview if the thumbnail cannot be decoded. + } + }); + } + return () => { + disposed = true; + if (localUrl) URL.revokeObjectURL(localUrl); + }; + }, [file]); + + return src && failedSrc !== src ? ( + { + if (image?.complete && image.naturalWidth > 0) setLoadedSrc(src); + }} + src={src} + alt={alt} + onLoad={() => setLoadedSrc(src)} + onError={() => setFailedSrc(src)} + className={cn(className, loadedSrc !== src && "invisible")} + /> + ) : src === undefined ? null : ( + fallback + ); +}); diff --git a/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx b/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx new file mode 100644 index 0000000000000..3d704b5209305 --- /dev/null +++ b/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx @@ -0,0 +1,125 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ProviderOptionSelection, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { useState } from "react"; +import { expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import { ComposerEffortColumn, describeComposerEffort } from "./ComposerModelEffort"; +import type { TraitsSelectionInput } from "./TraitsPicker"; + +// Shaped like Devin over ACP: its session mode comes first, and its reasoning id is its own. +const models: ReadonlyArray = [ + { + slug: "fusion", + name: "Fusion (Claude Opus 5.5 Medium + SWE-2 High)", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { + id: "mode", + label: "Session Mode", + type: "select", + currentValue: "bypass", + options: [ + { id: "code", label: "Code" }, + { id: "plan", label: "Plan" }, + { id: "bypass", label: "Bypass Permissions" }, + ], + }, + { + id: "thought_level", + label: "Thinking", + type: "select", + role: "reasoning", + currentValue: "medium", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + { + id: "speed", + label: "Speed", + type: "select", + currentValue: "standard", + options: [ + { id: "standard", label: "Standard" }, + { id: "fast", label: "Fast" }, + ], + }, + { id: "auto_approve", label: "Auto-approve tools", type: "boolean", currentValue: false }, + ], + }), + }, +]; + +function traitsInput( + modelOptions: ReadonlyArray, + onModelOptionsChange: (next: ReadonlyArray | undefined) => void, +): TraitsSelectionInput { + return { + provider: ProviderDriverKind.make("acpRegistry"), + instanceId: ProviderInstanceId.make("devin"), + models, + model: "fusion", + modelOptions, + planModeEnabled: false, + onModelOptionsChange, + }; +} + +function Column() { + const [options, setOptions] = useState>([]); + const traits = traitsInput(options, (next) => setOptions(next ?? [])); + return ( + <> +
+ +
+

{describeComposerEffort(traits, "bars", "sm").suffix}

+ + ); +} + +it("draws an agent's reasoning as bars and keeps its mode out of the levels", async () => { + await using screen = await render(, { router: false }); // no routes involved + const status = screen.getByRole("status"); + expect(status.element().querySelector("[data-composer-effort-glyph]")).not.toBeNull(); + await expect.element(status).toHaveTextContent(""); + + const levels = screen.getByRole("radiogroup", { name: "Thinking" }); + await expect.element(levels.getByRole("radio")).toHaveLength(3); + await expect.element(screen.getByRole("radio", { name: "Plan" })).not.toBeInTheDocument(); + + // Up and Down walk from the levels through the option rows without opening their menus. + await userEvent.tab(); + await expect.element(screen.getByRole("radio", { name: "Medium" })).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}"); + const speed = screen.getByRole("button", { name: "Speed: Standard" }); + await expect.element(speed).toHaveFocus(); + await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); + await userEvent.keyboard("{ArrowRight}"); + await expect.element(page.getByRole("menuitemradio", { name: "Standard" })).toHaveFocus(); + await userEvent.keyboard("{ArrowLeft}"); + await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); + await expect.element(speed).toHaveFocus(); + + await userEvent.click(screen.getByText("Auto-approve tools")); + await expect + .element(screen.getByRole("switch", { name: "Auto-approve tools" })) + .toHaveAttribute("aria-checked", "true"); + + await userEvent.click(screen.getByRole("button", { name: "Session Mode: Bypass Permissions" })); + await userEvent.click(page.getByRole("menuitemradio", { name: "Plan" })); + await expect + .element(screen.getByRole("button", { name: "Session Mode: Plan" })) + .toBeInTheDocument(); + await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/ComposerModelEffort.tsx b/apps/web/src/components/chat/ComposerModelEffort.tsx new file mode 100644 index 0000000000000..3a863936101b2 --- /dev/null +++ b/apps/web/src/components/chat/ComposerModelEffort.tsx @@ -0,0 +1,657 @@ +import { + buildTraitsTriggerDisplay, + getDescriptorStringValue, + isReasoningControl, + selectableProviderOptionChoices, +} from "@t3tools/client-runtime/model-traits"; +import type { ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; +import type { EffortDisplay } from "@t3tools/contracts/settings"; +import { getProviderOptionCurrentLabel } from "@t3tools/shared/model"; +import { CheckIcon, ChevronRightIcon, ChevronUpIcon } from "lucide-react"; +import { type KeyboardEvent, type ReactNode, use, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; +import { + Menu, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuRadioItemIndicator, + MenuTrigger, +} from "../ui/menu"; +import { Switch } from "../ui/switch"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import type { ComposerControlSize } from "./ComposerControl"; +import { ComposerPermissionsSection, type RuntimeModeOption } from "./ComposerFooterControls"; +import { composerFloatingLayerProps, focusCheckedPermission } from "./composerEventScope"; +import { EffortGlyph, FastBolt, resolveEffortGlyph } from "./EffortGlyph"; +import { DockedPickerContext } from "./ProviderModelPicker"; +import { + getTraitsSectionVisibility, + type TraitsSelectionInput, + ULTRATHINK_IN_BODY_NOTE, + useTraitsSelection, +} from "./TraitsPicker"; + +type SelectDescriptor = Extract; +type BooleanDescriptor = Extract; + +/** Codex service tiers and Claude's Fast mode share the footer's speed control. */ +function splitSpeed( + provider: TraitsSelectionInput["provider"], + selects: ReadonlyArray, + toggles: ReadonlyArray, +) { + const speedSelect = + provider === "codex" + ? (selects.find((descriptor) => descriptor.id === "serviceTier") ?? null) + : null; + const fastToggle = toggles.find((descriptor) => descriptor.id === "fastMode") ?? null; + return { + speedSelect, + fastToggle, + columnSelects: selects.filter((descriptor) => descriptor !== speedSelect), + columnToggles: toggles.filter((descriptor) => descriptor !== fastToggle), + }; +} + +type ColumnControl = + | { kind: "select"; descriptor: SelectDescriptor } + | { kind: "boolean"; descriptor: BooleanDescriptor }; + +/** + * The column lists the model's reasoning levels under the header that names it. Other options, + * such as an agent's session mode, sit below as one row each. + */ +function columnControls( + provider: TraitsSelectionInput["provider"], + selects: ReadonlyArray, + toggles: ReadonlyArray, +) { + const { columnSelects, columnToggles } = splitSpeed(provider, selects, toggles); + const controls: ReadonlyArray = [ + ...columnSelects.map((descriptor) => ({ kind: "select" as const, descriptor })), + ...columnToggles.map((descriptor) => ({ kind: "boolean" as const, descriptor })), + ]; + const effort = controls.find((control) => isReasoningControl(control.descriptor)) ?? null; + return { effort, others: controls.filter((control) => control !== effort) }; +} + +/** + * What the model trigger shows after the model name: the effort word (or bars, per the + * Appearance setting), a bolt for Fast, and other traits such as a context window. + */ +export function describeComposerEffort( + traits: TraitsSelectionInput | null, + effortDisplay: EffortDisplay, + size: ComposerControlSize, +): { suffix: ReactNode; detail: string | null } { + if (!traits) return { suffix: null, detail: null }; + const visibility = getTraitsSectionVisibility(traits); + if (!visibility.hasAnyControls) return { suffix: null, detail: null }; + const modelSelection = + traits.instanceId && traits.model + ? { instanceId: traits.instanceId, model: traits.model, options: traits.modelOptions ?? [] } + : null; + const primary = visibility.primarySelectDescriptor; + const display = buildTraitsTriggerDisplay({ + provider: traits.provider, + descriptors: visibility.descriptors, + primarySelectDescriptorId: primary?.id ?? null, + ultrathinkPromptControlled: visibility.ultrathinkPromptControlled, + modelSelection, + reportedModelSelection: traits.reportedModelSelection, + }); + // The same reasoning control the column lists, wherever the agent put it among its options. + const reasoning = + columnControls(traits.provider, visibility.selectDescriptors, visibility.booleanDescriptors) + .effort?.descriptor ?? null; + const ultrathink = visibility.ultrathinkPromptControlled && reasoning === primary; + const glyph = + reasoning?.type === "select" + ? resolveEffortGlyph( + reasoning, + ultrathink + ? "ultrathink" + : getDescriptorStringValue(reasoning, modelSelection, traits.reportedModelSelection), + ) + : null; + // Workflow levels such as Ultracode always show their name; the setting names every level. + const word = glyph + ? (glyph.special ?? (effortDisplay === "label" ? glyph.label : null)) + : !reasoning + ? null + : reasoning.type === "boolean" + ? `${reasoning.label} ${reasoning.currentValue === true ? "On" : "Off"}` + : ultrathink + ? "Ultrathink" + : getProviderOptionCurrentLabel(reasoning, modelSelection, traits.reportedModelSelection); + const fast = display.speedLabel === "Fast" || display.speedLabel === "Ultrafast"; + // Agent options such as modes live in the picker; the trigger adds only the context window. + const contextWindow = + visibility.contextWindowDescriptor && + getProviderOptionCurrentLabel( + visibility.contextWindowDescriptor, + modelSelection, + traits.reportedModelSelection, + ); + if (!word && !glyph && !fast && !contextWindow) return { suffix: null, detail: display.label }; + return { + detail: display.label, + suffix: ( + + {word ? ( + + {word} + + ) : glyph ? ( + + ) : null} + {fast ? : null} + {contextWindow ? ( + + {contextWindow} + + ) : null} + + ), + }; +} + +const COLUMN_ROW = + "flex h-8 w-full shrink-0 cursor-pointer items-center gap-2 rounded-sm px-2 text-left text-sm text-foreground outline-none hover:bg-foreground/[0.09] focus-visible:bg-foreground/[0.09] disabled:cursor-not-allowed disabled:opacity-64"; +const COLUMN_LABEL = "px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs"; + +function ColumnRow(props: { + label: string; + /** Shown on hover; the column is too narrow to print it under the label. */ + description?: string | undefined; + selected: boolean; + /** The column's one tab stop: the selected level, else the first when none shows as selected. */ + tabStop: boolean; + isDefault?: boolean | undefined; + disabled?: boolean; + onPick: () => void; +}) { + const rowProps = { + type: "button", + role: "radio", + "aria-checked": props.selected, + disabled: props.disabled, + "data-effort-row": true, + // Focus lands on a level after a model pick. + "data-effort-level": true, + // Up and Down move between rows. + tabIndex: props.tabStop ? 0 : -1, + className: COLUMN_ROW, + onClick: props.onPick, + } as const; + const content = ( + <> + {props.label} + {props.selected ? ( + <> + {props.isDefault ? Default : null} +
} + disabled={disabled || isResponding || !canRespond} + render={ + presentation === "inline" ? ( + + ) : ( + + + + ); +} + +it("stays on an unanswered question when the composer submits", async () => { + await using screen = await render( + , + ); + const submit = screen.getByRole("button", { name: "Submit" }); + await userEvent.click(submit); + await expect.element(screen.getByRole("status")).toHaveTextContent("Question 1"); + + await userEvent.click(screen.getByRole("button", { name: "Choose Incremental" })); + await userEvent.click(submit); + await expect.element(screen.getByRole("status")).toHaveTextContent("Question 2"); +}); + it("isolates drafts by environment, thread and request and discards only resolved requests", async () => { using _timers = fakeTimers(); const environmentId = EnvironmentId.make("scope-environment"); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 697a0e0bfd6f2..a19e7f205cef9 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,16 +1,16 @@ import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts"; +import { markdownProseDirection, textDirection } from "~/textDirection"; import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react"; import { BackgroundThreadContext } from "./backgroundThread"; import { useStableCallback } from "~/hooks/useStableCallback"; import { type PendingUserInput } from "../../session-logic"; -import { - derivePendingUserInputProgress, - type PendingUserInputDraftAnswer, -} from "../../pendingUserInput"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; +import { derivePendingUserInputProgress } from "../../pendingUserInput"; import { CheckboxMark } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Kbd } from "../ui/kbd"; import { cn } from "~/lib/utils"; +import { isInSideChatPane } from "~/lib/sideChatPane"; import { ComposerBanner } from "./ComposerBanner"; import ChatMarkdown from "../ChatMarkdown"; @@ -153,6 +153,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; + if (isInSideChatPane(target)) return; if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { return; } @@ -179,8 +180,20 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( return null; } + const promptDirection = markdownProseDirection( + prompt.questions + .flatMap((question) => [ + question.header, + question.question, + ...question.options.flatMap((option) => [option.label, option.description ?? ""]), + ]) + .join(" "), + ); + const questionTextAlign = promptDirection === "rtl" ? "text-right" : "text-left"; + return ( { setCollapsedQuestionId(open ? null : activeQuestion.id); @@ -195,11 +208,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( className="px-4 pt-1.5" > - {activeQuestion.header} + + {activeQuestion.header} + {isCollapsed ? ( <> - + {activeQuestion.question} @@ -241,7 +262,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( text={activeQuestion.question} cwd={cwd} threadRef={threadRef} - className="pt-2 text-foreground/85" + className={cn("pt-2 text-foreground/85", questionTextAlign)} /> {/* Rows span the header row's width so the radios align with the question text and the shortcut column sits under the dismiss button. */}
@@ -253,7 +274,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const isSelected = isOptimisticallySelected || draftSelectedOptions?.[index] === true; const className = cn( - "group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 text-left sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", + questionTextAlign, + "group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", isSelected ? "text-foreground" : "text-foreground/70 hover:bg-muted/30", (disabled || isResponding) && "opacity-50 cursor-not-allowed", !disabled && !isResponding && "cursor-pointer", @@ -269,9 +291,16 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( )}
- {option.label} + + {option.label} + {option.description && option.description !== option.label ? ( - {option.description} + + {option.description} + ) : null}
{index < 9 ? ( diff --git a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx new file mode 100644 index 0000000000000..850256b28904a --- /dev/null +++ b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx @@ -0,0 +1,151 @@ +import type { RuntimeMode } from "@t3tools/contracts"; +import { type ReactNode, useRef, useState } from "react"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext"; +import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu"; +import { + focusRequestedPermission, + markOpenToPermissions, + useComposerMenuProps, +} from "./composerEventScope"; +import { ComposerFooterModeControls, ComposerPermissionsSection } from "./ComposerFooterControls"; +import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; + +const options = runtimeModeOptions.map((mode) => ({ mode, ...runtimeModeConfig[mode] })); + +// Mirrors the overflow menu: mode radio items first, then the Permissions section. +function OverflowMenu() { + const [interaction, setInteraction] = useState("plan"); + const [mode, setMode] = useState("full-access"); + const composerMenuProps = useComposerMenuProps(); + const triggerRef = useRef(null); + return ( + <> + focusRequestedPermission(open, triggerRef.current)}> + More + + + Chat + Plan + + + + +

{mode}

+ + ); +} + +it("reaches Permissions from the mode items with arrow keys, previews, and picks with Enter", async () => { + await using screen = await render(, { router: false }); // no routes involved + const trigger = screen.getByRole("button", { name: "More" }); + await userEvent.tab(); + await expect.element(trigger).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}"); + await expect.element(page.getByRole("menuitemradio", { name: "Chat" })).toHaveFocus(); + + await userEvent.keyboard("{ArrowDown}{ArrowDown}"); + const supervised = page.getByRole("menuitemradio", { name: "Supervised" }); + await expect.element(supervised).toHaveFocus(); + await expect + .element(supervised) + .toHaveAccessibleDescription("Ask before commands and file changes."); + + await userEvent.keyboard("{ArrowRight}"); + await expect + .element(page.getByRole("menuitemradio", { name: "Auto-accept edits" })) + .toHaveFocus(); + await userEvent.keyboard("{ArrowLeft}{ArrowLeft}"); + await expect.element(page.getByRole("menuitemradio", { name: "Supervised" })).toHaveFocus(); + + await userEvent.keyboard("{ArrowUp}"); + await expect.element(page.getByRole("menuitemradio", { name: "Plan" })).toHaveFocus(); + + await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}"); + await expect.element(screen.getByRole("status")).toHaveTextContent("auto-accept-edits"); + await expect + .element(page.getByRole("menuitemradio", { name: "Supervised" })) + .not.toBeInTheDocument(); + await expect.element(trigger).toHaveFocus(); +}); + +it("marks the current mode as checked and closes on Escape", async () => { + await using screen = await render(, { router: false }); // no routes involved + await userEvent.tab(); + await userEvent.keyboard("{ArrowDown}"); + const fullAccess = page.getByRole("menuitemradio", { name: "Full access" }); + await expect.element(fullAccess).toHaveAttribute("aria-checked", "true"); + await userEvent.keyboard("{Escape}"); + await expect.element(fullAccess).not.toBeInTheDocument(); + await expect.element(screen.getByRole("button", { name: "More" })).toHaveFocus(); +}); + +// The real composer menu focus rule (useComposerMenuProps), with a stub composer handle. +function withComposer(children: ReactNode) { + const handle = { focusAtEnd: vi.fn() }; + const ref = { current: handle } as unknown as ComposerHandleRef; + return { handle, ui: {children} }; +} + +it("returns focus to the editor after a pick", async () => { + const { handle, ui } = withComposer(); + await using screen = await render(ui, { router: false }); // no routes involved + await userEvent.tab(); + await userEvent.keyboard("{ArrowDown}"); + await expect.element(page.getByRole("menuitemradio", { name: "Chat" })).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}"); + await expect.element(screen.getByRole("status")).toHaveTextContent("approval-required"); + await vi.waitFor(() => expect(handle.focusAtEnd).toHaveBeenCalled()); +}); + +it("opens on the checked permission when composer.mode marked the trigger", async () => { + await using screen = await render(, { router: false }); // no routes involved + const trigger = screen.getByRole("button", { name: "More" }); + markOpenToPermissions(trigger.element() as HTMLElement); + await userEvent.click(trigger); + await expect.element(page.getByRole("menuitemradio", { name: "Full access" })).toHaveFocus(); + + // The mark is used once: a plain reopen lands on the menu's usual first focus. + await userEvent.keyboard("{Escape}"); + await userEvent.click(trigger); + await expect.element(page.getByRole("menuitemradio", { name: "Full access" })).not.toHaveFocus(); +}); + +// Mirrors ComposerControlsBar: the chip shows only while the mode differs from the default. +function PermissionsChip() { + const [mode, setMode] = useState("approval-required"); + return ( +
+ + {mode === "full-access" ? null : ( + {}} + onRuntimeModeChange={setMode} + /> + )} +
+ ); +} + +it("returns focus to the editor when picking the default hides the chip", async () => { + const { handle, ui } = withComposer(); + await using screen = await render(ui, { router: false }); // no routes involved + await userEvent.click(screen.getByRole("button", { name: "Permissions: Supervised" })); + await userEvent.click(page.getByRole("menuitemradio", { name: "Full access" })); + await vi.waitFor(() => expect(handle.focusAtEnd).toHaveBeenCalled()); + await expect.element(screen.getByRole("button", { name: "Model" })).not.toHaveFocus(); +}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index c612464e4812e..a4a8993a7036e 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -4,7 +4,7 @@ import { CornerUpRight, ListPlus } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; import { formatContextWindowTokens } from "~/lib/contextWindow"; -import { cn } from "~/lib/utils"; +import { cn, isMacPlatform } from "~/lib/utils"; import { useShortcutModifierState } from "../../shortcutModifierState"; import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; import { Button } from "../ui/button"; @@ -38,7 +38,6 @@ interface ComposerPrimaryActionsProps { promptHasText: boolean; isSendBusy: boolean; sendDisabledReason: string | null; - isConnecting: boolean; isEnvironmentUnavailable: boolean; isPreparingWorktree: boolean; hasSendableContent: boolean; @@ -93,7 +92,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ promptHasText, isSendBusy, sendDisabledReason, - isConnecting, isEnvironmentUnavailable, isPreparingWorktree, hasSendableContent, @@ -210,9 +208,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ type="submit" className={cn(messageActionPillClassName, "h-9 sm:h-8", compact ? "px-3" : "px-4")} {...pointerFocusProps} - disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} + disabled={isSendBusy || isSendDisabled || isEnvironmentUnavailable} > - {isConnecting || isSendBusy ? "Sending..." : "Refine"} + {isSendBusy ? "Sending..." : "Refine"} ); } @@ -223,9 +221,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ type="submit" className={cn(messageActionPillClassName, "h-9 rounded-r-none px-4 sm:h-8")} {...pointerFocusProps} - disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} + disabled={isSendBusy || isSendDisabled || isEnvironmentUnavailable} > - {isConnecting || isSendBusy ? "Sending..." : "Implement"} + {isSendBusy ? "Sending..." : "Implement"} event.preventDefault() : undefined } - disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} + disabled={isSendBusy || isSendDisabled || isEnvironmentUnavailable} /> } > @@ -248,7 +246,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ { if (canOperateThread) onImplementPlanInNewThread(); }} @@ -287,21 +285,19 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ const submitStatus = isEnvironmentUnavailable ? "Environment disconnected" : (sendDisabledReason ?? - (isConnecting - ? "Connecting" - : isPreparingWorktree - ? "Preparing worktree" - : isSendBusy - ? isEditingQueuedMessage - ? "Updating queued message" - : "Submitting message" - : null)); + (isPreparingWorktree + ? "Preparing worktree" + : isSendBusy + ? isEditingQueuedMessage + ? "Updating queued message" + : "Submitting message" + : null)); const submitTooltip = submitStatus ?? (compactsBeforeSend ? `Summarize ${compactTokens} tokens of history, then send` : isRunning && !isEditingQueuedMessage && canSteer - ? `Click to ${followUpBehavior}, Ctrl/⌘-click${alternateShortcutLabel ? ` or ${alternateShortcutLabel}` : ""} to ${alternateAction}` + ? `Click to ${followUpBehavior}, ${isMacPlatform(navigator.platform) ? "⌘" : "Ctrl"}-click${alternateShortcutLabel ? ` or ${alternateShortcutLabel}` : ""} to ${alternateAction}` : submitLabel); const sendButton = ( @@ -318,7 +314,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ disabled={ isSendBusy || isSendDisabled || - isConnecting || isEnvironmentUnavailable || (!hasSendableContent && !showResume) } @@ -329,7 +324,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ) : null} - {isConnecting || isSendBusy ? ( + {isSendBusy ? (