From b56b66b1d7a38bdd1abaf7c1185a0a3210c9af55 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:12:04 -0700 Subject: [PATCH] refactor(web): chat view, composer and timeline stop syncing state ChatView, the composer core, timeline follow, sidebar, terminal drawer and command palette read stores and the router at use time. This layer also carries ThreadRouteView, the _chat route, the sidebar location, announcer and visited-time migration services, and onboarding's wizard. Effect categories removed: - latest-value ref mirrors in the composer and ChatView replaced by reads of the draft store, router and committed DOM at use time - composer registries (typing guard, responding state, open question attachments, uploads) moved to thread-keyed stores and a reconciler - timeline scroll, follow and position restore driven by the viewport handle and a follow state machine; citations load from a procedure - palette event bus moved to a store; chat route, chat, paste and window refocus listeners register once from dispatcher scopes and the chat root ref callback - draft route promotion effects replaced by rendering the store's redirect; thread change and terminal focus moved to handlers - terminal output and height mirrors replaced by a session subscription and a controlled store - optimistic messages, diff scope, file tabs, sidebar drops and details card mode moved to stores, render-time reconciles and app subscriptions - sidebar route reveal, scope and snooze wakes derived in render and from row ref callbacks; location and announcements run as services Bugs fixed: - Chat route shortcuts ran in an order that depended on render history. - Drafts promoted while off screen never finalized until visited. - Onboarding stuck after pairing finished, or spun forever on import. - Sidebar scopes were erased when a project left; many elapsed snooze wakes crashed the sidebar. - Uploads re-ran on every attachment change; queued-edit uploads stuck. - The find shortcut during a thread switch said find was unavailable. - Position restore re-ran on every row change; a fullscreen app pin kept follow off after a thread switch. - Citations to responses outside the loaded history failed at once. - A question could look answerable while its response was in flight, and a focused composer switching threads stopped holding requests. - An acknowledged message rendered twice for one commit. - A palette intent while open kept the previous session's state. - File tabs of a removed project only closed when that thread was active. - Switching to a thread with its terminal open pulled focus into it. Kept effects: - Timeline position restore and follow anchor release: must land before paint after the commit that changes the list. - Prompt editor editable, attribute and value sync: Tiptap's API. - Composer resting visibility and FLIP microtask: wait on a CSS rework. - Sidebar ordering mirror, drag cancel and list motion: post-commit. - Resume-compaction persistence and work-locally resend: server-driven. - Terminal surface setup: imperative WASM widget. Removes compat shims added lower in the stack: - rightPanelStore.ts (added in layer 05) - browserFaviconStore.ts (added in layer 05) - composerDraftStore.ts (added in layer 02) - appInit.tsx (added in layer 10) - routes/__root.tsx (added in layer 10) Co-Authored-By: Claude Opus 5.5 --- apps/web/src/appInit.tsx | 92 +- apps/web/src/attachmentUploads.test.ts | 202 +++++ apps/web/src/attachmentUploads.ts | 229 +++++ apps/web/src/browserFaviconStore.ts | 9 - apps/web/src/commandPaletteBus.ts | 33 +- apps/web/src/commandPaletteStore.ts | 43 + apps/web/src/components/AppSidebarLayout.tsx | 381 ++++---- .../components/BackgroundThreadAnnouncer.tsx | 89 +- .../web/src/components/ChatView.logic.test.ts | 79 +- apps/web/src/components/ChatView.logic.ts | 176 +--- apps/web/src/components/ChatView.tsx | 668 ++++---------- .../components/CommandPalette.logic.test.ts | 61 +- .../src/components/CommandPalette.logic.ts | 65 +- apps/web/src/components/CommandPalette.tsx | 395 ++++----- .../src/components/CommandPaletteContent.tsx | 29 +- .../components/ComposerPromptEditorTiptap.tsx | 69 +- apps/web/src/components/HostedEmptyState.tsx | 29 +- apps/web/src/components/LegacySidebar.tsx | 162 +--- .../components/PersistentThreadTerminals.tsx | 23 +- .../components/Sidebar.logic.browser.test.tsx | 31 + apps/web/src/components/Sidebar.logic.ts | 174 ++-- apps/web/src/components/Sidebar.pointer.ts | 8 +- apps/web/src/components/Sidebar.tsx | 356 ++++---- apps/web/src/components/ThreadRouteView.tsx | 155 +--- .../src/components/ThreadTerminalDrawer.tsx | 304 +++---- .../UsageToggleShortcut.browser.test.tsx | 30 +- apps/web/src/components/chat/ChatCanvas.tsx | 120 +-- .../src/components/chat/ChatCanvasContext.ts | 20 +- apps/web/src/components/chat/ChatComposer.tsx | 838 ++++++------------ apps/web/src/components/chat/ChatRoot.tsx | 77 ++ .../components/chat/ChatTerminalDrawers.tsx | 2 - ...UserInputPanel.navigation.browser.test.tsx | 42 +- .../components/chat/ComposerStashBadge.tsx | 18 +- .../chat/MessagesTimeline.browser.test.tsx | 122 ++- .../src/components/chat/MessagesTimeline.tsx | 485 ++++++---- .../chat/ThreadDetailsCard.browser.test.tsx | 138 +++ .../src/components/chat/ThreadDetailsCard.tsx | 151 ++-- .../components/chat/ThreadDetailsPanel.tsx | 3 +- .../components/chat/ThreadFindProvider.tsx | 30 +- .../src/components/chat/canvasStore.test.ts | 83 ++ apps/web/src/components/chat/canvasStore.ts | 157 ++++ .../src/components/chat/composerCommandKey.ts | 255 ++++++ .../chat/composerOverlayMeasurer.ts | 162 ++++ .../chat/optimisticUserMessages.test.ts | 141 +++ .../components/chat/optimisticUserMessages.ts | 160 ++++ .../components/chat/pasteToFocusComposer.ts | 48 + .../components/chat/threadFindActionBus.ts | 80 -- ...seAssistantCitationTarget.browser.test.tsx | 48 + .../chat/useAssistantCitationTarget.ts | 284 +++--- .../chat/useAttachmentPreviewHandoff.ts | 239 ++--- .../components/chat/useChatTerminalActions.ts | 29 +- .../chat/useComposerAttachmentIntake.ts | 34 +- .../components/chat/useComposerCommandKey.ts | 264 ------ .../chat/useComposerContextImport.ts | 15 +- .../chat/useComposerMultilinePrompt.ts | 16 +- .../chat/useComposerRestingTransition.ts | 43 +- .../src/components/chat/useComposerSend.ts | 162 ++-- .../src/components/chat/useComposerStash.ts | 187 ++-- .../useComposerTypingGuard.browser.test.tsx | 91 ++ .../chat/useComposerTypingGuard.test.ts | 112 +++ .../components/chat/useComposerTypingGuard.ts | 156 +++- .../chat/useDraftEnvironmentRouting.ts | 79 +- .../components/chat/useMultipleModelSend.ts | 4 - .../chat/usePasteToFocusComposer.ts | 53 -- .../chat/usePendingThreadRequests.ts | 288 ++++-- .../useProviderWorkspaceSnapshotRefresh.ts | 149 +--- .../src/components/chat/useQueuedRunEdit.ts | 145 +-- .../components/chat/useRightPanelActions.ts | 48 +- .../chat/useSystemComposerBanners.tsx | 31 +- apps/web/src/components/chat/useThreadFind.ts | 223 ++--- .../chat/useThreadFindPositionReader.ts | 26 +- .../src/components/chat/useTimelineFollow.ts | 754 +++++++--------- .../chat/useTimelinePositionRestore.ts | 227 ++--- .../src/components/chat/useTimelineRewind.ts | 3 - .../cloud/CloudEnvironmentConnectList.tsx | 213 +---- .../src/components/composerFooterLayout.ts | 7 - .../SnapShotCoordinator.browser.test.ts | 87 +- .../desktop/SnapShotCoordinator.tsx | 381 ++++---- apps/web/src/components/guest/GuestPage.tsx | 5 +- .../WelcomeWizard.import.browser.test.tsx | 44 + .../components/onboarding/WelcomeWizard.tsx | 438 ++++----- .../preview/ThreadPreviewMiniPlayer.tsx | 192 ++-- .../preview/useAgentOpenedPreviews.ts | 143 --- .../promptLinks/ThreadLinkPrompt.tsx | 49 - .../promptLinks/threadLinkPrompts.ts | 56 ++ .../components/sidebar/SidebarThreadRow.tsx | 217 ++--- .../src/components/sidebar/mainAppLocation.ts | 23 +- .../sidebar/threadNavigationCommands.ts | 93 ++ apps/web/src/composerDraftStore.test.ts | 378 ++++++++ apps/web/src/composerDraftStore.ts | 8 - .../hooks/FileMetadataThreadProvider.test.ts | 34 + .../src/hooks/FileMetadataThreadProvider.tsx | 133 ++- apps/web/src/hooks/useActiveThreadRef.ts | 20 +- .../src/hooks/useThreadVisitedMigration.ts | 38 +- .../src/hooks/useWorkspaceMutationRefresh.ts | 26 - apps/web/src/keybindings.ts | 8 - .../lib/desktopPasteAsText.browser.test.ts | 48 + apps/web/src/lib/desktopPasteAsText.ts | 83 +- apps/web/src/lib/terminalFocus.test.ts | 280 ++++++ apps/web/src/lib/windowRefocus.ts | 18 + apps/web/src/pendingUserInput.ts | 29 - apps/web/src/questionAttachments.test.ts | 86 +- apps/web/src/questionAttachments.ts | 120 ++- apps/web/src/reopenClosedView.ts | 3 + apps/web/src/rightPanelStore.test.ts | 79 +- apps/web/src/rightPanelStore.ts | 61 +- apps/web/src/routes/__root.tsx | 3 - apps/web/src/routes/_chat.tsx | 200 +---- apps/web/src/shortcuts/chatRouteCommands.ts | 173 ++++ .../commandPaletteCommands.browser.test.tsx | 47 + .../src/shortcuts/commandPaletteCommands.ts | 125 +++ .../src/sidebarPendingFileDropStore.test.ts | 121 +-- apps/web/src/sidebarPendingFileDropStore.ts | 103 ++- apps/web/src/state/entities.ts | 13 - apps/web/src/state/terminalSessions.test.ts | 44 +- apps/web/src/state/terminalSessions.ts | 115 ++- knip.jsonc | 17 - 117 files changed, 8344 insertions(+), 6751 deletions(-) create mode 100644 apps/web/src/attachmentUploads.test.ts create mode 100644 apps/web/src/attachmentUploads.ts create mode 100644 apps/web/src/commandPaletteStore.ts create mode 100644 apps/web/src/components/Sidebar.logic.browser.test.tsx create mode 100644 apps/web/src/components/chat/ChatRoot.tsx create mode 100644 apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx create mode 100644 apps/web/src/components/chat/canvasStore.test.ts create mode 100644 apps/web/src/components/chat/canvasStore.ts create mode 100644 apps/web/src/components/chat/composerCommandKey.ts create mode 100644 apps/web/src/components/chat/composerOverlayMeasurer.ts create mode 100644 apps/web/src/components/chat/optimisticUserMessages.test.ts create mode 100644 apps/web/src/components/chat/optimisticUserMessages.ts create mode 100644 apps/web/src/components/chat/pasteToFocusComposer.ts delete mode 100644 apps/web/src/components/chat/threadFindActionBus.ts create mode 100644 apps/web/src/components/chat/useAssistantCitationTarget.browser.test.tsx delete mode 100644 apps/web/src/components/chat/useComposerCommandKey.ts create mode 100644 apps/web/src/components/chat/useComposerTypingGuard.browser.test.tsx create mode 100644 apps/web/src/components/chat/useComposerTypingGuard.test.ts delete mode 100644 apps/web/src/components/chat/usePasteToFocusComposer.ts delete mode 100644 apps/web/src/components/preview/useAgentOpenedPreviews.ts delete mode 100644 apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx create mode 100644 apps/web/src/components/promptLinks/threadLinkPrompts.ts create mode 100644 apps/web/src/components/sidebar/threadNavigationCommands.ts create mode 100644 apps/web/src/hooks/FileMetadataThreadProvider.test.ts delete mode 100644 apps/web/src/hooks/useWorkspaceMutationRefresh.ts create mode 100644 apps/web/src/lib/desktopPasteAsText.browser.test.ts create mode 100644 apps/web/src/lib/terminalFocus.test.ts create mode 100644 apps/web/src/lib/windowRefocus.ts create mode 100644 apps/web/src/shortcuts/chatRouteCommands.ts create mode 100644 apps/web/src/shortcuts/commandPaletteCommands.browser.test.tsx create mode 100644 apps/web/src/shortcuts/commandPaletteCommands.ts diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index e86f140cc110b..0b7d746ad487d 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -8,11 +8,20 @@ import { applyAppearanceContrast } from "./appearanceContrast"; import { applyAppearanceFontVariables } from "./appearanceFonts"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding"; import { resolveServerBackedAppDisplayName } from "./branding.logic"; -import { subscribeComposerDraftModelReconciliation } from "./composerDraftStore"; +import { + subscribeComposerDraftModelReconciliation, + subscribeDraftThreadPromotion, + useComposerDraftStore, +} from "./composerDraftStore"; +import { startDraftAttachmentUploads } from "./attachmentUploads"; +import { retainThreadOverlaysForThread } from "./components/chat/threadOverlayRetention"; +import { focusTerminalOnThreadEnter } from "./lib/terminalFocus"; import { startBrowserProfileReporting } from "./browser/BrowserProfileReporter"; +import { startBackgroundThreadAnnouncements } from "./components/BackgroundThreadAnnouncer"; import { startElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { startIndexDraftStart } from "./components/indexDraftStart"; import { startNewThreadLinks } from "./components/promptLinks/openNewThreadLink"; +import { startThreadLinkPrompts } from "./components/promptLinks/threadLinkPrompts"; import { startHostFreezeToasts } from "./components/HostFreezeToastCoordinator"; import { KeybindingsConfigWarning } from "./components/KeybindingsConfigWarning"; import { createKeybindingsUpdateToastController } from "./components/KeybindingsUpdateToast.logic"; @@ -23,10 +32,13 @@ import { startGuideRunNotifications } from "./components/pullRequest/guide/PullR import { installReopenClosedViewCommands } from "./components/ReopenClosedViewShortcut"; import { startSlowRpcRequestToasts } from "./components/SlowRpcRequestToastCoordinator"; import { startThreadNotifications } from "./components/ThreadNotificationCoordinator"; +import { startMainAppLocationTracking } from "./components/sidebar/mainAppLocation"; import { startDesktopUpdateLaunchNotice } from "./components/sidebar/updateEverythingRun"; import { startDesktopAppActivation } from "./components/desktop/DesktopAppActivationCoordinator"; import { startDesktopWebLinks } from "./components/desktop/DesktopWebLinkCoordinator"; +import { startSnapShotCoordinator } from "./components/desktop/SnapShotCoordinator"; import { startFirstRunGate, whileFirstRunAppOpen } from "./components/onboarding/firstRunGateStore"; +import { startAgentOpenedPreviews } from "./components/preview/agentOpenedPreviews"; import { startPreviewTitleRecording } from "./browserHistoryStore"; import { stackedThreadToast, toastManager } from "./components/ui/toast"; import { isElectron } from "./env"; @@ -38,7 +50,8 @@ import { subscribeClientSettings, } from "./hooks/useSettings"; import { syncBrowserChromeTheme } from "./hooks/useTheme"; -import { installDesktopPasteAsText } from "./lib/desktopPasteAsText"; +import { startThreadVisitedMigration } from "./hooks/useThreadVisitedMigration"; +import "./lib/desktopPasteAsText"; import { syncDocumentElectronPlatformClasses, syncDocumentWindowControlsOverlayClass, @@ -51,8 +64,13 @@ import { import { startProviderAuthDelivery } from "./providerAuthDelivery"; import { getRouter, type AppRouter } from "./router"; import { appAtomRegistry } from "./rpc/atomRegistry"; +import { installChatRouteCommands } from "./shortcuts/chatRouteCommands"; +import { installCommandPaletteCommands } from "./shortcuts/commandPaletteCommands"; +import { retainPendingFileDropsForThread } from "./sidebarPendingFileDropStore"; +import { retainThreadFindForThread } from "./components/chat/threadFindStore"; import { primaryEnvironmentIdAtom } from "./state/primaryEnvironment"; import { readProject } from "./state/entities"; +import { environmentProjects } from "./state/projects"; import { primaryServerConfigAtom, primaryServerConfigEventAtom, @@ -60,7 +78,10 @@ import { } from "./state/server"; import { environmentShell, liveShellEnvironmentIdsAtom } from "./state/shell"; import { environmentThreadShells } from "./state/threads"; +import { resolveThreadRouteTarget } from "./threadRoutes"; import { useUiStateStore } from "./uiStateStore"; +import { useRightPanelStore } from "./rightPanelStore"; +import { Atom } from "effect/reactivity"; /** Writes appearance settings onto the document, touching only the groups that changed. */ function startClientAppearance(): void { @@ -221,13 +242,72 @@ function startComposerDraftServices(): void { threadRefsAtom: environmentThreadShells.threadRefsAtom, shellStateAtom: environmentShell.stateAtom, }); + subscribeDraftThreadPromotion({ + registry: appAtomRegistry, + threadShellAtom: environmentThreadShells.quietThreadShellAtom, + threadRefsAtom: environmentThreadShells.threadRefsAtom, + }); + startDraftAttachmentUploads(); +} + +/** A thread's workspace file tabs close once its project is gone from a loaded snapshot. */ +function startFileSurfaceReconciliation(): void { + const projectsChangedAtom = Atom.make((get) => [ + get(environmentProjects.projectsAtom), + get(liveShellEnvironmentIdsAtom), + ]); + appAtomRegistry.subscribe( + projectsChangedAtom, + () => + useRightPanelStore.getState().reconcileAllFileSurfaces((ref) => { + if (!appAtomRegistry.get(environmentShell.hasSnapshotAtom(ref.environmentId))) return null; + const projectId = + appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref))?.projectId ?? + useComposerDraftStore.getState().getDraftThread(ref)?.projectId; + if (projectId === undefined) return null; + return readProject(scopeProjectRef(ref.environmentId, projectId)) !== null; + }), + { immediate: true }, + ); } function startRouteServices(router: AppRouter): void { + installChatRouteCommands(router); + installCommandPaletteCommands(router); installReopenClosedViewCommands(router); + startMainAppLocationTracking(router); startIndexDraftStart(router); + startThreadLinkPrompts(router); startNewThreadLinks(router); + let enteredThreadKey: string | null = null; + router.subscribe("onResolved", () => { + // Draft-promotion redirects and sidebar file drops only serve the route they lead to. + const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + useComposerDraftStore.getState().retainPromotedDraftRedirectsForRoute(target); + retainPendingFileDropsForThread(target?.kind === "server" ? target.threadRef : null); + retainThreadFindForThread(target?.kind === "server" ? target.threadRef : null); + // Chat overlays belong to the thread they were opened in. + const draftSession = + target?.kind === "draft" + ? useComposerDraftStore.getState().getDraftSession(target.draftId) + : null; + const threadRef = + target?.kind === "server" + ? target.threadRef + : draftSession + ? scopeThreadRef(draftSession.environmentId, draftSession.threadId) + : null; + const threadKey = threadRef ? scopedThreadKey(threadRef) : null; + retainThreadOverlaysForThread(threadKey); + // Arriving at a thread with its drawer open focuses the drawer terminal; + // otherwise ChatView focuses the composer. + if (threadKey !== enteredThreadKey) { + enteredThreadKey = threadKey; + if (threadRef) focusTerminalOnThreadEnter(threadRef); + } + }); + // Each route paints its own surface, which the browser chrome samples once it is on screen. // onRendered only fires for a new href; onResolved also covers `router.invalidate()`, which // can swap the root between the bare outlet and the shell at the same address. @@ -236,8 +316,6 @@ function startRouteServices(router: AppRouter): void { router.subscribe("onResolved", resyncBrowserChrome); } -// Compat shim until the web-chat-view layer: services the base chat view and -// sidebar still run themselves are not started here yet. /** * Starts every app-lifetime service once, before the first render, and returns the router. * They run for the life of the page, so their disposers (kept for tests) are dropped here. @@ -254,7 +332,6 @@ export function startAppServices(): AppRouter { syncDocumentWindowControlsOverlayClass(); } - installDesktopPasteAsText(window.desktopBridge, window); startClientAppearance(); startDocumentTitle(); startEnvironmentThemeRepaint(); @@ -262,6 +339,8 @@ export function startAppServices(): AppRouter { startModelPreferencesMigration(); startComposerDraftServices(); + startFileSurfaceReconciliation(); + startAgentOpenedPreviews(); startPreviewTitleRecording(); startBrowserProfileReporting(); if (isElectron) startElectronBrowserHost(); @@ -271,6 +350,7 @@ export function startAppServices(): AppRouter { if (isElectron) { startDesktopAppActivation(router); startDesktopWebLinks(router); + whileFirstRunAppOpen(() => startSnapShotCoordinator(router)); } startKeybindingsUpdateToasts(); @@ -280,6 +360,8 @@ export function startAppServices(): AppRouter { startPermissionUpdateNotices(router); startProjectCloneToasts(router); startThreadNotifications(appAtomRegistry, router); + startBackgroundThreadAnnouncements(appAtomRegistry, router); + startThreadVisitedMigration(appAtomRegistry); startGuideRunNotifications(appAtomRegistry, router); if (isElectron) startDesktopUpdateLaunchNotice(); diff --git a/apps/web/src/attachmentUploads.test.ts b/apps/web/src/attachmentUploads.test.ts new file mode 100644 index 0000000000000..2c9c6e9795e03 --- /dev/null +++ b/apps/web/src/attachmentUploads.test.ts @@ -0,0 +1,202 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + type AttachmentUploadPolicy, + planAttachmentUploads, + planDraftUploadsForPolicies, +} from "./attachmentUploads"; +import { + DraftId, + QUEUED_EDIT_DRAFT_PREFIX, + type ComposerFileAttachment, + type ComposerImageAttachment, + type ComposerThreadDraftState, +} from "./composerDraftStore"; +import { questionAttachmentDraftId } from "./questionAttachments"; + +vi.mock("./lib/attachmentUploadQueue", () => ({ + releaseAttachmentUpload: vi.fn(), + releaseDraftAttachments: vi.fn(), + startAttachmentUpload: vi.fn(), +})); +vi.mock("./state/server", async () => { + const { Atom } = await import("effect/reactivity"); + return { environmentServerConfigsAtom: Atom.make(new Map()) }; +}); +vi.mock("./state/session", async () => { + const { Atom } = await import("effect/reactivity"); + return { environmentSession: { sessionStateAtom: () => Atom.make(null) } }; +}); + +const environmentId = EnvironmentId.make("environment-1"); +const otherEnvironmentId = EnvironmentId.make("environment-2"); +const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1")); +const threadKey = scopedThreadKey(threadRef); +const policy: AttachmentUploadPolicy = { supported: true, maxFileBytes: 10, canOperate: true }; + +function image(id: string): ComposerImageAttachment { + return { + type: "image", + id, + name: `${id}.png`, + mimeType: "image/png", + sizeBytes: 4, + previewUrl: `blob:${id}`, + file: new File(["png"], `${id}.png`, { type: "image/png" }), + }; +} + +function file( + id: string, + sizeBytes = 4, + extra: Partial = {}, +): ComposerFileAttachment { + return { + type: "file", + id, + name: `${id}.pdf`, + mimeType: "application/pdf", + sizeBytes, + file: new File(["pdf"], `${id}.pdf`, { type: "application/pdf" }), + ...extra, + } as ComposerFileAttachment; +} + +function draft( + attachments: ReadonlyArray, +): ComposerThreadDraftState { + return { + images: attachments.filter((attachment) => attachment.type === "image"), + files: attachments.filter((attachment) => attachment.type === "file"), + } as ComposerThreadDraftState; +} + +const ids = (attachments: ReadonlyArray<{ readonly id: string }>) => + attachments.map((attachment) => attachment.id); + +describe("planAttachmentUploads", () => { + it("starts images and files that fit, releases oversized files", () => { + const plan = planAttachmentUploads(policy, [image("a"), file("small"), file("big", 11)]); + expect(ids(plan.start)).toEqual(["a", "small"]); + expect(ids(plan.release)).toEqual(["big"]); + }); + + it("releases only unpersisted uploads when the capability is gone", () => { + const persisted = file("persisted", 4, { + uploadEnvironmentId: environmentId, + uploadedAttachmentId: "server-copy", + }); + const plan = planAttachmentUploads({ ...policy, supported: false }, [image("a"), persisted]); + expect(ids(plan.release)).toEqual(["a"]); + expect(plan.start).toEqual([]); + }); + + it("starts nothing without operate access, and no files without a size limit", () => { + expect( + planAttachmentUploads({ ...policy, canOperate: false }, [image("a"), file("f")]), + ).toEqual({ release: [], start: [] }); + const unlimited = planAttachmentUploads({ ...policy, maxFileBytes: null }, [ + image("a"), + file("f"), + ]); + expect(ids(unlimited.start)).toEqual(["a"]); + expect(ids(unlimited.release)).toEqual(["f"]); + }); +}); + +describe("planDraftUploadsForPolicies", () => { + const policies = new Map([ + [environmentId, policy], + [otherEnvironmentId, policy], + ]); + + it("re-plans every attachment of drafts in a changed environment only", () => { + const otherKey = scopedThreadKey(scopeThreadRef(otherEnvironmentId, ThreadId.make("t2"))); + const state = { + draftsByThreadKey: { + [threadKey]: draft([image("a"), file("f")]), + [otherKey]: draft([image("b")]), + }, + draftThreadsByThreadKey: {}, + }; + const work = planDraftUploadsForPolicies(new Map(), state, policies, new Set([environmentId])); + expect(work.map((entry) => [entry.draftTarget, ids(entry.start)])).toEqual([ + [threadRef, ["a", "f"]], + ]); + }); + + it("uses the environment the add named for a queued-edit draft", () => { + const key = `${QUEUED_EDIT_DRAFT_PREFIX}${threadKey}:run-1`; + const draftTarget = DraftId.make(key); + const state = { + draftsByThreadKey: { [key]: draft([image("a")]) }, + draftThreadsByThreadKey: {}, + }; + expect(planDraftUploadsForPolicies(new Map(), state, policies, null)).toEqual([]); + const targets = new Map([[key, { environmentId, draftTarget }]]); + const work = planDraftUploadsForPolicies(targets, state, policies, new Set([environmentId])); + expect(work.map((entry) => [entry.environmentId, entry.draftTarget, ids(entry.start)])).toEqual( + [[environmentId, draftTarget, ["a"]]], + ); + }); + + it("restores a draft session's environment and releases on capability loss", () => { + const key = "draft-1"; + const state = { + draftsByThreadKey: { [key]: draft([image("a")]) }, + draftThreadsByThreadKey: { [key]: { environmentId: otherEnvironmentId } }, + }; + const lost = planDraftUploadsForPolicies( + new Map(), + state, + new Map([[otherEnvironmentId, { ...policy, supported: false }]]), + null, + ); + expect(lost.map((work) => [work.environmentId, ids(work.release)])).toEqual([ + [otherEnvironmentId, ["a"]], + ]); + }); + + it("plans a draft session in its current environment, not the one its adds named", () => { + const key = "draft-moved"; + const draftTarget = DraftId.make(key); + const state = { + draftsByThreadKey: { [key]: draft([image("a")]) }, + draftThreadsByThreadKey: { [key]: { environmentId: otherEnvironmentId } }, + }; + const targets = new Map([[key, { environmentId, draftTarget }]]); + expect(planDraftUploadsForPolicies(targets, state, policies, new Set([environmentId]))).toEqual( + [], + ); + const work = planDraftUploadsForPolicies(targets, state, policies, null); + expect(work.map((entry) => [entry.environmentId, entry.draftTarget])).toEqual([ + [otherEnvironmentId, draftTarget], + ]); + }); + + it("uploads question drafts to the environment encoded in their key", () => { + const key = questionAttachmentDraftId(environmentId, threadRef.threadId, "request", "q1"); + const work = planDraftUploadsForPolicies( + new Map(), + { draftsByThreadKey: { [key]: draft([image("a")]) }, draftThreadsByThreadKey: {} }, + policies, + null, + ); + expect(work.map((entry) => [entry.environmentId, entry.draftTarget])).toEqual([ + [environmentId, key], + ]); + }); + + it("forgets targets of drafts that no longer exist", () => { + const targets = new Map([["gone", { environmentId, draftTarget: DraftId.make("gone") }]]); + planDraftUploadsForPolicies( + targets, + { draftsByThreadKey: {}, draftThreadsByThreadKey: {} }, + policies, + null, + ); + expect(targets.size).toBe(0); + }); +}); diff --git a/apps/web/src/attachmentUploads.ts b/apps/web/src/attachmentUploads.ts new file mode 100644 index 0000000000000..2d44f2b32cea7 --- /dev/null +++ b/apps/web/src/attachmentUploads.ts @@ -0,0 +1,229 @@ +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + sessionGrantsScope, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; + +import { attachmentsToReleaseOnUploadCapabilityLoss } from "./components/chat/composerAttachmentFiles"; +import { + composerFileNeedsReattach, + composerTargetKey, + DraftId, + QUEUED_EDIT_DRAFT_PREFIX, + setComposerAttachmentUploader, + type ComposerFileAttachment, + type ComposerImageAttachment, + type ComposerThreadDraftState, + type ComposerThreadTarget, + useComposerDraftStore, +} from "./composerDraftStore"; +import { releaseAttachmentUpload, startAttachmentUpload } from "./lib/attachmentUploadQueue"; +import { questionAttachmentDraftEnvironmentId } from "./questionAttachments"; +import { appAtomRegistry } from "./rpc/atomRegistry"; +import { environmentServerConfigsAtom } from "./state/server"; +import { environmentSession } from "./state/session"; + +type DraftAttachment = ComposerImageAttachment | ComposerFileAttachment; + +/** An environment without a known server config has no policy: uploads wait for it. */ +export interface AttachmentUploadPolicy { + readonly supported: boolean; + readonly maxFileBytes: number | null; + readonly canOperate: boolean; +} + +export function planAttachmentUploads( + policy: AttachmentUploadPolicy, + attachments: ReadonlyArray, +): { readonly release: DraftAttachment[]; readonly start: DraftAttachment[] } { + // The capability can flap on reconnect or version skew. Deleting a persisted hydrated upload + // here would make the next send fail verification while the file still sits in the draft. + if (!policy.supported) { + return { release: attachmentsToReleaseOnUploadCapabilityLoss(attachments), start: [] }; + } + if (!policy.canOperate) return { release: [], start: [] }; + const { maxFileBytes } = policy; + const fits = (attachment: DraftAttachment) => + attachment.type !== "file" || (maxFileBytes !== null && attachment.sizeBytes <= maxFileBytes); + return { + release: attachmentsToReleaseOnUploadCapabilityLoss( + attachments.filter((attachment) => !fits(attachment)), + ), + start: attachments.filter( + (attachment) => + fits(attachment) && !(attachment.type === "file" && composerFileNeedsReattach(attachment)), + ), + }; +} + +interface DraftUploadTarget { + readonly environmentId: EnvironmentId; + readonly draftTarget: ComposerThreadTarget; +} + +export interface DraftUploadWork extends DraftUploadTarget { + readonly release: DraftAttachment[]; + readonly start: DraftAttachment[]; +} + +interface DraftUploadState { + readonly draftsByThreadKey: Readonly>; + readonly draftThreadsByThreadKey: Readonly>; +} + +function samePolicy(left: AttachmentUploadPolicy, right: AttachmentUploadPolicy): boolean { + return ( + left.supported === right.supported && + left.maxFileBytes === right.maxFileBytes && + left.canOperate === right.canOperate + ); +} + +/** The environment a draft target names without the caller's help. */ +function namedDraftUploadTarget( + state: DraftUploadState, + target: ComposerThreadTarget, +): DraftUploadTarget | null { + if (typeof target !== "string") + return { environmentId: target.environmentId, draftTarget: target }; + const environmentId = + state.draftThreadsByThreadKey[target]?.environmentId ?? + questionAttachmentDraftEnvironmentId(target); + return environmentId ? { environmentId, draftTarget: target } : null; +} + +/** + * Draft sessions and question drafts name their environment at plan time, so a draft moved to + * another environment is planned there. Keys that name none (queued edits) use the environment + * their adds named; a thread key names its thread's environment. + */ +function draftUploadTargetForKey( + explicitTargets: ReadonlyMap, + state: DraftUploadState, + threadKey: string, +): DraftUploadTarget | null { + const named = namedDraftUploadTarget(state, DraftId.make(threadKey)); + if (named) return named; + const explicit = explicitTargets.get(threadKey); + if (explicit) return explicit; + if (threadKey.startsWith(QUEUED_EDIT_DRAFT_PREFIX)) return null; + const threadRef = parseScopedThreadKey(threadKey); + return threadRef ? { environmentId: threadRef.environmentId, draftTarget: threadRef } : null; +} + +/** + * Re-plans every attachment of the drafts in environments whose policy changed (config arrival, + * capability loss, operate scope), or of all drafts when `changed` is null. `targets` holds the + * environment adds named for drafts that name none themselves; entries for drafts that no longer + * exist are dropped. + */ +export function planDraftUploadsForPolicies( + targets: Map, + state: DraftUploadState, + policies: ReadonlyMap, + changed: ReadonlySet | null, +): DraftUploadWork[] { + for (const threadKey of targets.keys()) { + if (!state.draftsByThreadKey[threadKey]) targets.delete(threadKey); + } + const work: DraftUploadWork[] = []; + for (const [threadKey, draft] of Object.entries(state.draftsByThreadKey)) { + if (draft.images.length === 0 && draft.files.length === 0) continue; + const target = draftUploadTargetForKey(targets, state, threadKey); + if (!target || (changed && !changed.has(target.environmentId))) continue; + const policy = policies.get(target.environmentId); + if (!policy) continue; + work.push({ ...target, ...planAttachmentUploads(policy, [...draft.images, ...draft.files]) }); + } + return work; +} + +const uploadPoliciesAtom = Atom.make((get) => { + const policies = new Map(); + for (const [environmentId, config] of get(environmentServerConfigsAtom)) { + const session = get(environmentSession.sessionStateAtom(environmentId)); + const grant = Option.getOrNull(AsyncResult.value(session)); + const maxUploadBytes = config.environment.capabilities.fileAttachments?.maxUploadBytes ?? null; + policies.set(environmentId, { + supported: config.environment.capabilities.attachmentUploads === true, + maxFileBytes: maxUploadBytes === null ? null : clampFileAttachmentUploadBytes(maxUploadBytes), + canOperate: + session._tag !== "Failure" && + grant !== null && + sessionGrantsScope(grant, AuthOrchestrationOperateScope), + }); + } + return policies; +}).pipe(Atom.keepAlive, Atom.withLabel("web-attachment-upload-policies")); + +function runWork(work: DraftUploadWork): void { + for (const attachment of work.release) releaseAttachmentUpload(attachment.id); + for (const attachment of work.start) { + startAttachmentUpload({ + environmentId: work.environmentId, + image: attachment, + draftTarget: work.draftTarget, + }); + } +} + +const draftUploadTargets = new Map(); + +/** + * Called by the draft store's add, move and retarget actions. Without a known policy yet, the draft waits for its + * environment's config through `startDraftAttachmentUploads`. + */ +function uploadDraftAttachments(input: { + readonly environmentId: EnvironmentId | undefined; + readonly draftTarget: ComposerThreadTarget; + readonly attachments: ReadonlyArray; +}): void { + const named = namedDraftUploadTarget(useComposerDraftStore.getState(), input.draftTarget); + const target = + named ?? + (input.environmentId + ? { environmentId: input.environmentId, draftTarget: input.draftTarget } + : null); + if (!target) return; + if (!named) draftUploadTargets.set(composerTargetKey(input.draftTarget), target); + const policy = appAtomRegistry.get(uploadPoliciesAtom).get(target.environmentId); + if (policy) runWork({ ...target, ...planAttachmentUploads(policy, input.attachments) }); +} + +/** + * Connects the draft store's add actions to the upload queue and re-plans drafts when an + * environment's upload policy changes or stored drafts finish loading. Completion writes back to + * the owning draft from the queue itself. + */ +export function startDraftAttachmentUploads(): () => void { + setComposerAttachmentUploader(uploadDraftAttachments); + let policies: ReadonlyMap = new Map(); + const replan = (changed: ReadonlySet | null) => { + const state = useComposerDraftStore.getState(); + for (const work of planDraftUploadsForPolicies(draftUploadTargets, state, policies, changed)) { + runWork(work); + } + }; + const onPolicies = (next: ReadonlyMap) => { + const changed = new Set(); + for (const [environmentId, policy] of next) { + const previous = policies.get(environmentId); + if (!previous || !samePolicy(previous, policy)) changed.add(environmentId); + } + policies = next; + if (changed.size > 0) replan(changed); + }; + onPolicies(appAtomRegistry.get(uploadPoliciesAtom)); + const stopPolicies = appAtomRegistry.subscribe(uploadPoliciesAtom, onPolicies); + const stopHydration = useComposerDraftStore.persist.onFinishHydration(() => replan(null)); + if (useComposerDraftStore.persist.hasHydrated()) replan(null); + return () => { + setComposerAttachmentUploader(null); + stopPolicies(); + stopHydration(); + }; +} diff --git a/apps/web/src/browserFaviconStore.ts b/apps/web/src/browserFaviconStore.ts index 153694e5166f6..32cc06c053773 100644 --- a/apps/web/src/browserFaviconStore.ts +++ b/apps/web/src/browserFaviconStore.ts @@ -171,15 +171,6 @@ function mergeBrowserFaviconState( }; } -/** - * Compat shim for the base ChatView until the web-chat-view layer. The store - * now reads a thread's project from the shell or draft, so this is a no-op. - */ -export function registerFaviconProjectForThread( - _threadRef: ScopedThreadRef, - _projectRef: ScopedProjectRef, -): void {} - /** Server threads name their project in the shell; drafts in the composer draft store. */ function useFaviconProjectRefForThread(threadRef: ScopedThreadRef): ScopedProjectRef | null { const shell = useQuietThreadShell(threadRef); diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index fe30e76a94c24..a9f21a10abfb0 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -1,34 +1,35 @@ import type { EnvironmentId, PullRequestLinkedThreadsResult } from "@t3tools/contracts"; +import { dispatchCommandPalette } from "./commandPaletteStore"; + export interface CommandPaletteLinkedThreads { readonly environmentId: EnvironmentId; readonly threads: PullRequestLinkedThreadsResult["threads"]; } -const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; - export interface CommandPaletteOpenDetail { readonly open?: "add-project" | "new-thread-in"; readonly query?: string; readonly linkedThreads?: CommandPaletteLinkedThreads; } -export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { - window.dispatchEvent( - new CustomEvent(COMMAND_PALETTE_OPEN_EVENT, detail ? { detail } : undefined), - ); -} - -export function onOpenCommandPalette( - listener: (detail: CommandPaletteOpenDetail) => void, -): () => void { - const handler = (event: Event) => { - listener((event as CustomEvent).detail ?? {}); - }; - window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, handler); - return () => window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, handler); +export function openCommandPalette(detail: CommandPaletteOpenDetail = {}): void { + if (detail.open === "new-thread-in") { + dispatchCommandPalette({ _tag: "OpenNewThreadIn" }); + } else if (detail.open === "add-project") { + dispatchCommandPalette({ _tag: "OpenAddProject" }); + } else if (detail.query !== undefined) { + dispatchCommandPalette({ + _tag: "OpenSearch", + query: detail.query, + ...(detail.linkedThreads ? { linkedThreads: detail.linkedThreads } : {}), + }); + } else { + dispatchCommandPalette({ _tag: "SetOpen", open: true }); + } } +/** True while the palette popup is on screen, including its closing animation. */ export function isCommandPaletteOpen(): boolean { return ( typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null diff --git a/apps/web/src/commandPaletteStore.ts b/apps/web/src/commandPaletteStore.ts new file mode 100644 index 0000000000000..8f59e26e2eb13 --- /dev/null +++ b/apps/web/src/commandPaletteStore.ts @@ -0,0 +1,43 @@ +import { create } from "zustand"; + +import { + type CommandPaletteUiAction, + type CommandPaletteUiState, + reduceCommandPaletteUiState, +} from "./components/CommandPalette.logic"; + +const INITIAL_STATE: CommandPaletteUiState = { + open: false, + mode: "command", + openIntent: null, + session: 0, +}; + +export const useCommandPaletteStore = create(() => INITIAL_STATE); + +export function dispatchCommandPalette(action: CommandPaletteUiAction): void { + useCommandPaletteStore.setState((state) => reduceCommandPaletteUiState(state, action), true); +} + +// The palette is only mounted on app-shell routes (not /pair, sign-in or +// landings), so its shortcuts must stay inert elsewhere. +let mountedPalettes = 0; + +export function isCommandPaletteMounted(): boolean { + return mountedPalettes > 0; +} + +/** Ref callback for the palette's root; an unmounted palette cannot stay open. */ +export function commandPaletteMountRef(node: Element | null): (() => void) | undefined { + if (node === null) return undefined; + mountedPalettes += 1; + return () => { + mountedPalettes -= 1; + if (mountedPalettes === 0) { + useCommandPaletteStore.setState( + (state) => ({ ...INITIAL_STATE, session: state.session + 1 }), + true, + ); + } + }; +} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index e84e40c8f5448..20deba1b3a1ac 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,14 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; -import { - useEffect, - useRef, - useState, - useSyncExternalStore, - type CSSProperties, - type ReactNode, -} from "react"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react"; +import { useLocation, useRouter, type AnyRouter } from "@tanstack/react-router"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { isElectron } from "../env"; @@ -39,11 +32,9 @@ import { } from "../panelAnimations"; import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; import LegacyThreadSidebar from "./LegacySidebar"; -import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; -import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; import type { SidebarRouteReveal } from "./sidebar/routeReveal"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { useProjects } from "../state/entities"; @@ -64,7 +55,9 @@ import { useSidebarVisibility, } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { useThreadRouteRef } from "../hooks/useThreadRouteTarget"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { registerCommands, registerMenuAction } from "../shortcuts/dispatcher"; +import { resolveThreadRouteRef } from "../threadRoutes"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)"; @@ -126,10 +119,181 @@ function readInitialThreadSidebarWidth(): number { } } +function isSettingsPath(pathname: string) { + return pathname === "/settings" || pathname.startsWith("/settings/"); +} + +function readUsagePageOpen(router: AnyRouter) { + return router.state.location.pathname === "/usage"; +} + +function isKeybindingCaptureTarget(event: KeyboardEvent) { + return event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]"); +} + +function readRoutePanelsOpen(router: AnyRouter) { + const routeThreadRef = resolveThreadRouteRef(router.state.matches.at(-1)?.params ?? {}); + if (!routeThreadRef) return { terminalOpen: false, previewOpen: false }; + return { + terminalOpen: selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + routeThreadRef, + ).terminalOpen, + previewOpen: + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === + "preview", + }; +} + +export function registerNavigationHistoryCommands(router: AnyRouter): () => void { + return registerCommands( + { + "navigation.back": (event) => { + event.preventDefault(); + event.stopPropagation(); + window.history.back(); + }, + "navigation.forward": (event) => { + event.preventDefault(); + event.stopPropagation(); + window.history.forward(); + }, + }, + { + when: (event) => !isKeybindingCaptureTarget(event), + context: () => ({ + ...readRoutePanelsOpen(router), + usagePageOpen: readUsagePageOpen(router), + }), + }, + ); +} + +const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]); + +// A held key undoes once. Module state: undoing an archive navigates, and the +// hold must survive whatever that re-renders. +let heldUndoKey: string | null = null; + +// Undoes the thread actions in the sidebar's notice wherever the thread sidebar +// can show; Settings replaces it. Captured so mod+z reaches it before the +// composer's own undo handler consumes the key. +function registerThreadUndoCommands( + router: AnyRouter, + readSidebarVisible: () => boolean, +): () => void { + const threadSidebarShown = () => !isSettingsPath(router.state.location.pathname); + const unregister = registerCommands( + {}, + { + capture: true, + when: threadSidebarShown, + onKeyDown: (event) => { + // Cancel a held key's repeats so they cannot fall through to the + // editor's own undo, even after the last notice is gone. + if (event.repeat) { + if (event.code !== heldUndoKey) return false; + event.preventDefault(); + event.stopPropagation(); + return true; + } + // A modifier tapped mid-hold leaves the held key repeating. + if (!MODIFIER_KEYS.has(event.key)) heldUndoKey = null; + const command = resolveShortcutCommand( + event, + appAtomRegistry.get(primaryServerKeybindingsAtom), + { + context: { + terminalFocus: isTerminalFocused(), + previewFocus: isPreviewFocused(), + ...readRoutePanelsOpen(router), + // A collapsed sidebar hides the notice, so text fields keep their own undo. + editableFocus: + isEditableFocused(event.target) && + !(readSidebarVisible() && threadUndoClaimsShortcut()), + modelPickerOpen: isModelPickerOpen(), + }, + }, + ); + if (command !== "thread.undo") { + if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut(); + return false; + } + if (isCommandPaletteOpen() || isModelPickerOpen()) return false; + if (!undoLatestThreadAction()) return false; + heldUndoKey = event.code; + event.preventDefault(); + event.stopPropagation(); + return true; + }, + }, + ); + // Pastes from a menu, dictation and drops edit text without a keypress. + const onInput = () => { + if (threadSidebarShown()) releaseThreadUndoShortcut(); + }; + window.addEventListener("input", onInput, true); + return () => { + unregister(); + window.removeEventListener("input", onInput, true); + }; +} + +function registerSidebarToggleCommands(router: AnyRouter, toggleSidebar: () => void): () => void { + const unregisterCommands = registerCommands( + { + "sidebar.toggle": (event) => { + event.preventDefault(); + event.stopPropagation(); + toggleSidebar(); + }, + }, + { + capture: true, + when: (event) => + !isKeybindingCaptureTarget(event) && + // The rich-text composer claims Mod+B only to bold selected text; + // the toggle stays available everywhere else. + !( + isRichTextBoldShortcut(event, window.getSelection()) && + event.target instanceof HTMLElement && + event.target.closest('[data-composer-rich-text="true"]') + ), + context: () => ({ usagePageOpen: readUsagePageOpen(router) }), + }, + ); + const unregisterMenuAction = registerMenuAction("sidebar.toggle", toggleSidebar); + return () => { + unregisterCommands(); + unregisterMenuAction(); + }; +} + +/** Keyboard and menu commands that live as long as the app shell. */ +function registerAppShellCommands( + router: AnyRouter, + sidebar: { readonly toggle: () => void; readonly readVisibility: () => boolean }, +): () => void { + // Newest capture scope runs first: undo sees every key, then the toggle may consume it. + const disposers = [ + registerSidebarToggleCommands(router, sidebar.toggle), + registerThreadUndoCommands(router, sidebar.readVisibility), + registerNavigationHistoryCommands(router), + registerMenuAction("open-settings", () => { + if (!isSettingsPath(router.state.location.pathname)) + void router.navigate({ to: "/settings" }); + }), + ]; + return () => { + for (const dispose of disposers) dispose(); + }; +} + function SidebarControl() { + const router = useRouter(); const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar(); + const { toggleSidebar, readVisibility, isMobile, mobileSheetMounted } = useSidebar(); const isSidebarVisible = useSidebarVisibility(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); const stageBackdropVariant = useSidebarStageBackdropVariant( @@ -138,48 +302,21 @@ function SidebarControl() { const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", { context: { usagePageOpen }, }); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if ( - event.target instanceof HTMLElement && - event.target.closest("[data-keybinding-capture]") - ) { - return; - } - if ( - isRichTextBoldShortcut(event, window.getSelection()) && - event.target instanceof HTMLElement && - event.target.closest('[data-composer-rich-text="true"]') - ) { - // The rich-text composer claims Mod+B only to bold selected text; - // the toggle stays available everywhere else. - return; - } - if ( - resolveShortcutCommand(event, keybindings, { context: { usagePageOpen } }) !== - "sidebar.toggle" - ) - return; - - event.preventDefault(); - event.stopPropagation(); - toggleSidebar(); - }; - - window.addEventListener("keydown", onKeyDown, true); - const unsubscribe = window.desktopBridge?.onMenuAction((action) => { - if (action === "sidebar.toggle") toggleSidebar(); - }); - return () => { - window.removeEventListener("keydown", onKeyDown, true); - unsubscribe?.(); - }; - }, [keybindings, toggleSidebar, usagePageOpen]); + // The control lives as long as the shell, so it carries the shell's commands. Created + // once so re-renders never re-register them (which would reset their ordering). + const [appShellCommandsRef] = useState( + () => (node: HTMLDivElement | null) => + node === null + ? undefined + : registerAppShellCommands(router, { + toggle: toggleSidebar, + readVisibility, + }), + ); return (
) { - if (!routeThreadRef) return { terminalOpen: false, previewOpen: false }; - return { - terminalOpen: selectThreadTerminalUiState( - useTerminalUiStateStore.getState().terminalUiStateByThreadKey, - routeThreadRef, - ).terminalOpen, - previewOpen: - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === - "preview", - }; -} - -export function NavigationHistoryShortcuts() { - const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const routeThreadRef = useThreadRouteRef(); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if ( - event.target instanceof HTMLElement && - event.target.closest("[data-keybinding-capture]") - ) { - return; - } - const command = resolveShortcutCommand(event, keybindings, { - context: { - terminalFocus: isTerminalFocused(), - previewFocus: isPreviewFocused(), - ...routePanelsOpen(routeThreadRef), - editableFocus: isEditableFocused(event.target), - modelPickerOpen: isModelPickerOpen(), - usagePageOpen, - }, - }); - if (command !== "navigation.back" && command !== "navigation.forward") return; - - event.preventDefault(); - event.stopPropagation(); - if (command === "navigation.back") window.history.back(); - else window.history.forward(); - }; - - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, routeThreadRef, usagePageOpen]); - - return null; -} - -const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]); - -// Undoes the thread actions in the sidebar's notice. Mounted wherever the -// thread sidebar can show; Settings replaces it. Listens in the capture phase -// so mod+z reaches it before the composer's own undo handler consumes the key. -function ThreadUndoShortcut() { - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const routeThreadRef = useThreadRouteRef(); - // A collapsed sidebar hides the notice, so text fields keep their own undo. - const noticeVisible = useSidebarVisibility(); - // Outlives the effect: undoing an archive navigates, which re-runs it mid-hold. - const heldUndoKey = useRef(null); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - // A held key undoes once. Cancel its repeats so they cannot fall through - // to the editor's own undo, even after the last notice is gone. - if (event.repeat) { - if (event.code === heldUndoKey.current) { - event.preventDefault(); - event.stopPropagation(); - } - return; - } - // A modifier tapped mid-hold leaves the held key repeating. - if (!MODIFIER_KEYS.has(event.key)) heldUndoKey.current = null; - const command = resolveShortcutCommand(event, keybindings, { - context: { - terminalFocus: isTerminalFocused(), - previewFocus: isPreviewFocused(), - ...routePanelsOpen(routeThreadRef), - editableFocus: - isEditableFocused(event.target) && !(noticeVisible && threadUndoClaimsShortcut()), - modelPickerOpen: isModelPickerOpen(), - }, - }); - if (command !== "thread.undo") { - if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut(); - return; - } - if (isCommandPaletteOpen() || isModelPickerOpen()) return; - if (undoLatestThreadAction()) { - heldUndoKey.current = event.code; - event.preventDefault(); - event.stopPropagation(); - } - }; - // Pastes from a menu, dictation and drops edit text without a keypress. - const onInput = () => releaseThreadUndoShortcut(); - - window.addEventListener("keydown", onKeyDown, true); - window.addEventListener("input", onInput, true); - return () => { - window.removeEventListener("keydown", onKeyDown, true); - window.removeEventListener("input", onInput, true); - }; - }, [keybindings, noticeVisible, routeThreadRef]); - - return null; -} - // Settings swaps the thread sidebar out of the tree. Keep the lightweight // project projection subscribed so returning to a draft never renders the // zero-project state while the environment snapshot reconnects. @@ -329,7 +353,6 @@ function ProjectProjectionRetention() { } export function AppSidebarLayout({ children }: { children: ReactNode }) { - const navigate = useNavigate(); const legacySidebarEnabled = useLegacySidebarEnabled(); const [sidebarRouteReveal] = useState(() => ({ observedKey: null, @@ -337,11 +360,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { })); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); - useThreadVisitedMigration(); const pathname = useLocation({ select: (location) => location.pathname }); const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname); const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed; - const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); + const isOnSettings = isSettingsPath(pathname); const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); // A clamped drag may not change width, so viewport changes need a separate subscription. @@ -372,26 +394,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { ...windowControlsInsetStyle, } as CSSProperties; - useEffect(() => { - const onMenuAction = window.desktopBridge?.onMenuAction; - if (typeof onMenuAction !== "function") { - return; - } - - const unsubscribe = onMenuAction((action) => { - if (action === "open-settings") { - const isSettingsRoute = /^\/settings(\/|$)/.test(pathname); - if (!isSettingsRoute) { - void navigate({ to: "/settings" }); - } - } - }); - - return () => { - unsubscribe?.(); - }; - }, [navigate, pathname]); - return ( - - {isOnSettings ? null : } - ); diff --git a/apps/web/src/components/BackgroundThreadAnnouncer.tsx b/apps/web/src/components/BackgroundThreadAnnouncer.tsx index 40e0ebf3a0ba8..82c280e6e56fe 100644 --- a/apps/web/src/components/BackgroundThreadAnnouncer.tsx +++ b/apps/web/src/components/BackgroundThreadAnnouncer.tsx @@ -1,14 +1,13 @@ -import { useAtomValue } from "@effect/atom-react"; import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; -import { useEffect, useRef } from "react"; +import type { AnyRouter } from "@tanstack/react-router"; +import type { AtomRegistry } from "effect/reactivity"; import { useComposerDraftStore } from "../composerDraftStore"; -import { useLiveRegion } from "../hooks/useLiveRegion"; -import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; -import { useQuietThreadShells } from "../state/entities"; +import { createLiveRegion } from "../hooks/useLiveRegion"; import { liveShellEnvironmentIdsAtom } from "../state/shell"; -import { resolveActiveThreadRouteRef } from "../threadRoutes"; +import { environmentThreadShells } from "../state/threads"; +import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "../threadRoutes"; import { type BackgroundThreadMemory, type BackgroundThreadSnapshot, @@ -16,23 +15,30 @@ import { resolveBackgroundThreadRequest, } from "./backgroundThreadAnnouncement"; -/** - * Speaks status changes for threads other than the open one. It lives outside - * the sidebar because a collapsed sidebar is inert, which silences its rows. - */ -export function BackgroundThreadAnnouncer() { - const threads = useQuietThreadShells(); - const liveEnvironmentIds = useAtomValue(liveShellEnvironmentIdsAtom); - const routeTarget = useThreadRouteTarget(); - const routeDraftThread = useComposerDraftStore((store) => - routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, - ); - const openThreadRef = resolveActiveThreadRouteRef(routeTarget, routeDraftThread); - const openThreadKey = openThreadRef === null ? null : scopedThreadKey(openThreadRef); - const { regionRef, announce } = useLiveRegion(); - const memoryRef = useRef(new Map()); +const backgroundThreadLiveRegion = createLiveRegion(); +const attachBackgroundThreadRegion = backgroundThreadLiveRegion.attach; + +function readOpenThreadKey(router: AnyRouter): string | null { + const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + const draft = + target?.kind === "draft" + ? useComposerDraftStore.getState().getDraftSession(target.draftId) + : null; + const ref = resolveActiveThreadRouteRef(target, draft); + return ref === null ? null : scopedThreadKey(ref); +} + +/** Speaks status changes for threads other than the open one, for the life of the app. */ +export function startBackgroundThreadAnnouncements( + registry: AtomRegistry.AtomRegistry, + router: AnyRouter, +): () => void { + let memory: BackgroundThreadMemory = new Map(); + let openThreadKey = readOpenThreadKey(router); - useEffect(() => { + const update = () => { + const threads = registry.get(environmentThreadShells.quietThreadShellsAtom); + const liveEnvironmentIds = registry.get(liveShellEnvironmentIdsAtom); const now = new Date().toISOString(); const snapshots: BackgroundThreadSnapshot[] = []; for (const thread of threads) { @@ -50,15 +56,36 @@ export function BackgroundThreadAnnouncer() { snoozed: effectiveSnoozed(thread, { now }), }); } - const { memory, announcement } = nextBackgroundThreadAnnouncement( - memoryRef.current, - snapshots, - openThreadKey, - ); - memoryRef.current = memory; - if (announcement !== null) announce(announcement); - }, [threads, liveEnvironmentIds, openThreadKey, announce]); + const next = nextBackgroundThreadAnnouncement(memory, snapshots, openThreadKey); + memory = next.memory; + if (next.announcement !== null) backgroundThreadLiveRegion.announce(next.announcement); + }; + // The draft store changes on every keystroke; only a new open thread matters here. + const updateOpenThread = () => { + const next = readOpenThreadKey(router); + if (next === openThreadKey) return; + openThreadKey = next; + update(); + }; + + const unsubscribes = [ + registry.subscribe(environmentThreadShells.quietThreadShellsAtom, update), + registry.subscribe(liveShellEnvironmentIdsAtom, update), + router.subscribe("onResolved", updateOpenThread), + useComposerDraftStore.subscribe(updateOpenThread), + ]; + update(); + return () => { + for (const unsubscribe of unsubscribes) unsubscribe(); + }; +} + +/** + * The region the background announcements are spoken through. It lives outside + * the sidebar because a collapsed sidebar is inert, which silences its rows. + */ +export function BackgroundThreadAnnouncer() { // No role="status": Orca 50 maps it to a status bar in Chromium and drops its updates. - return
; + return
; } diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 426818dba7a24..d41364b8663d2 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -8,7 +8,6 @@ import { deriveAvailableRevertTurnCounts, deriveWorkspaceMutationId, } from "./ChatView.logic"; -import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { deriveProviderInstanceEntries } from "../providerInstances"; import { ensureInlineContextReferences } from "../lib/composerContextReferences"; @@ -41,9 +40,6 @@ import { getAntigravitySendBlockReason, resolveComposerProviderSelection, resolveWorktreeSetupProgress, - peekRememberedThreadTimeline, - rememberReadyThreadTimeline, - resetHeldThreadTimeline, resolveThreadSwitchTimeline, threadKeysShareEnvironment, timelineHasEphemeralPreviewUrls, @@ -52,7 +48,6 @@ import { deriveComposerSendState, deriveLockedProvider, hasServerAcknowledgedLocalDispatch, - resolveDraftPromotionNavigationTarget, waitForRevertedMessage, } from "./ChatView.logic"; @@ -105,51 +100,6 @@ const readySession = { updatedAt: "2026-03-29T00:00:10.000Z", }; -describe("resolveDraftPromotionNavigationTarget", () => { - const serverThreadRef = { environmentId, threadId }; - const preparingRun = { - ...completedTurn, - status: "preparing" as const, - startedAt: null, - completedAt: null, - }; - - it("stays on the draft until the server owns the send", () => { - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread: makeThread({ latestRun: preparingRun }), - backgroundSubmissionPending: false, - }), - ).toBeNull(); - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread: makeThread(), - backgroundSubmissionPending: false, - }), - ).toBeNull(); - }); - - it("promotes a persisted send while its worktree is still preparing", () => { - const serverThread = makeThread({ latestRun: preparingRun, latestUserMessageAt: now }); - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread, - backgroundSubmissionPending: false, - }), - ).toBe(serverThreadRef); - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread, - backgroundSubmissionPending: true, - }), - ).toBeNull(); - }); -}); - describe("deriveComposerSendState", () => { it("keeps text sendable while excluding expired terminal pills", () => { const state = deriveComposerSendState({ @@ -401,23 +351,23 @@ describe("deriveCommittedServerUserMessageIds", () => { }); describe("resolveThreadSwitchTimeline", () => { - afterEach(() => { - resetHeldThreadTimeline(); - }); - - const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] }; - - it("paints a remembered destination instead of the last-viewed thread", () => { - rememberReadyThreadTimeline(held); - rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["b1", "b2"] }); - expect(peekRememberedThreadTimeline("env-1:thread-a")).toEqual(["a1", "a2"]); + it("paints the destination's remembered timeline while it loads", () => { expect( resolveThreadSwitchTimeline({ loading: true, activeThreadKey: "env-1:thread-a", nextEntries: [], + rememberedForActive: ["a1", "a2"], }), ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + expect( + resolveThreadSwitchTimeline({ + loading: false, + activeThreadKey: "env-1:thread-a", + nextEntries: [], + rememberedForActive: ["a1", "a2"], + }), + ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); }); it("does not hold another environment's timeline across a jump", () => { @@ -427,7 +377,7 @@ describe("resolveThreadSwitchTimeline", () => { loading: true, activeThreadKey: "env-2:thread-b", nextEntries: [], - lastReady: held, + rememberedForActive: null, }), ).toEqual({ entries: [], displayThreadKey: "env-2:thread-b" }); }); @@ -668,13 +618,6 @@ describe("resolveComposerProviderSelection", () => { }); }); -describe("checkout Git memory", () => { - it("does not confuse an environment id containing the separator with a path", () => { - rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false); - expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined(); - }); -}); - describe("worktree setup visibility", () => { const stage = ( id: "fetch" | "checkout" | "submodules" | "setup-script" | "agent", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 231135d9c6073..9a67b759f8e73 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -111,7 +111,7 @@ type ThreadStructureRow = ThreadStructure["visibleTurnItems"][number]; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; -const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; +export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export function agentControlledBrowserCloseConfirmation( surfaces: readonly RightPanelSurface[], @@ -240,14 +240,6 @@ export function resolveProactiveTurnDiffAction(input: { /** Mark reactivated Diff tabs to preserve review scope. Already-active tabs must not leave a marker for * the next open. */ -export function resolveExplicitDiffOpenOnActivate(input: { - readonly surfaceKind: RightPanelSurface["kind"]; - readonly diffOpen: boolean; - readonly activeThreadRef: ScopedThreadRef; -}): ScopedThreadRef | null { - return input.surfaceKind === "diff" && !input.diffOpen ? input.activeThreadRef : null; -} - export function codexArtifactTemplatePromptToAppend( currentDraft: string, template: CodexArtifactTemplate, @@ -342,75 +334,6 @@ export function resolveDraftHeroState(input: { ); } -/** Remounting or initially emptying LegendList flashes the chat pane, even for cached threads. */ -export type HeldThreadTimeline = { - threadKey: string | null; - entries: T; - markdownCwd?: string | null; - workspaceRoot?: string | null; -}; - -const MAX_REMEMBERED_THREAD_TIMELINES = 16; - -let rememberedThreadTimelines = new Map>(); -let rememberedThreadTimelineOrder: string[] = []; -let lastReadyThreadKey: string | null = null; - -function rememberThreadTimelineEntries(held: HeldThreadTimeline): void { - if (held.threadKey === null) { - return; - } - rememberedThreadTimelines.set(held.threadKey, held); - rememberedThreadTimelineOrder = [ - ...rememberedThreadTimelineOrder.filter((key) => key !== held.threadKey), - held.threadKey, - ]; - while (rememberedThreadTimelineOrder.length > MAX_REMEMBERED_THREAD_TIMELINES) { - const evicted = rememberedThreadTimelineOrder.shift(); - if (evicted !== undefined) { - rememberedThreadTimelines.delete(evicted); - } - } - lastReadyThreadKey = held.threadKey; -} - -export function rememberReadyThreadTimeline( - held: HeldThreadTimeline, -): void { - if (held.threadKey === null || held.entries.length === 0) { - return; - } - rememberThreadTimelineEntries(held); -} - -export function peekRememberedThreadTimeline( - threadKey: string | null, -): T | null { - if (threadKey === null) { - return null; - } - return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null; -} - -export function peekHeldThreadTimeline< - T extends readonly unknown[], ->(): HeldThreadTimeline | null { - if (lastReadyThreadKey === null) { - return null; - } - const held = rememberedThreadTimelines.get(lastReadyThreadKey); - if (held === undefined || held.entries.length === 0) { - return null; - } - return held as HeldThreadTimeline; -} - -export function resetHeldThreadTimeline(): void { - rememberedThreadTimelines = new Map(); - rememberedThreadTimelineOrder = []; - lastReadyThreadKey = null; -} - export function threadKeysShareEnvironment(left: string | null, right: string | null): boolean { if (left === null || right === null) { return false; @@ -429,56 +352,28 @@ export function isPaintOnlyThreadTimeline( ); } +/** + * While a switched-to thread's detail resyncs, paint the timeline it last had + * ready instead of an empty list. + */ export function resolveThreadSwitchTimeline(input: { loading: boolean; activeThreadKey: string | null; nextEntries: T; - rememberedForActive?: T | null; - lastReady?: HeldThreadTimeline | null; + rememberedForActive: T | null; }): { entries: T; displayThreadKey: string | null } { - if (input.nextEntries.length > 0) { - return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; - } - - const rememberedForActive = - input.rememberedForActive ?? peekRememberedThreadTimeline(input.activeThreadKey); - if (input.loading && rememberedForActive !== null && rememberedForActive.length > 0) { + const { rememberedForActive } = input; + if ( + input.nextEntries.length === 0 && + input.loading && + rememberedForActive !== null && + rememberedForActive.length > 0 + ) { return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey }; } - return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; } -export function resolveDraftPromotionNavigationTarget(input: { - serverThreadRef: ScopedThreadRef | null; - serverThread: Pick | null | undefined; - backgroundSubmissionPending: boolean; -}): ScopedThreadRef | null { - if (input.backgroundSubmissionPending) { - return null; - } - const latestRun = input.serverThread?.latestRun ?? null; - const runStarted = latestRun?.startedAt != null; - const startupStopped = - latestRun?.status === "failed" || - latestRun?.status === "interrupted" || - latestRun?.status === "cancelled"; - const messagePersisted = input.serverThread?.latestUserMessageAt != null; - return runStarted || startupStopped || messagePersisted ? input.serverThreadRef : null; -} - -export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { - const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); - return () => globalThis.clearTimeout(timeoutId); -} - -export function hasEnvironmentReconnectWarningGraceElapsed( - activeEnvironmentId: EnvironmentId | null, - elapsedEnvironmentId: EnvironmentId | null, -): boolean { - return activeEnvironmentId !== null && activeEnvironmentId === elapsedEnvironmentId; -} - export function startNewThreadForProject( projectRef: ScopedProjectRef | null, handleNewThread: (projectRef: ScopedProjectRef) => Promise, @@ -697,13 +592,6 @@ export function resolveTerminalWorktreePath(input: { return input.threadWorktreePath; } -export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { - if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { - return; - } - URL.revokeObjectURL(previewUrl); -} - export async function resolveFileAttachmentUrl(input: { attachment: ChatFileAttachment; environmentId: EnvironmentId; @@ -763,18 +651,6 @@ export async function prepareRevertedMessageAttachments(input: { ); } -export function revokeUserMessagePreviewUrls(message: ChatMessage): void { - if (message.role !== "user" || !message.attachments) { - return; - } - for (const attachment of message.attachments) { - if (!isImageAttachment(attachment)) { - continue; - } - revokeBlobPreviewUrl(attachment.previewUrl); - } -} - export function timelineHasEphemeralPreviewUrls( entries: ReadonlyArray & { message?: ChatMessage }>, ): boolean { @@ -936,31 +812,7 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean return key !== null && sessionDismissedBranchMismatchKeys.has(key); } -// Remember non-Git checkouts to avoid flashing the branch strip before delayed Git status arrives. -const sessionCheckoutIsRepo = new Map(); - -function checkoutIsRepoKey(environmentId: EnvironmentId, cwd: string): string { - return JSON.stringify([environmentId, cwd]); -} - -export function rememberCheckoutIsRepo( - environmentId: EnvironmentId, - cwd: string, - isRepo: boolean, -): void { - sessionCheckoutIsRepo.set(checkoutIsRepoKey(environmentId, cwd), isRepo); -} - -export function recallCheckoutIsRepo( - environmentId: EnvironmentId, - cwd: string | null, -): boolean | undefined { - return cwd === null - ? undefined - : sessionCheckoutIsRepo.get(checkoutIsRepoKey(environmentId, cwd)); -} - -export function threadHasStarted(thread: Thread | null | undefined): boolean { +function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean(thread && (thread.latestRun !== null || thread.itemCount > 0 || thread.runtime)); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 53a6a7deb2b3f..c5563bc335f20 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,6 +1,6 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; -import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; +import { ChatRoot } from "./chat/ChatRoot"; import { announcedRun, isDetailBehindShell } from "./chat/threadActivityAnnouncement"; import { deriveAvailableRevertTurnCounts, @@ -12,6 +12,13 @@ import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread"; import { useComposerSelection } from "./chat/useComposerSelection"; import { useProjectCloneBanner } from "./chat/useProjectCloneBanner"; import { useAttachmentPreviewHandoff } from "./chat/useAttachmentPreviewHandoff"; +import { + applyOptimisticUserMessagesAction, + createOptimisticBlobPreviews, + emptyOptimisticUserMessages, + reconcileOptimisticUserMessages, +} from "./chat/optimisticUserMessages"; +import { useStore } from "zustand"; import { useTimelineRewind } from "./chat/useTimelineRewind"; import { useThreadLifecycle } from "./chat/useThreadLifecycle"; import { useRightPanelActions } from "./chat/useRightPanelActions"; @@ -19,19 +26,13 @@ import { type TerminalLaunchContext, useChatTerminalActions } from "./chat/useCh import { useComposerSend } from "./chat/useComposerSend"; import { useComposerResume } from "./chat/useComposerResume"; import { useImplementPlanInNewThread } from "./chat/useProposedPlanSend"; -import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider"; +import { ThreadFind, ThreadFindCanvas } from "./chat/ThreadFindProvider"; +import { closeThreadFind, openThreadFind, useThreadFindOpen } from "./chat/threadFindStore"; import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; import { resolveDraftHeroState } from "./ChatView.logic"; import { useQueuedRunEdit } from "./chat/useQueuedRunEdit"; -import { - isPaintOnlyThreadTimeline, - peekHeldThreadTimeline, - peekRememberedThreadTimeline, - resolveThreadSwitchTimeline, - recallCheckoutIsRepo, - rememberCheckoutIsRepo, -} from "./ChatView.logic"; +import { isPaintOnlyThreadTimeline, resolveThreadSwitchTimeline } from "./ChatView.logic"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; import { useStableCallback } from "../hooks/useStableCallback"; import { useProjectScriptEditing } from "./chat/useProjectScriptEditing"; @@ -106,7 +107,7 @@ import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { useAtomValue } from "@effect/atom-react"; import { type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; @@ -118,8 +119,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import { isElectron } from "../env"; -import { useDiffPanelStore } from "../diffPanelStore"; -import { useActiveThreadRef } from "../hooks/useActiveThreadRef"; +import { readRouteThreadKey, useActiveThreadRef } from "../hooks/useActiveThreadRef"; import { composerRequiresModifier } from "../composer-logic"; import { derivePhase, @@ -133,17 +133,14 @@ import { isLatestRunSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; +import { recallCheckoutIsRepo, revisionedVcsStatus } from "../state/revisionedVcsStatus"; import { DEFAULT_INTERACTION_MODE, type ChatMessage } from "../types"; import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider"; -import { - RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, - type ThreadPanelPresentation, -} from "../rightPanelLayout"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { PopoverCreateHandle } from "./ui/popover"; import { selectActiveRightPanel, @@ -152,9 +149,14 @@ import { selectThreadRightPanelState, useRightPanelStore, } from "../rightPanelStore"; +import { useDiffPanelStore } from "../diffPanelStore"; import { isPreviewSupportedInRuntime, useThreadPreviewState } from "../previewStateStore"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; -import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; +import { + ThreadPreviewMiniPlayer, + useThreadPreviewCanvasSource, +} from "./preview/ThreadPreviewMiniPlayer"; +import { createChatCanvasStore, resolveChatCanvasModel } from "./chat/canvasStore"; import { usePreviewSession } from "./preview/usePreviewSession"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; @@ -177,7 +179,6 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; import { newMessageId } from "~/lib/utils"; -import { registerFaviconProjectForThread } from "~/browserFaviconStore"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -200,19 +201,13 @@ import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { - isSameSidebarThreadRef, - useSidebarPendingFileDropStore, -} from "../sidebarPendingFileDropStore"; import { composerDraftHasUserContent, - type ComposerFileAttachment, type ComposerImageAttachment, type DraftThreadEnvMode, useComposerDraftStore, DraftId, } from "../composerDraftStore"; -import { type TerminalContextDraft } from "../lib/terminalContext"; import { collectInlineContextIds } from "../lib/composerContextReferences"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; @@ -225,7 +220,6 @@ import { } from "../state/server"; import { threadEnvironment, useThreadFamily } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; -import { vcsEnvironment } from "../state/vcs"; import { type EnvironmentPresentation, useEnvironmentProviders, @@ -245,7 +239,6 @@ import { useQuietThreadShell, useThreadCheckpointSummaries, } from "../state/entities"; -import { useEnvironmentShellHasSnapshot } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialogHost, openExpandedImagePreview } from "./chat/ExpandedImageDialog"; @@ -257,15 +250,12 @@ import { RevertConfirmHost, } from "./chat/ChatOverlayHosts"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; +import { useCitedHistory } from "./chat/citationProcedure"; import type { MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; import { ThreadTimeline } from "./chat/ThreadTimeline"; import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; -import { - overlayComposerIsResting, - resolveComposerTimelineInset, - resolveScrollToEndClearance, -} from "./composerFooterLayout"; +import { createComposerOverlayMeasurer } from "./chat/composerOverlayMeasurer"; import { ChatHeader } from "./chat/ChatHeader"; import { useRemoteOpenState } from "~/remoteOpen"; import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; @@ -297,9 +287,12 @@ import { import { QueuedRunsControl, type QueuedRunsControlHandle } from "./chat/QueuedRunsControl"; import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullRequest"; import { useProactivePanels } from "../hooks/useProactivePanels"; -import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEntries"; +import { + peekLastReadyThreadTimeline, + peekReadyThreadTimeline, + useIncrementalTimelineEntries, +} from "../hooks/useIncrementalTimelineEntries"; import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; -import { useAgentOpenedPreviews } from "./preview/useAgentOpenedPreviews"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; @@ -318,7 +311,6 @@ import { } from "./chat/draftHeroTransition"; import { buildLocalDraftThread, - collectUserMessageBlobPreviewUrls, deriveCommittedServerUserMessageIds, dismissBranchMismatchForSession, shouldShowPlanFollowUpPrompt, @@ -329,10 +321,8 @@ import { resolveComposerProviderSelection, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, - revokeUserMessagePreviewUrls, startNewThreadForProject, codexArtifactTemplatePromptToAppend, - shouldRefocusComposerOnWindowFocus, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; @@ -343,7 +333,6 @@ import { readEnvironmentScope, readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; -import { observeResize } from "~/lib/observeResize"; import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; import { ChatRightPanel } from "./chat/ChatRightPanel"; @@ -399,6 +388,15 @@ const noopHeldTurnDiff = (_turnId: RunId, _filePath?: string) => {}; const noopHeldRevert = (_targetTurnCount: number) => {}; const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; +// Built outside the component: React Compiler cannot lower getters in object literals. +function routeThreadKeyReader(router: Parameters[0]) { + return { + get current() { + return readRouteThreadKey(router); + }, + }; +} + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -414,13 +412,9 @@ export default function ChatView(props: ChatViewProps) { const handleNewThread = useNewThreadHandler(); const routeThreadRef = scopeThreadRef(environmentId, threadId); const routeThreadKey = scopedThreadKey(routeThreadRef); - const currentRouteThreadKeyRef = useRef(routeThreadKey); - useLayoutEffect(() => { - currentRouteThreadKeyRef.current = routeThreadKey; - return () => { - currentRouteThreadKeyRef.current = null; - }; - }, [routeThreadKey]); + const router = useRouter(); + // Async continuations compare against the thread on screen when they settle. + const currentRouteThreadKeyRef = routeThreadKeyReader(router); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -486,7 +480,6 @@ export default function ChatView(props: ChatViewProps) { updatedAt: fallbackUpdatedAt, lastVisitedAt: fallbackLastVisitedAt, }); - const promptRef = useRef(""); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const queuedRunEdit = useQueuedRunEdit({ @@ -495,7 +488,6 @@ export default function ChatView(props: ChatViewProps) { threadDraftTarget: baseComposerDraftTarget, activeThreadId: serverThread !== null || draftThread !== null ? threadId : null, serverProjection, - promptRef, composerRef, }); const composerDraftTarget = queuedRunEdit.draftTarget; @@ -565,6 +557,7 @@ export default function ChatView(props: ChatViewProps) { ? { citation, key: citationLocation.key ?? citationLocation.href } : null; })() as AssistantCitationRequest | null; + const citationHistory = useCitedHistory(citationRequest); const { resolvedTheme } = useTheme(); const composerRuntimeMode = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.runtimeMode ?? null, @@ -600,9 +593,6 @@ export default function ChatView(props: ChatViewProps) { }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const composerImagesRef = useRef([]); - const composerFilesRef = useRef([]); - const composerTerminalContextsRef = useRef([]); const branchToolbarRef = useRef(null); const [restingComposerControlsHost, setRestingComposerControlsHost] = useState(null); @@ -623,7 +613,24 @@ export default function ChatView(props: ChatViewProps) { }; const citeAssistantTextStable = useStableCallback(citeAssistantText); const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); - const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + const [optimisticBlobPreviews] = useState(createOptimisticBlobPreviews); + const [heldOptimisticUserMessages, setHeldOptimisticUserMessages] = useState(() => + emptyOptimisticUserMessages(threadId), + ); + // Acknowledged messages leave the timeline in the same render the server's copy enters it. + const optimisticState = reconcileOptimisticUserMessages( + heldOptimisticUserMessages, + threadId, + serverAcknowledgedUserMessageIds, + ); + if (optimisticState !== heldOptimisticUserMessages) { + setHeldOptimisticUserMessages(optimisticState); + } + const optimisticUserMessages = optimisticState.messages; + const setOptimisticUserMessages = (action: SetStateAction) => + setHeldOptimisticUserMessages((state) => + applyOptimisticUserMessagesAction(state, threadId, action), + ); // Draft ids survive promotion and expire on navigation, keeping the pending resend scoped correctly. const [workLocallyResendDraftId, setWorkLocallyResendDraftId] = useState(null); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< @@ -652,17 +659,19 @@ export default function ChatView(props: ChatViewProps) { setIsWorkspaceFileDragActive(false); } - useEffect(() => { - if (!isWorkspaceFileDragActive) return; - const clearWorkspaceFileDrag = () => setIsWorkspaceFileDragActive(false); - window.addEventListener("dragend", clearWorkspaceFileDrag); - return () => window.removeEventListener("dragend", clearWorkspaceFileDrag); - }, [isWorkspaceFileDragActive]); + const setWorkspaceFileDragActive = (active: boolean) => { + // A drag that ends outside the drop target never sends it dragleave. + if (active && !isWorkspaceFileDragActive) { + window.addEventListener("dragend", () => setIsWorkspaceFileDragActive(false), { + once: true, + }); + } + setIsWorkspaceFileDragActive(active); + }; const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const isMobileViewport = useMediaQuery("max-sm"); const [workspaceLayoutElement, setWorkspaceLayoutElement] = useState(null); const threadPanelPopoverAnchorRef = useRef(null); - const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [terminalUiLaunchContext, setTerminalUiLaunchContext] = useState(null); // Overrides chosen before a server thread's first turn; they belong to that thread only. @@ -683,13 +692,25 @@ export default function ChatView(props: ChatViewProps) { const getTimelineScrollableNodeStable = useStableCallback(getTimelineScrollableNode); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerTimelineInset, setComposerTimelineInset] = useState(0); - const composerTimelineInsetRef = useRef(0); const [composerReservedHeight, setComposerReservedHeight] = useState(0); - const composerReservedHeightRef = useRef(0); - const composerExpandedHeightRef = useRef(0); - const composerRestingRef = useRef(false); - const composerMountedRef = useRef(true); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); + const [ + { + attach: attachComposerOverlay, + publishHeight: publishComposerOverlayHeight, + remeasure: remeasureComposerOverlay, + startSeries: startComposerOverlaySeries, + endSeries: endComposerOverlaySeries, + showThread: showComposerOverlayThread, + }, + ] = useState(() => + createComposerOverlayMeasurer({ + setOverlayElement: setComposerOverlayElement, + setTimelineInset: setComposerTimelineInset, + setReservedHeight: setComposerReservedHeight, + setScrollToEndClearance, + }), + ); const [timelineOverflows, setTimelineOverflows] = useState(false); const fanoutSelectionsAtom = draftFanoutSelectionsAtom(routeThreadKey); const { sendInFlight: sendInFlightRef } = draftSendState(routeThreadKey); @@ -703,7 +724,6 @@ export default function ChatView(props: ChatViewProps) { }; const setMultipleModelSelectionsStable = useStableCallback(setMultipleModelSelections); const feedbackUploadsInFlightRef = useRef(new Set()); - const terminalUiOpenByThreadRef = useRef>({}); const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), @@ -919,14 +939,16 @@ export default function ChatView(props: ChatViewProps) { selectActiveRightPanel(state.byThreadKey, activeThreadRef), ); const diffOpen = activeRightPanelKind === "diff"; - const explicitDiffOpenRef = useRef(null); + // Arriving at a thread whose diff is already showing is a generic opening, so it shows the + // checkout like the store's other generic activations. No store action runs on a thread switch. useLayoutEffect(() => { - const explicitThreadRef = explicitDiffOpenRef.current; - explicitDiffOpenRef.current = null; - if (diffOpen && activeThreadRef && explicitThreadRef !== activeThreadRef) { + if ( + activeThreadRef && + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === "diff" + ) { useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); } - }, [activeThreadRef, diffOpen]); + }, [activeThreadRef]); const rightPanelState = useRightPanelStore((state) => selectThreadRightPanelState(state.byThreadKey, activeThreadRef), ); @@ -935,7 +957,6 @@ export default function ChatView(props: ChatViewProps) { ); const activePreviewState = useThreadPreviewState(activeThreadRef); const activePreviewServerEpoch = activePreviewState.serverEpoch; - const previewSessionsReady = !activeEnvironmentServerBrowser || activePreviewState.listLoaded; const resolvePreviewRuntimeTabId = activeThreadRef ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId) : undefined; @@ -976,44 +997,8 @@ export default function ChatView(props: ChatViewProps) { const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; - const [threadPanelPresentation, setThreadPanelPresentation] = - useState("inline"); const [threadPanelPopoverHandle] = useState(PopoverCreateHandle); - const threadPanelOpen = useRightPanelStore((state) => - selectThreadPanelOpen( - state.threadPanelVisibilityByThreadKey, - activeThreadRef, - threadPanelPresentation, - ), - ); - - useEffect(() => { - if (!activeThreadRef || !previewSessionsReady) return; - const hiddenTabIds = new Set( - Object.values(activePreviewState.sessions) - .filter((session) => session.runtime === "server" && session.reveal === false) - .map((session) => session.tabId), - ); - useRightPanelStore - .getState() - .reconcileBrowserSurfaces( - activeThreadRef, - Object.keys(activePreviewState.sessions), - hiddenTabIds, - ); - }, [activePreviewState.sessions, activeThreadRef, previewSessionsReady]); - - useEffect(() => { - const source = activePreviewMiniPlayer?.source; - if ( - activeThreadRef && - previewSessionsReady && - source?.kind === "browser" && - !activePreviewState.sessions[source.tabId] - ) { - usePreviewMiniPlayerStore.getState().close(activeThreadRef); - } - }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]); + const [chatCanvasStore] = useState(createChatCanvasStore); const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime); const activePlan = deriveActivePlanState(serverProjection, activeActivityRun?.runId); @@ -1073,9 +1058,6 @@ export default function ChatView(props: ChatViewProps) { }); }; const handleOpenDraftProjectSettingsStable = useStableCallback(handleOpenDraftProjectSettings); - const activeEnvironmentBootstrapComplete = useEnvironmentShellHasSnapshot( - activeThread?.environmentId ?? null, - ); const activeProjectKey = activeProject ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` : null; @@ -1103,16 +1085,7 @@ export default function ChatView(props: ChatViewProps) { const handleFilePendingChangeStable = useStableCallback(handleFilePendingChange); const configuredPreviewUrls = getConfiguredPreviewUrls(activeProjectScripts); - useEffect(() => { - if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return; - useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); - }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); - const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; - useEffect(() => { - if (!activeThreadRef || !activeProjectRef) return; - registerFaviconProjectForThread(activeThreadRef, activeProjectRef); - }, [activeProjectRef, activeThreadRef]); const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); const activeEnvironmentUnavailable = @@ -1304,12 +1277,10 @@ export default function ChatView(props: ChatViewProps) { } = usePendingThreadRequests({ environmentId, activeThreadId, - activeThread, serverProjection, supportsQuestionAttachments, composerDraftTarget, setComposerDraftPrompt, - promptRef, composerRef, setThreadError, }); @@ -1452,12 +1423,14 @@ export default function ChatView(props: ChatViewProps) { } }; const openFileAttachmentStable = useStableCallback(openFileAttachment); - const { handoffAttachmentPreviewsStable, timelineAttachmentUrlById } = - useAttachmentPreviewHandoff({ - environmentId, - serverVisibleTurnItems, - optimisticUserMessages, - }); + const { timelineAttachmentUrlById } = useAttachmentPreviewHandoff({ + environmentId, + threadId, + serverVisibleTurnItems, + handoff: optimisticState.handoff, + blobPreviews: optimisticBlobPreviews, + setOptimisticUserMessages: setHeldOptimisticUserMessages, + }); const anchoredTimelineMessages = feedbackSubmissions.flatMap((submission) => submission.status === "interrupted" ? [] @@ -1506,7 +1479,7 @@ export default function ChatView(props: ChatViewProps) { loading: timelineEmpty && threadSyncPhase !== null, activeThreadKey, nextEntries: timelineEntries, - rememberedForActive: peekRememberedThreadTimeline(activeThreadKey), + rememberedForActive: peekReadyThreadTimeline(activeThreadKey), }); const displayedTimelineKey = displayedTimeline.displayThreadKey ?? routeThreadKey; const paintOnlyDisplayedTimeline = isPaintOnlyThreadTimeline( @@ -1578,17 +1551,12 @@ export default function ChatView(props: ChatViewProps) { const gitStatusQuery = useEnvironmentQuery( gitStatusCwd === null ? null - : vcsEnvironment.status({ + : revisionedVcsStatus({ environmentId, input: { cwd: gitStatusCwd }, + revision: workspaceMutationId, }), ); - useWorkspaceMutationRefresh({ - enabled: gitStatusCwd !== null, - mutationId: workspaceMutationId, - refresh: gitStatusQuery.refresh, - resourceKey: `git-status:${activeThreadKey ?? ""}:${gitStatusCwd ?? ""}`, - }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const scriptKeybindings = useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? @@ -1630,6 +1598,8 @@ export default function ChatView(props: ChatViewProps) { payload: { answers: request.answers }, })), ); + // The native "Don't ask again" can be answered from any client (mobile, an agent over MCP), so this + // client learns of it from the projection, not from a handler of its own, and remembers it locally. useEffect(() => { if (nativeResumeCompactionDismissed && !resumeCompactionPermanentlyDismissed) { setResumeCompactionPermanentlyDismissed(true); @@ -1656,17 +1626,10 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; - const heldPaintContext = paintOnlyDisplayedTimeline - ? peekHeldThreadTimeline() - : null; + const heldPaintContext = paintOnlyDisplayedTimeline ? peekLastReadyThreadTimeline() : null; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; const liveIsGitRepo = gitStatusQuery.data?.isRepo; - useEffect(() => { - if (gitStatusCwd !== null && liveIsGitRepo !== undefined) { - rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo); - } - }, [environmentId, gitStatusCwd, liveIsGitRepo]); const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; // Provider-native subagents cannot take messages; approvals and questions go to the top-level parent. const showProviderSubagentBar = isProviderSubagent; @@ -1803,7 +1766,7 @@ export default function ChatView(props: ChatViewProps) { const composer = composerRef.current; if (!composer) return; - const currentDraft = composer.getSendContext().prompt; + const currentDraft = composer.readPrompt(); const prompt = codexArtifactTemplatePromptToAppend(currentDraft, template); if (prompt !== null && !composer.insertTextAtEnd(prompt, { ensureLeadingBoundary: true })) { toastManager.add({ @@ -1865,7 +1828,6 @@ export default function ChatView(props: ChatViewProps) { panelTerminalIds, canReuseTerminal, gitCwd, - setTerminalFocusRequestId, setTerminalUiLaunchContext, setThreadError, }); @@ -1883,6 +1845,37 @@ export default function ChatView(props: ChatViewProps) { activeThreadRef?.environmentId ?? null, AuthPreviewOperateScope, ); + const shownPreviewMiniPlayer = + activeThreadRef && + activePreviewMiniPlayer && + previewMiniPlayerVisible && + (activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) + ? activePreviewMiniPlayer + : null; + const canvasPreview = useThreadPreviewCanvasSource(activeThreadRef, shownPreviewMiniPlayer); + const threadPanelInlineOpen = useRightPanelStore((state) => + selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, activeThreadRef, "inline"), + ); + // Selects only the mode, so canvas resizes rerender the chat view only when the + // card switches presentation. + const threadPanelPresentation = useStore( + chatCanvasStore, + (state) => + resolveChatCanvasModel({ + measurements: state.measurements, + detailsCardMeasurement: state.detailsCardMeasurement, + threadRef: activeThreadRef, + preview: canvasPreview, + detailsCardInlineOpen: threadPanelInlineOpen, + }).detailsCardMode, + ); + const threadPanelOpen = useRightPanelStore((state) => + selectThreadPanelOpen( + state.threadPanelVisibilityByThreadKey, + activeThreadRef, + threadPanelPresentation, + ), + ); const supportsThreadPullRequests = serverConfig?.environment.capabilities.threadPullRequests === true; const visiblePullRequests = visibleThreadPullRequests( @@ -1891,19 +1884,7 @@ export default function ChatView(props: ChatViewProps) { const visiblePullRequestCount = visiblePullRequests.length; const pullRequestsSurfaceAvailable = isServerThread && supportsThreadPullRequests && visiblePullRequestCount > 0; - const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( - activeThreadRef?.environmentId ?? null, - ); - useAgentOpenedPreviews({ - threadRef: activeThreadRef, - deviceState, - deviceStateLoaded, - previewState: activePreviewState, - serverBrowserAvailable: activeEnvironmentServerBrowser, - panelSurfaces: rightPanelState.surfaces, - miniPlayerSource: activePreviewMiniPlayer?.source ?? null, - sheetLayout: shouldUsePlanSidebarSheet, - }); + const { state: deviceState } = useDeviceState(activeThreadRef?.environmentId ?? null); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. const activeProjectRepository = sourceControlClients.repositorySelector( @@ -1949,12 +1930,6 @@ export default function ChatView(props: ChatViewProps) { visiblePullRequestCount, onDiffPanelOpen, }); - useEffect(() => { - if (!canMaximizeRightPanel) return; - if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) { - useRightPanelStore.getState().setMaximized(routeThreadRef, true); - } - }, [canMaximizeRightPanel, routeThreadRef]); const { closeSurface: closeRightPanelSurface, closeOtherSurfaces: closeOtherRightPanelSurfacesStable, @@ -2034,9 +2009,7 @@ export default function ChatView(props: ChatViewProps) { const { showScrollToBottom, timelineLiveFollowEnabled, - cancelPositionRestoreRef, isTimelineAtLogicalEndStable, - cancelTimelineLiveFollowForUserNavigationStable, onComposerPageScrollKeyDownStable, onComposerPageScrollKeyUpStable, onComposerPageScrollReleaseStable, @@ -2046,14 +2019,14 @@ export default function ChatView(props: ChatViewProps) { onToolOutputCollapsedAtEndStable, onIsAtEndChangeStable, anchorNewTurn, + attachTimelineViewport, + attachComposerOverlay: attachFollowComposerOverlay, } = useTimelineFollow({ legendListRef, composerRef, - composerOverlayElement, composerTimelineInset, routeThreadKey, activeThreadKey, - activeThreadId: activeThread?.id, isServerThread, serverProjection, threadStatus, @@ -2064,88 +2037,12 @@ export default function ChatView(props: ChatViewProps) { setTimelineAnchor, }); - useEffect(() => { - if (!activeThread?.id || terminalUiState.terminalOpen) return; - const frame = window.requestAnimationFrame(() => { - focusComposerStable(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - }, [activeThread?.id, focusComposerStable, terminalUiState.terminalOpen]); - - useEffect(() => { - if (!activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return; - let frame: number | null = null; - const onWindowFocus = () => { - if (frame !== null) window.cancelAnimationFrame(frame); - // Wait an extra frame after window focus so the composer treats focus as an expansion request. - frame = window.requestAnimationFrame(() => { - frame = window.requestAnimationFrame(() => { - frame = null; - if (document.querySelector("[data-mcp-app-fullscreen]") !== null) return; - if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposerStable(); - }); - }); - }; - window.addEventListener("focus", onWindowFocus); - return () => { - window.removeEventListener("focus", onWindowFocus); - if (frame !== null) window.cancelAnimationFrame(frame); - }; - }, [activeThread?.id, focusComposerStable, isMobileViewport, terminalUiState.terminalOpen]); - - useEffect(() => { - if (!activeThread?.id) return; - if (activeMessageCount === 0) { - return; - } - const removedMessages = optimisticUserMessages.filter((message) => - serverAcknowledgedUserMessageIds.has(message.id), - ); - if (removedMessages.length === 0) { - return; - } - const timer = window.setTimeout(() => { - setOptimisticUserMessages((existing) => - existing.filter((message) => !serverAcknowledgedUserMessageIds.has(message.id)), - ); - }, 0); - for (const removedMessage of removedMessages) { - const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage); - if (previewUrls.length > 0) { - handoffAttachmentPreviewsStable(removedMessage.id, previewUrls); - continue; - } - revokeUserMessagePreviewUrls(removedMessage); - } - return () => { - window.clearTimeout(timer); - }; - }, [ - activeMessageCount, - activeThread?.id, - serverAcknowledgedUserMessageIds, - handoffAttachmentPreviewsStable, - optimisticUserMessages, - ]); - // Promotion drops draftId while a send may still be dispatching, so key by thread. - const [optimisticThreadId, setOptimisticThreadId] = useState(threadId); - const [retiredOptimisticMessages, setRetiredOptimisticMessages] = useState< - ReadonlyArray - >([]); - if (optimisticThreadId !== threadId) { - setOptimisticThreadId(threadId); - setRetiredOptimisticMessages(optimisticUserMessages); - setOptimisticUserMessages([]); + const [localDispatchThreadId, setLocalDispatchThreadId] = useState(threadId); + if (localDispatchThreadId !== threadId) { + setLocalDispatchThreadId(threadId); resetLocalDispatch(); } - useEffect(() => { - for (const message of retiredOptimisticMessages) { - revokeUserMessagePreviewUrls(message); - } - }, [retiredOptimisticMessages]); const activeWorktreePath = activeThread?.worktreePath ?? null; const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ @@ -2205,130 +2102,11 @@ export default function ChatView(props: ChatViewProps) { currentGitBranch: gitStatusQuery.data?.refName ?? null, }) : null; - const publishComposerOverlayHeight = (height: number) => { - const nextHeight = Math.ceil(height); - if (nextHeight <= 0) return; - const isResting = overlayComposerIsResting({ - composerMounted: composerMountedRef.current, - composerReportedResting: composerRestingRef.current, - }); - const modelStrip = composerOverlayElement?.querySelector( - '[data-composer-model-strip="true"]', - ); - const restingOnlyHeight = - modelStrip && isResting - ? Math.max( - 0, - modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop), - ) - : 0; - const noticeStack = composerOverlayElement?.querySelector( - '[data-composer-banner-stack="true"]', - ); - const expandedNoticesHeight = noticeStack - ? Math.max( - 0, - noticeStack.getBoundingClientRect().height - - Number.parseFloat(getComputedStyle(noticeStack).minHeight), - ) - : 0; - const nextInset = resolveComposerTimelineInset({ - currentInset: composerTimelineInsetRef.current, - overlayHeight: height, - isResting, - restingOnlyHeight, - expandedNoticesHeight, - }); - if (composerTimelineInsetRef.current !== nextInset) { - composerTimelineInsetRef.current = nextInset; - setComposerTimelineInset(nextInset); - } - // Reserve the expanded composer height for the terminal drawer and empty placeholder. - // Keep it while the mobile strip collapses, but allow growth from approvals or tasks. - // A hero overlay fills the pane, so reserve only its stack. - const stack = composerOverlayElement?.querySelector( - '[data-chat-composer-stack="true"]', - ); - const expandedComposerHeight = - isDraftHeroState && stack ? Math.ceil(stack.getBoundingClientRect().height) : nextInset; - const mobileCollapsed = Boolean( - stack?.querySelector('[data-chat-composer-mobile-collapsed="true"]'), - ); - if (!mobileCollapsed) composerExpandedHeightRef.current = expandedComposerHeight; - const reservedHeight = Math.max(composerExpandedHeightRef.current, expandedComposerHeight); - if (composerReservedHeightRef.current !== reservedHeight) { - composerReservedHeightRef.current = reservedHeight; - setComposerReservedHeight(reservedHeight); - } - const mainSurface = composerOverlayElement?.querySelector( - '[data-chat-composer-main-surface="true"]', - ); - const button = composerOverlayElement?.parentElement?.querySelector( - 'button[aria-label="Scroll to end"]', - ); - const clearance = - composerOverlayElement && mainSurface && button - ? resolveScrollToEndClearance({ - overlayHeight: nextHeight, - mainSurfaceTop: mainSurface.getBoundingClientRect().top, - button: button.getBoundingClientRect(), - attachments: Array.from( - composerOverlayElement.querySelectorAll( - '[data-composer-banner-surface="attached"]', - ), - (element) => element.getBoundingClientRect(), - ), - }) - : nextHeight; - setScrollToEndClearance(clearance); - }; - const publishComposerOverlayHeightStable = useStableCallback(publishComposerOverlayHeight); - // The composer's layout effect publishes this flag before parent layout effects or resize observations. - const onComposerRestingChange = (resting: boolean) => { - composerRestingRef.current = resting; - }; - const onComposerRestingChangeStable = useStableCallback(onComposerRestingChange); - useLayoutEffect(() => { - if (!composerOverlayElement) return; - composerTimelineInsetRef.current = 0; - composerReservedHeightRef.current = 0; - composerExpandedHeightRef.current = 0; - publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A thread switch must re-measure the overlay for the new timeline. - }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeightStable]); - - useLayoutEffect(() => { - if (!composerOverlayElement) return; - - const updateHeight = () => { - publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height); - }; - - updateHeight(); - // The hero overlay fills the pane, so the composer's own growth only shows on its stack. - const stack = isDraftHeroState - ? composerOverlayElement.querySelector('[data-chat-composer-stack="true"]') - : null; - return observeResize( - stack ? [composerOverlayElement, stack] : composerOverlayElement, - updateHeight, - ); - }, [ - composerOverlayElement, - isDraftHeroState, - publishComposerOverlayHeightStable, - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- The scroll-to-bottom button changes the overlay's height without resizing the observed node first. - showScrollToBottom, - ]); + // Neither ChatView nor the composer remounts on a server thread switch, so + // no element lifecycle marks the new thread's measurement series. useLayoutEffect(() => { - if (composerMountedRef.current === composerMounted) return; - composerMountedRef.current = composerMounted; - if (!composerOverlayElement) return; - composerTimelineInsetRef.current = 0; - composerReservedHeightRef.current = 0; - composerExpandedHeightRef.current = 0; - publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height); - }, [composerMounted, composerOverlayElement, publishComposerOverlayHeightStable]); + showComposerOverlayThread(activeThreadKey); + }, [activeThreadKey, showComposerOverlayThread]); const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); const activeThreadReferenceCopyTarget = activeThreadId === null || !isServerThread @@ -2411,8 +2189,6 @@ export default function ChatView(props: ChatViewProps) { pullRequestsSurfaceAvailable, gitCwd, activeProjectCwd, - explicitDiffOpenRef, - setTerminalFocusRequestId, onDiffPanelOpen, closeRightPanelSurface, }); @@ -2637,28 +2413,6 @@ export default function ChatView(props: ChatViewProps) { setTerminalUiLaunchContext(null); } - useEffect(() => { - if (!activeThreadKey) return; - const previous = terminalUiOpenByThreadRef.current[activeThreadKey] ?? false; - const current = Boolean(terminalUiState.terminalOpen); - - if (!previous && current) { - terminalUiOpenByThreadRef.current[activeThreadKey] = current; - setTerminalFocusRequestId((value) => value + 1); - return; - } else if (previous && !current) { - terminalUiOpenByThreadRef.current[activeThreadKey] = current; - const frame = window.requestAnimationFrame(() => { - focusComposerStable(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - } - - terminalUiOpenByThreadRef.current[activeThreadKey] = current; - }, [activeThreadKey, focusComposerStable, terminalUiState.terminalOpen]); - const getShortcutContext = (eventTarget: EventTarget | null = document.activeElement) => ({ terminalFocus: getTerminalFocusOwner() !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), @@ -2669,7 +2423,10 @@ export default function ChatView(props: ChatViewProps) { composerFocus: document.activeElement?.getAttribute("data-testid") === "composer-editor", draftThreadRoute: routeKind === "draft", turnRunning: phase === "running", - composerRequiresModifier: composerRequiresModifier(settings.sendShortcut, promptRef.current), + composerRequiresModifier: composerRequiresModifier( + settings.sendShortcut, + composerRef.current?.readPrompt() ?? "", + ), isWeb: !isElectron, isDesktop: isElectron, }); @@ -2677,10 +2434,9 @@ export default function ChatView(props: ChatViewProps) { const timelineSkills = activeProviderStatus ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) : EMPTY_PROVIDER_SKILLS; - const threadFindControlsRef = useRef(null); - const [isThreadFindActive, setIsThreadFindActive] = useState(false); - const openThreadFind = () => threadFindControlsRef.current?.open(); - const closeThreadFind = () => threadFindControlsRef.current?.close(); + const threadFindEnabled = + isServerThread && serverConfig?.threadFind === true && !paintOnlyDisplayedTimeline; + const isThreadFindActive = useThreadFindOpen(activeThreadRef, threadFindEnabled); useChatShortcuts({ enabled: activeThreadId !== null, @@ -2702,8 +2458,9 @@ export default function ChatView(props: ChatViewProps) { if (!event.repeat) copyActiveThreadReference(); }, "chat.find": () => { - if (!isServerThread || serverConfig?.threadFind !== true) return false; - openThreadFind(); + if (!isServerThread || serverConfig?.threadFind !== true || activeThreadKey === null) + return false; + openThreadFind(activeThreadKey); }, "composer.cycleHost": ({ event }) => { if (!canChangeEnvironment) return false; @@ -2742,7 +2499,6 @@ export default function ChatView(props: ChatViewProps) { }); }, [toggleRightPanelStable, toggleRightPanelMaximizedStable]); - usePasteToFocusComposer(activeThreadId, composerRef); const { onConfirmRevertStable, onForkFromRunStable, @@ -2764,7 +2520,6 @@ export default function ChatView(props: ChatViewProps) { supportsConversationRollback, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel, - promptRef, composerRef, composerDraftTarget, setThreadError, @@ -2851,6 +2606,7 @@ export default function ChatView(props: ChatViewProps) { activeThread, activeThreadKey, activeThreadBranch, + gitStatus: gitStatusQuery.data ?? null, activeProject, activeProjectDefaultModelSelection, activeProposedPlan, @@ -2867,10 +2623,6 @@ export default function ChatView(props: ChatViewProps) { providerInstanceEntries, composerRef, composerDraftTarget, - promptRef, - composerImagesRef, - composerFilesRef, - composerTerminalContextsRef, feedbackUploadsInFlightRef, queuedRunEdit, isConnecting, @@ -2892,8 +2644,6 @@ export default function ChatView(props: ChatViewProps) { needsLoadBalancing, loadBalancing, readPendingEnvironmentChange, - pendingApprovals, - respondingRequestIds, activePendingProgress, onAdvanceActivePendingUserInput, onRespondToApproval: onRespondToApprovalStable, @@ -2904,6 +2654,10 @@ export default function ChatView(props: ChatViewProps) { setDockedDraftHeroThreadKey, setSetupQueueSendThreadKey, setOptimisticUserMessages, + optimisticBlobPreviews: { + track: (message) => optimisticBlobPreviews.track(threadId, message), + release: (message) => optimisticBlobPreviews.release(threadId, message), + }, setFeedbackSubmissionsByThreadKey, setThreadError, setKeepFullHistory, @@ -3029,39 +2783,6 @@ export default function ChatView(props: ChatViewProps) { }; const onExpandTimelineImageStable = useStableCallback(onExpandTimelineImage); - const pendingSidebarFileDrops = useSidebarPendingFileDropStore((state) => state.pending); - const consumePendingFileDrop = useSidebarPendingFileDropStore( - (state) => state.consumePendingFileDrop, - ); - useEffect(() => { - if (pendingSidebarFileDrops.length === 0) return; - if ( - typeof composerDraftTarget === "string" || - !pendingSidebarFileDrops.some((drop) => - isSameSidebarThreadRef(composerDraftTarget, drop.threadRef), - ) - ) { - return; - } - if (!activeThread) return; - if (!composerRef.current) { - const raf = window.requestAnimationFrame(() => { - if (!composerRef.current || typeof composerDraftTarget === "string") return; - const files = consumePendingFileDrop(composerDraftTarget); - if (files !== null) composerRef.current?.addDroppedFiles(files); - }); - return () => window.cancelAnimationFrame(raf); - } - const files = consumePendingFileDrop(composerDraftTarget); - if (files !== null) composerRef.current.addDroppedFiles(files); - }, [ - activeThread, - composerDraftTarget, - composerRef, - consumePendingFileDrop, - pendingSidebarFileDrops, - ]); - const addDroppedFilesToComposer = useStableCallback( (files: Parameters[0]) => composerRef.current?.addDroppedFiles(files), @@ -3091,7 +2812,6 @@ export default function ChatView(props: ChatViewProps) { onCloseSurface={closeRightPanelSurface} terminal={{ launchContext: activeTerminalLaunchContext ?? null, - focusRequestId: terminalFocusRequestId, keybindings, onAddTerminalContext: addTerminalContextToDraftStable, onSplitTerminal: splitPanelTerminalStable, @@ -3160,15 +2880,16 @@ export default function ChatView(props: ChatViewProps) { ); const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ - setDragActive: setIsWorkspaceFileDragActive, + setDragActive: setWorkspaceFileDragActive, addFiles: addDroppedFilesToComposer, addFolders: addDroppedFoldersToComposer, }); const content = ( -
{ composerRef.current?.restoreAfterTimelineReachedEnd(); @@ -3441,9 +3164,17 @@ export default function ChatView(props: ChatViewProps) {
{ + attachFollowComposerOverlay(overlay); + const detachMeasurer = attachComposerOverlay(overlay); + return () => { + attachFollowComposerOverlay(null); + detachMeasurer(); + }; + }} inert={isRevertingCheckpoint} data-chat-composer-overlay="true" + data-draft-hero={isDraftHeroState ? "true" : undefined} className={ isDraftHeroState ? "pointer-events-none absolute inset-0 z-20 flex items-center" @@ -3555,7 +3286,6 @@ export default function ChatView(props: ChatViewProps) { isResuming } canResume={resumableRunId !== null || hasHeldQueuedRuns} - isRevertingCheckpoint={isRevertingCheckpoint} sendDisabledReason={ !canOperateThread ? "This connection cannot change threads." @@ -3636,12 +3366,9 @@ export default function ChatView(props: ChatViewProps) { getTimelineScrollableNode={getTimelineScrollableNodeStable} isTimelineAtLogicalEnd={isTimelineAtLogicalEndStable} timelineOverflows={timelineOverflows} - onComposerOverlayHeightChange={publishComposerOverlayHeightStable} - onRestingChange={onComposerRestingChangeStable} - promptRef={promptRef} - composerImagesRef={composerImagesRef} - composerFilesRef={composerFilesRef} - composerTerminalContextsRef={composerTerminalContextsRef} + onComposerOverlayHeightChange={publishComposerOverlayHeight} + onComposerOverlaySeriesStart={startComposerOverlaySeries} + onComposerOverlaySeriesEnd={endComposerOverlaySeries} onPageScrollKeyDown={onComposerPageScrollKeyDownStable} onPageScrollKeyUp={onComposerPageScrollKeyUpStable} onPageScrollRelease={onComposerPageScrollReleaseStable} @@ -3766,14 +3493,11 @@ export default function ChatView(props: ChatViewProps) { {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser ? ( ) : null} - {activeThreadRef && - activePreviewMiniPlayer && - previewMiniPlayerVisible && - (activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) ? ( + {activeThreadRef && shownPreviewMiniPlayer ? ( ) : null} @@ -3787,7 +3511,6 @@ export default function ChatView(props: ChatViewProps) { -
+ ); return ( {content} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index f37f5405c5907..7ec1a3e9ac1e3 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -9,6 +9,7 @@ import { filterPinnedBrowseEntries, filterCommandPaletteGroups, reduceCommandPaletteUiState, + resolveAddProjectEntry, createCommandPaletteSearchState, reduceCommandPaletteSearchState, type CommandPaletteUiState, @@ -32,7 +33,7 @@ describe("linked pull request thread navigation", () => { ], }; const state = reduceCommandPaletteUiState( - { open: false, mode: "command", openIntent: null }, + { open: false, mode: "command", openIntent: null, session: 0 }, { _tag: "OpenSearch", query, @@ -59,7 +60,7 @@ describe("linked pull request thread navigation", () => { describe("reduceCommandPaletteUiState", () => { it("opens PR search from another overlay and replaces an earlier search", () => { const first = reduceCommandPaletteUiState( - { open: true, mode: "files", openIntent: null }, + { open: true, mode: "files", openIntent: null, session: 0 }, { _tag: "OpenSearch", query: "https://github.com/acme/web/pull/7", @@ -69,6 +70,7 @@ describe("reduceCommandPaletteUiState", () => { open: true, mode: "command", openIntent: { kind: "search", query: "https://github.com/acme/web/pull/7" }, + session: 1, }); const second = reduceCommandPaletteUiState(first, { _tag: "OpenSearch", @@ -78,10 +80,56 @@ describe("reduceCommandPaletteUiState", () => { kind: "search", query: "https://github.com/acme/web/pull/8", }); + // The second intent starts a new session, so the open palette starts over from it. + expect(second.session).toBe(2); expect( reduceCommandPaletteUiState(second, { _tag: "SetOpen", open: false }).openIntent, ).toBeNull(); }); + + it("keeps the session across mode switches so only intents start the palette over", () => { + let state: CommandPaletteUiState = { + open: false, + mode: "command", + openIntent: null, + session: 0, + }; + state = reduceCommandPaletteUiState(state, { _tag: "OpenNewThreadIn" }); + expect(state).toMatchObject({ open: true, openIntent: { kind: "new-thread-in" }, session: 1 }); + state = reduceCommandPaletteUiState(state, { _tag: "ToggleMode", mode: "files" }); + expect(state).toMatchObject({ mode: "files", openIntent: null, session: 1 }); + state = reduceCommandPaletteUiState(state, { _tag: "OpenAddProject" }); + expect(state).toMatchObject({ + mode: "command", + openIntent: { kind: "add-project" }, + session: 2, + }); + }); +}); + +describe("resolveAddProjectEntry", () => { + const local = EnvironmentId.make("local"); + const remote = EnvironmentId.make("remote"); + + it("sends the user to connect an environment when none can add a project", () => { + expect(resolveAddProjectEntry([], null)).toEqual({ kind: "connect-environment" }); + }); + + it("asks for an environment when there is a choice or no connected default", () => { + expect( + resolveAddProjectEntry([{ environmentId: local }, { environmentId: remote }], local), + ).toEqual({ kind: "choose-environment" }); + expect(resolveAddProjectEntry([{ environmentId: local }], null)).toEqual({ + kind: "choose-environment", + }); + }); + + it("goes straight to sources for the only environment", () => { + expect(resolveAddProjectEntry([{ environmentId: local }], local)).toEqual({ + kind: "choose-source", + environmentId: local, + }); + }); }); const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -825,7 +873,12 @@ describe("command palette search return", () => { }); describe("returning from file and content search", () => { - const root: CommandPaletteUiState = { open: true, mode: "command", openIntent: null }; + const root: CommandPaletteUiState = { + open: true, + mode: "command", + openIntent: null, + session: 0, + }; const query = "https://github.com/acme/web/pull/7"; const context = { query, @@ -860,7 +913,7 @@ describe("returning from file and content search", () => { it("closing a child search discards its return context before reopening", () => { const child: CommandPaletteUiState = { ...root, mode: "files", parentSearch: context }; const closed = reduceCommandPaletteUiState(child, { _tag: "SetOpen", open: false }); - expect(closed).toEqual({ open: false, mode: "files", openIntent: null }); + expect(closed).toEqual({ open: false, mode: "files", openIntent: null, session: 0 }); expect(reduceCommandPaletteUiState(closed, { _tag: "BackToCommand" })).toEqual(closed); expect(reduceCommandPaletteUiState(closed, { _tag: "SetOpen", open: true })).toEqual(root); }); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 7ac1410b2c87c..a2c76bb0494c2 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -81,7 +81,10 @@ export type CommandPaletteOpenIntent = export interface CommandPaletteUiState { readonly open: boolean; readonly mode: SearchOverlayMode; + /** What the current palette session opened to; read once when the palette view mounts. */ readonly openIntent: CommandPaletteOpenIntent | null; + /** Bumped by every intent so an open palette starts over from the new one. */ + readonly session: number; readonly parentSearch?: CommandPaletteReturnContext; readonly restoredSearch?: CommandPaletteReturnContext; } @@ -97,7 +100,6 @@ export type CommandPaletteUiAction = | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } | { readonly _tag: "OpenChangeTheme" } - | { readonly _tag: "ClearOpenIntent" } | { readonly _tag: "OpenChildSearch"; readonly mode: "files" | "content"; @@ -109,47 +111,70 @@ export function reduceCommandPaletteUiState( state: CommandPaletteUiState, action: CommandPaletteUiAction, ): CommandPaletteUiState { + const { session } = state; + const openWithIntent = (openIntent: CommandPaletteOpenIntent): CommandPaletteUiState => ({ + open: true, + mode: "command", + openIntent, + session: session + 1, + }); switch (action._tag) { case "SetOpen": return action.open - ? { open: true, mode: "command", openIntent: state.openIntent } - : { open: false, mode: state.mode, openIntent: null }; + ? { open: true, mode: "command", openIntent: state.openIntent, session } + : { open: false, mode: state.mode, openIntent: null, session }; case "ToggleMode": return state.open && state.mode === action.mode - ? { open: false, mode: state.mode, openIntent: null } - : { open: true, mode: action.mode, openIntent: null }; + ? { open: false, mode: state.mode, openIntent: null, session } + : { open: true, mode: action.mode, openIntent: null, session }; case "OpenChildSearch": - return { open: true, mode: action.mode, openIntent: null, parentSearch: action.context }; + return { + open: true, + mode: action.mode, + openIntent: null, + session, + parentSearch: action.context, + }; case "BackToCommand": return state.open && state.mode !== "command" ? { open: true, mode: "command", openIntent: null, + session, ...(state.parentSearch ? { restoredSearch: state.parentSearch } : {}), } : state; case "OpenSearch": - return { - open: true, - mode: "command", - openIntent: { - kind: "search", - query: action.query, - ...(action.linkedThreads ? { linkedThreads: action.linkedThreads } : {}), - }, - }; + return openWithIntent({ + kind: "search", + query: action.query, + ...(action.linkedThreads ? { linkedThreads: action.linkedThreads } : {}), + }); case "OpenAddProject": - return { open: true, mode: "command", openIntent: { kind: "add-project" } }; + return openWithIntent({ kind: "add-project" }); case "OpenNewThreadIn": - return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } }; + return openWithIntent({ kind: "new-thread-in" }); case "OpenChangeTheme": - return { open: true, mode: "command", openIntent: { kind: "change-theme" } }; - case "ClearOpenIntent": - return state.openIntent ? { ...state, openIntent: null } : state; + return openWithIntent({ kind: "change-theme" }); } } +/** Where "Add project" starts, given the environments that can take a new project. */ +export type AddProjectEntry = + | { readonly kind: "connect-environment" } + | { readonly kind: "choose-environment" } + | { readonly kind: "choose-source"; readonly environmentId: EnvironmentId }; + +export function resolveAddProjectEntry( + options: ReadonlyArray>, + defaultEnvironmentId: EnvironmentId | null, +): AddProjectEntry { + if (options.length === 0) return { kind: "connect-environment" }; + if (options.length > 1 || defaultEnvironmentId === null) return { kind: "choose-environment" }; + return { kind: "choose-source", environmentId: defaultEnvironmentId }; +} + export interface CommandPaletteThreadContentMatch { readonly source: "user" | "assistant"; readonly snippet: string; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 7951655a2daf5..0ee75b036046c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,7 +1,11 @@ import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import { canCreateProjectInEnvironment, getCloneDestinationBrowsePath, @@ -66,12 +70,10 @@ import { TextSearchIcon, UserPlusIcon, } from "lucide-react"; -import { requestThreadFindOpen } from "./chat/threadFindActionBus"; +import { openThreadFind } from "./chat/threadFindStore"; import { useDeferredValue, useEffect, - useEffectEvent, - useLayoutEffect, useReducer, useRef, useState, @@ -93,7 +95,6 @@ import { } from "./promptLinks/copyPromptLink"; import { useClientSettings } from "../hooks/useSettings"; import { useStableCallback } from "../hooks/useStableCallback"; -import { useTheme } from "../hooks/useTheme"; import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; import { filesystemEnvironment, useFilesystemReadAccess } from "../state/filesystem"; @@ -126,14 +127,12 @@ import { isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, } from "../lib/projectPaths"; -import { onOpenCommandPalette } from "../commandPaletteBus"; -import { isPreviewFocused } from "../lib/previewFocus"; -import { isTerminalFocused } from "../lib/terminalFocus"; import { - PULL_REQUESTS_PANEL_REF, - selectActiveRightPanel, - useRightPanelStore, -} from "../rightPanelStore"; + commandPaletteMountRef, + dispatchCommandPalette, + useCommandPaletteStore, +} from "../commandPaletteStore"; +import { PULL_REQUESTS_PANEL_REF, useRightPanelStore } from "../rightPanelStore"; import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; import { cn, @@ -142,7 +141,6 @@ import { isWindowsPlatform, newProjectId, } from "../lib/utils"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams } from "../threadRoutes"; import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems"; import { @@ -173,14 +171,14 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, - reduceCommandPaletteUiState, createCommandPaletteSearchState, + type CommandPaletteGroup, + resolveAddProjectEntry, type AddProjectCloneFlow, type AddProjectEnvironmentOption, buildPaletteRemoteSourceReadiness, errorMessage, getEnvironmentBrowsePlatform, - overlayModeForCommand, remoteProjectInputPlaceholder, reduceCommandPaletteSearchState, type SearchOverlayMode, @@ -206,11 +204,7 @@ import { sourceControlHostIcon, } from "./CommandPaletteAddProject"; import { useCommandPaletteThreadItems } from "./CommandPaletteThreadItems"; -import { - APPEARANCE_OPTIONS, - notifyThemeSaveFailure, - useCommandPaletteThemeItems, -} from "./CommandPaletteThemeItems"; +import { useCommandPaletteThemeItems } from "./CommandPaletteThemeItems"; import { CommandPaletteVirtualizedResults, scrollCommandPaletteRowIntoView, @@ -222,8 +216,11 @@ import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; import { canShareWithGuestAtom } from "./chat/guestShareAvailability"; import { openGuestShareDialog } from "./chat/guestShareDialogState"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; -import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; -import { settingsForThisPlatform, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; +import { + revealSettingsSearchTarget, + settingsForThisPlatform, + SETTINGS_SECTION_LABELS, +} from "./settings/settingsSearch"; import { primaryServerKeybindingsAtom } from "../state/server"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; @@ -239,148 +236,39 @@ import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; import { threadRecentUse } from "@t3tools/shared/paletteSearch"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; +const ADD_PROJECT_ENTRY_GROUP = "add-project"; +const NEW_THREAD_IN_GROUP = "projects"; + +/** The submenu an open intent starts in; its groups resolve while rendering. */ +function openIntentView(intent: CommandPaletteOpenIntent | null): CommandPaletteView | null { + switch (intent?.kind) { + case "add-project": + return { + addonIcon: , + groups: [{ value: ADD_PROJECT_ENTRY_GROUP, label: "", items: [] }], + }; + case "new-thread-in": + return { + addonIcon: , + groups: [{ value: NEW_THREAD_IN_GROUP, label: "Projects", items: [] }], + }; + case "change-theme": + return { + addonIcon: , + groups: [{ value: "themes", label: "Change theme", items: [] }], + }; + case "search": + case undefined: + return null; + } +} export function CommandPalette({ children }: { children: ReactNode }) { - const navigate = useNavigate(); - const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); - const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { - open: false, - mode: "command", - openIntent: null, - }); - const setOpen = (open: boolean) => dispatch({ _tag: "SetOpen", open }); - const toggleMode = (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }); - const openAddProject = () => dispatch({ _tag: "OpenAddProject" }); - const openNewThreadIn = () => dispatch({ _tag: "OpenNewThreadIn" }); - const clearOpenIntent = () => dispatch({ _tag: "ClearOpenIntent" }); + const state = useCommandPaletteStore(); + const setOpen = (open: boolean) => dispatchCommandPalette({ _tag: "SetOpen", open }); const openChildSearch = (mode: "files" | "content", context: CommandPaletteReturnContext) => - dispatch({ _tag: "OpenChildSearch", mode, context }); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const { theme, themeHalves, resolvedTheme, appearanceMode, setAppearanceMode } = useTheme(); + dispatchCommandPalette({ _tag: "OpenChildSearch", mode, context }); const composerHandleRef = useRef(null); - const routeTarget = useThreadRouteTarget(); - const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; - const terminalOpen = useTerminalUiStateStore((state) => - routeThreadRef - ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen - : false, - ); - const previewOpen = useRightPanelStore((state) => - routeThreadRef - ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" - : false, - ); - - useEffect(() => { - if (!state.open || state.mode === "command") return; - const onEscapeKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.isComposing || event.key !== "Escape") return; - event.preventDefault(); - event.stopPropagation(); - dispatch({ _tag: "BackToCommand" }); - }; - window.addEventListener("keydown", onEscapeKeyDown, true); - return () => window.removeEventListener("keydown", onEscapeKeyDown, true); - }, [state.mode, state.open]); - - useEffect(() => { - const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented) return; - const command = resolveShortcutCommand(event, keybindings, { - context: { - terminalFocus: isTerminalFocused(), - terminalOpen, - previewFocus: isPreviewFocused(), - previewOpen, - modelPickerOpen: composerHandleRef.current?.isModelPickerOpen() ?? false, - usagePageOpen, - }, - }); - if (command === "appearance.cycle") { - event.preventDefault(); - event.stopPropagation(); - if (event.repeat) return; - const nextMode = - appearanceMode === "system" ? "light" : appearanceMode === "light" ? "dark" : "system"; - if (!setAppearanceMode(nextMode)) { - notifyThemeSaveFailure(); - } else { - toastManager.add({ - id: "appearance-cycle", - title: `Appearance: ${APPEARANCE_OPTIONS.find((option) => option.mode === nextMode)?.label}`, - timeout: 1500, - }); - } - return; - } - if (command === "theme.select") { - event.preventDefault(); - event.stopPropagation(); - if (event.repeat) return; - dispatch({ _tag: "OpenChangeTheme" }); - return; - } - if (command === "themeEditor.toggle") { - event.preventDefault(); - event.stopPropagation(); - toggleThemeEditorForTheme({ - theme, - themeHalves, - initialAppearance: resolvedTheme, - }); - return; - } - if (command === "usage.open") { - event.preventDefault(); - event.stopPropagation(); - setOpen(false); - void navigate({ to: "/usage" }); - return; - } - const mode = overlayModeForCommand(command); - if (mode === null) { - return; - } - event.preventDefault(); - event.stopPropagation(); - toggleMode(mode); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [ - appearanceMode, - keybindings, - navigate, - previewOpen, - resolvedTheme, - setAppearanceMode, - setOpen, - terminalOpen, - theme, - themeHalves, - toggleMode, - usagePageOpen, - ]); - - useEffect( - () => - onOpenCommandPalette((detail) => { - if (detail.open === "new-thread-in") { - openNewThreadIn(); - } else if (detail.open === "add-project") { - openAddProject(); - } else if (detail.query !== undefined) { - dispatch({ - _tag: "OpenSearch", - query: detail.query, - ...(detail.linkedThreads ? { linkedThreads: detail.linkedThreads } : {}), - }); - } else { - setOpen(true); - } - }), - [openAddProject, openNewThreadIn, setOpen], - ); return ( @@ -389,22 +277,22 @@ export function CommandPalette({ children }: { children: ReactNode }) { onOpenChange={(open, eventDetails) => { if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { eventDetails.cancel(); - dispatch({ _tag: "BackToCommand" }); + dispatchCommandPalette({ _tag: "BackToCommand" }); return; } setOpen(open); }} > -
+
{children}
@@ -413,6 +301,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { function CommandPaletteDialog(props: { readonly mode: SearchOverlayMode; + readonly session: number; readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; readonly openChildSearch: ( @@ -420,7 +309,6 @@ function CommandPaletteDialog(props: { context: CommandPaletteReturnContext, ) => void; readonly initialContext: CommandPaletteReturnContext | undefined; - readonly clearOpenIntent: () => void; }) { const composerHandleRef = useComposerHandleContext(); @@ -453,11 +341,11 @@ function CommandPaletteDialog(props: { ) : ( )} @@ -472,28 +360,34 @@ function OpenCommandPaletteDialog(props: { context: CommandPaletteReturnContext, ) => void; readonly initialContext: CommandPaletteReturnContext | undefined; - readonly clearOpenIntent: () => void; }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); - const { clearOpenIntent, openIntent, openChildSearch, setOpen } = props; - const initialSearch = props.initialContext ?? { - query: openIntent?.kind === "search" ? openIntent.query : "", - linkedThreadSearch: openIntent?.kind === "search" ? openIntent : null, - }; - const [searchState, dispatchSearch] = useReducer( - reduceCommandPaletteSearchState, - initialSearch.query, - createCommandPaletteSearchState, + const { openChildSearch, setOpen } = props; + // Keyed by palette session: the intent and restored search only seed this + // mount, and closing must not change what the closing palette shows. + const [initialSearch] = useState( + () => + props.initialContext ?? { + query: props.openIntent?.kind === "search" ? props.openIntent.query : "", + linkedThreadSearch: props.openIntent?.kind === "search" ? props.openIntent : null, + }, ); + const linkedThreadSearch = initialSearch.linkedThreadSearch; + const [searchState, dispatchSearch] = useReducer(reduceCommandPaletteSearchState, null, () => { + const view = props.initialContext ? null : openIntentView(props.openIntent); + return view + ? { query: "", views: [{ view, parentQuery: "" }] } + : createCommandPaletteSearchState(initialSearch.query); + }); const { query, views: viewStack } = searchState; const setQuery = (query: string) => dispatchSearch({ type: "query", query }); - const [linkedThreadSearch, setLinkedThreadSearch] = useState(initialSearch.linkedThreadSearch); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); const resultListRef = useRef(null); - const highlightClearedRef = useRef(false); + // A pushed view drops the typed highlight until the user moves it again. + const highlightClearedRef = useRef(viewStack.length > 0); function clearTypedHighlight(): void { highlightClearedRef.current = true; setHighlightedItemValue(null); @@ -729,7 +623,9 @@ function OpenCommandPaletteDialog(props: { const { projectCwdById, projectByKey, projectTitleById } = useProjectLookups(); - const activeThreadId = activeThread?.id; + const activeThreadKey = activeThread + ? scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)) + : null; const supportsThreadFind = environments.find((environment) => environment.environmentId === activeThread?.environmentId) ?.serverConfig?.threadFind === true; @@ -812,6 +708,8 @@ function OpenCommandPaletteDialog(props: { useEffect( () => () => { browseNavigation.invalidate(); + // A replaced or closed session must not report its in-flight lookup. + cloneLookupGeneration.current += 1; }, [browseNavigation], ); @@ -1179,22 +1077,69 @@ function OpenCommandPaletteDialog(props: { }, ]; + const openConnectionSettings = () => { + setOpen(false); + void navigate({ to: "/settings/connections" }); + }; + const addProjectEntry = resolveAddProjectEntry( + addProjectEnvironmentOptions, + defaultAddProjectEnvironmentId, + ); + const openAddProjectFlow = () => { - if (addProjectEnvironmentOptions.length === 0) { - setOpen(false); - void navigate({ to: "/settings/connections" }); - return; + switch (addProjectEntry.kind) { + case "connect-environment": + openConnectionSettings(); + return; + case "choose-environment": + pushPaletteView({ + addonIcon: , + groups: addProjectEnvironmentGroups, + }); + return; + case "choose-source": + startAddProjectSourceSelection(addProjectEntry.environmentId); + return; } + }; - if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) { - pushPaletteView({ - addonIcon: , - groups: addProjectEnvironmentGroups, - }); - return; + // The view an "Add project" intent opens to follows the environments as they + // connect, so it resolves here instead of being fixed when the palette opened. + const addProjectEntryGroups = (): ReadonlyArray => { + switch (addProjectEntry.kind) { + case "connect-environment": + return [ + { + value: ADD_PROJECT_ENTRY_GROUP, + label: "", + items: [ + { + kind: "action", + value: "action:add-project:connect-environment", + searchTerms: ["connect", "environment", "connection", "settings"], + title: "Connect an environment", + description: "No connected environment can add a project", + icon: , + keepOpen: true, + run: async () => { + openConnectionSettings(); + }, + }, + ], + }, + ]; + case "choose-environment": + return addProjectEnvironmentGroups; + case "choose-source": + return buildAddProjectSourceGroups( + addProjectEntry.environmentId, + buildPaletteRemoteSourceReadiness( + browseEnvironmentId === addProjectEntry.environmentId + ? sourceControlDiscovery.data + : null, + ), + ); } - - void startAddProjectSourceSelection(defaultAddProjectEnvironmentId); }; const openNewProjectFlow = () => { @@ -1240,7 +1185,7 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + groups: [{ value: NEW_THREAD_IN_GROUP, label: "Projects", items: projectThreadItems }], }); } @@ -1256,7 +1201,7 @@ function OpenCommandPaletteDialog(props: { }); } - if (activeThreadId && supportsThreadFind) { + if (activeThreadKey !== null && supportsThreadFind) { actionItems.push({ kind: "action", value: "find-current-thread", @@ -1265,7 +1210,7 @@ function OpenCommandPaletteDialog(props: { icon: , shortcutCommand: "chat.find", run: async () => { - requestThreadFindOpen(); + openThreadFind(activeThreadKey); }, }); } @@ -1505,45 +1450,6 @@ function OpenCommandPaletteDialog(props: { actionItems.push(changeThemeItem); actionItems.push(changeAppearanceItem); - // Apply intents after commit but before paint because they clear parent state and may navigate. - const projectThreadItemCount = projectThreadItems.length; - const applyOpenIntent = useEffectEvent((intent: CommandPaletteOpenIntent) => { - clearOpenIntent(); - browseNavigation.invalidate(); - cloneLookupGeneration.current += 1; - setIsRemoteProjectLookingUp(false); - setAddProjectEnvironmentId(null); - setAddProjectCloneFlow(null); - setNewProjectFlow(null); - setLinkedThreadSearch(intent.kind === "search" ? intent : null); - dispatchSearch({ type: "reset", query: intent.kind === "search" ? intent.query : "" }); - switch (intent.kind) { - case "search": - return; - case "add-project": - openAddProjectFlow(); - return; - case "new-thread-in": - pushPaletteView({ - addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], - }); - return; - case "change-theme": - pushPaletteView({ - addonIcon: , - groups: [{ value: "themes", label: "Change theme", items: [] }], - }); - return; - } - }); - useLayoutEffect(() => { - if (openIntent === null) return; - if (openIntent.kind === "new-thread-in" && projectThreadItemCount === 0) return; - // oxlint-disable-next-line react/set-state-in-effect -- applying the intent dispatches to the parent reducer and can navigate, neither of which may run during render. - applyOpenIntent(openIntent); - }, [openIntent, projectThreadItemCount]); - actionItems.push(themeEditorItem); if ( @@ -1652,6 +1558,7 @@ function OpenCommandPaletteDialog(props: { ...(item.secondary ? { secondary: true } : {}), icon: , run: async () => { + revealSettingsSearchTarget(item.targetId ?? item.id); await navigate({ to: item.to, hash: item.targetId ?? item.id, @@ -1670,11 +1577,15 @@ function OpenCommandPaletteDialog(props: { addProjectEnvironmentId, buildPaletteRemoteSourceReadiness(sourceControlDiscovery.data), ) - : currentView?.groups[0]?.value === "themes" - ? changeThemeItem.groups - : currentView?.groups[0]?.value === "appearance" - ? changeAppearanceItem.groups - : (currentView?.groups ?? rootGroups); + : currentView?.groups[0]?.value === ADD_PROJECT_ENTRY_GROUP + ? addProjectEntryGroups() + : currentView?.groups[0]?.value === NEW_THREAD_IN_GROUP + ? [{ value: NEW_THREAD_IN_GROUP, label: "Projects", items: projectThreadItems }] + : currentView?.groups[0]?.value === "themes" + ? changeThemeItem.groups + : currentView?.groups[0]?.value === "appearance" + ? changeAppearanceItem.groups + : (currentView?.groups ?? rootGroups); const isLinkedThreadSearch = linkedThreadSearch?.linkedThreads !== undefined && deferredQuery === linkedThreadSearch.query; @@ -2411,11 +2322,17 @@ function OpenCommandPaletteDialog(props: { return; } // ChatView ignores shortcuts while the palette is open, so intercept find here. - if (command === "chat.find" && activeThreadId && !event.nativeEvent.isComposing) { + if (command === "chat.find" && activeThreadKey !== null && !event.nativeEvent.isComposing) { event.preventDefault(); event.stopPropagation(); setOpen(false); - requestThreadFindOpen(); + if (supportsThreadFind) openThreadFind(activeThreadKey); + else + toastManager.add({ + id: "thread-find-unavailable", + title: "Thread search is unavailable on this server.", + description: "Update the server to enable it.", + }); return; } diff --git a/apps/web/src/components/CommandPaletteContent.tsx b/apps/web/src/components/CommandPaletteContent.tsx index 7137f97b8c061..811d091bf3ec9 100644 --- a/apps/web/src/components/CommandPaletteContent.tsx +++ b/apps/web/src/components/CommandPaletteContent.tsx @@ -1,9 +1,26 @@ import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; -import { type ComponentProps, type ReactNode, useEffect, useRef } from "react"; +import { type ComponentProps, type ReactNode } from "react"; import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command"; import { Kbd, KbdGroup } from "./ui/kbd"; +/** + * A view replacing another inside an open dialog mounts with focus on the body, + * left there by the removed input. Claim it so typing cannot reach the composer + * behind the modal. On first open the dialog's own initial focus handles it. + */ +function claimFocusInOpenDialog(input: HTMLInputElement | null) { + if (!input) return; + const active = input.ownerDocument.activeElement; + if ( + active === null || + active === input.ownerDocument.body || + input.closest('[role="dialog"]')?.contains(active) + ) { + input.focus(); + } +} + type CommandPaletteContentProps = Omit, "children"> & { readonly children: ReactNode; readonly escapeLabel?: ReactNode; @@ -28,19 +45,11 @@ export function CommandPaletteContent({ testId, ...commandProps }: CommandPaletteContentProps) { - const inputRef = useRef(null); - - // Wait until the dialog records restoration focus before focusing a replaced input. - useEffect(() => { - const frame = requestAnimationFrame(() => inputRef.current?.focus()); - return () => cancelAnimationFrame(frame); - }, []); - return (
- + {inputAccessory}
literalText ? position : expandCollapsedComposerCursor(text, position); - const onChangeRef = useRef(onChange); - const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); - const onCommandKeyDownRef = useRef(onCommandKeyDown); - const isPathQueryActiveRef = useRef(isPathQueryActive); - const buildFragmentRef = useRef(buildContextClipboardFragment); - const importFragmentRef = useRef(importContextFragment); const latestValueRef = useRef(value); - const editorHolder = useRef(null); - - useEffect(() => { - onChangeRef.current = onChange; - }, [onChange]); - useEffect(() => { - onVisibleSelectionChangeRef.current = onVisibleSelectionChange; - }, [onVisibleSelectionChange]); - useEffect(() => { - onCommandKeyDownRef.current = onCommandKeyDown; - }, [onCommandKeyDown]); - useEffect(() => { - isPathQueryActiveRef.current = isPathQueryActive; - }, [isPathQueryActive]); - useEffect(() => { - buildFragmentRef.current = buildContextClipboardFragment; - }, [buildContextClipboardFragment]); - useEffect(() => { - importFragmentRef.current = importContextFragment; - }, [importContextFragment]); - useLayoutEffect(() => { - latestValueRef.current = value; - }, [value]); + // The editor config is built once, so its handlers reach the current callbacks through here. + const readCallbacks = useStableCallback(() => ({ + onChange, + onVisibleSelectionChange, + onCommandKeyDown, + isPathQueryActive, + importContextFragment, + })); // Stable so a skills refresh does not rerun the controlled-value effect. const skillLabelFor = useStableCallback((name: string) => composerSkillMeta(skills, name)); @@ -1074,7 +1054,7 @@ function ComposerPromptEditorTiptapInner( if (isApplyingControlledUpdateRef.current) return; if (!snapshotChanged) { if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { - onVisibleSelectionChangeRef.current?.(); + readCallbacks().onVisibleSelectionChange?.(); } return; } @@ -1096,7 +1076,13 @@ function ComposerPromptEditorTiptapInner( [updated.state.selection.$from.nodeBefore, updated.state.selection.$from.nodeAfter].some( (node) => node?.isInline && node.isAtom && !node.isText && node.type.name !== "hardBreak", ); - onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, contextIds); + readCallbacks().onChange( + nextValue, + nextCursor, + nextExpandedCursor, + suppressTrigger, + contextIds, + ); }; const editorAttributes = { @@ -1301,9 +1287,9 @@ function ComposerPromptEditorTiptapInner( return true; } } - const handler = onCommandKeyDownRef.current; + const handler = readCallbacks().onCommandKeyDown; if (event.key === "Enter") { - const instance = editorHolder.current; + const instance = (view.dom as TiptapEditorHTMLElement).editor; const isTaskItem = richText && (instance?.isActive("taskItem") ?? false); const isListItem = richText && (instance?.isActive("listItem") ?? false); const handled = handler?.("Enter", event, isTaskItem || isListItem) ?? false; @@ -1390,7 +1376,7 @@ function ComposerPromptEditorTiptapInner( ); return true; } - const importFragment = importFragmentRef.current; + const importFragment = readCallbacks().importContextFragment; let text = !literalText && importFragment ? importPastedComposerText(clipboardData, importFragment) @@ -1412,8 +1398,8 @@ function ComposerPromptEditorTiptapInner( const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); const literalLength = literalText ? 0 - : pastedPathQueryLength(lineBefore, text, isPathQueryActiveRef.current?.()); - const editorInstance = editorHolder.current; + : pastedPathQueryLength(lineBefore, text, readCallbacks().isPathQueryActive?.()); + const editorInstance = (view.dom as TiptapEditorHTMLElement).editor; if (editorInstance) { const $paste = view.state.selection.$from; const nested = ["listItem", "taskItem", "blockquote"].some((name) => @@ -1476,10 +1462,6 @@ function ComposerPromptEditorTiptapInner( editor?.setEditable(!disabled); }, [disabled, editor]); - useEffect(() => { - editorHolder.current = editor; - }, [editor]); - // Push attributes before layout measurements; Tiptap updates them later in a passive effect. useLayoutEffect(() => { if (!editor?.isInitialized) return; @@ -1539,7 +1521,10 @@ function ComposerPromptEditorTiptapInner( onCitationDraftSave(replacedValue, serializeEditorDoc(current.state.doc).value); }); + // The one place the controlled value reaches the editor. Imperative handlers compare against + // the committed value, so it is recorded here even before the editor exists. useLayoutEffect(() => { + latestValueRef.current = value; if (!editor) return; const initialSelection = !hasAppliedControlledSelectionRef.current; hasAppliedControlledSelectionRef.current = true; @@ -1665,7 +1650,7 @@ function ComposerPromptEditorTiptapInner( start: snapshotRef.current.expandedCursor, end: snapshotRef.current.expandedCursor, }; - onChangeRef.current( + readCallbacks().onChange( snapshotRef.current.value, boundedCursor, snapshotRef.current.expandedCursor, @@ -1756,7 +1741,7 @@ function ComposerPromptEditorTiptapInner( ); const handleCopyCut = (event: React.ClipboardEvent, cut: boolean) => { - const build = buildFragmentRef.current; + const build = buildContextClipboardFragment; if (!editor || (cut && !editor.isEditable)) return; const clipboardData = event.clipboardData; const { from, to } = editor.state.selection; diff --git a/apps/web/src/components/HostedEmptyState.tsx b/apps/web/src/components/HostedEmptyState.tsx index 4bf8195a5ea1b..929ed5765e5df 100644 --- a/apps/web/src/components/HostedEmptyState.tsx +++ b/apps/web/src/components/HostedEmptyState.tsx @@ -1,17 +1,17 @@ import { useAuth } from "@clerk/react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { RegistryContext, useAtomValue } from "@effect/atom-react"; import { Link } from "@tanstack/react-router"; import { CheckIcon } from "lucide-react"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useContext, useState } from "react"; import { APP_DISPLAY_NAME, DESKTOP_DOWNLOAD_URL } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { isElectron } from "../env"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; -import { useRelayEnvironmentDiscovery } from "../state/environments"; import { CommandBlock } from "./CommandBlock"; import { useT3ConnectAuthPrompt } from "./clerk/useT3ConnectAuthPrompt"; import { CloudEnvironmentConnectRows } from "./cloud/CloudEnvironmentConnectList"; +import { createComputerSelection, resolveSelectedComputers } from "./cloud/relayDiscoveryStore"; import { Button } from "./ui/button"; import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset } from "./ui/sidebar"; @@ -66,13 +66,13 @@ export function HostedEmptyState() { function T3ConnectSteps() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { openAuthPrompt } = useT3ConnectAuthPrompt(); + const registry = useContext(RegistryContext); // Adding the first computer unmounts this screen. Wait for all health checks // so every compatible registration is queued before that happens. - const [autoAddedComputers] = useState(() => new Set()); - const [selectedIds, setSelectedIds] = useState>(() => new Set()); - const discovered = [...useRelayEnvironmentDiscovery().environments.values()]; - const discoverySettled = - discovered.length > 0 && discovered.every(({ availability }) => availability !== "checking"); + const [computerSelection] = useState(() => + createComputerSelection(registry, { waitForHealthChecks: true, refreshWhileHidden: false }), + ); + const selectedIds = resolveSelectedComputers([], useAtomValue(computerSelection.choicesAtom)); return ( <> @@ -94,18 +94,7 @@ function T3ConnectSteps() { - setSelectedIds((current) => { - const next = new Set(current); - if (selected) next.add(environmentId); - else next.delete(environmentId); - return next; - }), - }} + selection={{ store: computerSelection, selectedIds }} empty={

Waiting for your computer…

} />
diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index ae85add1c9b9f..7c9dae63647c2 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -24,7 +24,7 @@ import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; -import React, { useEffect, useEffectEvent, memo, useRef, useState } from "react"; +import React, { createContext, memo, useContext, useRef, useState } from "react"; import { cn } from "~/lib/utils"; import { useShallow } from "zustand/react/shallow"; import { @@ -83,9 +83,8 @@ import { useStableCallback } from "../hooks/useStableCallback"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; -import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; -import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; +import { clearPendingFileDrop, queuePendingFileDrop } from "../sidebarPendingFileDropStore"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { readThreadShell, @@ -111,12 +110,9 @@ import { useUiStateStore, } from "../uiStateStore"; import { - resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, } from "../keybindings"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; @@ -180,14 +176,13 @@ import { getThreadKeysToDeselectAfterDelete, useThreadSelectionStore, } from "../threadSelectionStore"; -import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; +import { openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, deleteSelectedThreadEntries, filterSidebarV2VisibleThreads, getSidebarThreadIdsToPrewarm, - resolveAdjacentThreadId, isContextMenuPointerDown, isSidebarNestedLinkClick, isTrailingDoubleClick, @@ -198,11 +193,13 @@ import { shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, useSidebarRowSubscriptionLease, - useThreadJumpHintVisibility, + setFileDragActive, + useThreadJumpHintsVisible, ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { threadKeyList, threadNavigationScopeRef } from "./sidebar/threadNavigationCommands"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; @@ -382,6 +379,11 @@ function checkTaskPermission(environmentId: EnvironmentId): boolean { return false; } +// The active row reports itself, so a route change scrolls to it once it mounts. +const RevealActiveRowContext = createContext<(row: HTMLElement | null, threadKey: string) => void>( + () => {}, +); + const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, @@ -414,12 +416,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const canOperatePreview = useEnvironmentScope(thread.environmentId, AuthPreviewOperateScope); const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const [isFileDragOver, setIsFileDragOver] = useState(false); - useEffect(() => { - if (!isFileDragOver) return; - const clearFileDrag = () => setIsFileDragOver(false); - window.addEventListener("dragend", clearFileDrag); - return () => window.removeEventListener("dragend", clearFileDrag); - }, [isFileDragOver]); + const revealActiveRow = useContext(RevealActiveRowContext); const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt); @@ -465,7 +462,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP // Hooks must precede this ref's plain uses for the compiler to cache it. const threadRef = scopeThreadRef(thread.environmentId, thread.id); const fileDropHandlers = makeWorkspaceFileDropHandlers({ - setDragActive: setIsFileDragOver, + setDragActive: (active) => setFileDragActive(setIsFileDragOver, active), addFiles: (files) => { onFileDropThreads(threadRef, files); }, @@ -668,6 +665,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onBlurCapture={handleBlurCapture} >
revealActiveRow(node, threadKey) : undefined} role="button" tabIndex={0} data-active={isActive} @@ -1288,8 +1286,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec reportFailure: false, }); const router = useRouter(); - const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); - const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); const { isMobile, setOpenMobile } = useSidebar(); const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); @@ -2769,6 +2765,7 @@ function SortableProjectItem({ } interface SidebarProjectsContentProps { + contentRef: React.RefCallback; showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; desktopUpdateButtonAction: "download" | "install" | "none"; @@ -2812,6 +2809,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( props: SidebarProjectsContentProps, ) { const { + contentRef, showArm64IntelBuildWarning, arm64IntelBuildWarningDescription, desktopUpdateButtonAction, @@ -2863,8 +2861,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( return ( + window.removeEventListener("mousedown", clearThreadSelectionOnOutsideMouseDown); +} + export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { const reorderProjects = useUiStateStore((store) => store.reorderProjects); const navigate = useNavigate(); + const router = useRouter(); const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); @@ -3271,7 +3284,6 @@ export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRou const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); - const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); @@ -3296,7 +3308,6 @@ export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRou sidebarProjectSortOrder, }); const { - sidebarThreadByKey, activeRouteProjectKey, visibleSidebarThreadKeys, threadJumpCommandByKey, @@ -3316,20 +3327,14 @@ export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRou // mount scroll to the open thread once. Its row can mount after the route // does, once its environment's shells arrive, so the reveal waits for it. const mountRevealOwed = useRef(true); - const routeRowRendered = - routeThreadKey !== null && visibleSidebarThreadKeys.includes(routeThreadKey); - const revealRouteRow = useStableCallback(() => { - const pendingKey = pendingSidebarRouteReveal(routeReveal, routeThreadKey); - if (pendingKey === null && (!mountRevealOwed.current || routeThreadKey === null)) return; - const activeRow = document.querySelector( - '[data-sidebar="content"] [data-thread-item] [data-active="true"]', - ); - if (!activeRow) return; + const revealActiveRow = (row: HTMLElement | null, threadKey: string) => { + if (row === null) return; + const pendingKey = pendingSidebarRouteReveal(routeReveal, threadKey); + if (pendingKey === null && !mountRevealOwed.current) return; completeSidebarRouteReveal(routeReveal); mountRevealOwed.current = false; - activeRow.scrollIntoView({ block: "nearest" }); - }); - useEffect(revealRouteRow, [revealRouteRow, routeThreadKey, routeRowRendered]); + row.scrollIntoView({ block: "nearest" }); + }; const newThreadShortcutLabelOptions = { platform, context: { @@ -3426,90 +3431,18 @@ export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRou context: sidebarShortcutContext, }, ); + const showThreadJumpHints = useThreadJumpHintsVisible(shouldShowThreadJumpHintsNow); const visibleThreadJumpLabelByKey = showThreadJumpHints ? threadJumpLabelByKey : EMPTY_THREAD_JUMP_LABELS; const orderedSidebarThreadKeys = visibleSidebarThreadKeys; - useEffect(() => { - updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); - }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); - - const onWindowKeyDown = useEffectEvent((event: globalThis.KeyboardEvent) => { - const shortcutContext = { - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - modelPickerOpen: isModelPickerOpen(), - }; - - if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) { - return; - } - - const command = resolveShortcutCommand(event, keybindings, { - platform, - context: shortcutContext, - }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return; - } - - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + const threadNavigationRef = threadNavigationScopeRef({ + traversalKeys: threadKeyList(orderedSidebarThreadKeys), + jumpKeys: threadKeyList(threadJumpThreadKeys), + router, + navigate: navigateToThread, }); - useEffect(() => { - const handleWindowKeyDown = (event: globalThis.KeyboardEvent) => onWindowKeyDown(event); - window.addEventListener("keydown", handleWindowKeyDown); - return () => { - window.removeEventListener("keydown", handleWindowKeyDown); - }; - }, []); - - useEffect(() => { - const onMouseDown = (event: globalThis.MouseEvent) => { - if (!useThreadSelectionStore.getState().hasSelection()) return; - const target = event.target instanceof HTMLElement ? event.target : null; - if (!shouldClearThreadSelectionOnMouseDown(target)) return; - clearSelection(); - }; - - window.addEventListener("mousedown", onMouseDown); - return () => { - window.removeEventListener("mousedown", onMouseDown); - }; - }, [clearSelection]); const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); const desktopUpdateButtonAction = desktopUpdateState @@ -3635,13 +3568,14 @@ export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRou }; return ( - <> + {prewarmedSidebarThreadRefs.map((threadRef) => ( ))} - + ); } diff --git a/apps/web/src/components/PersistentThreadTerminals.tsx b/apps/web/src/components/PersistentThreadTerminals.tsx index 2b2aef90551d0..dab0f16969a56 100644 --- a/apps/web/src/components/PersistentThreadTerminals.tsx +++ b/apps/web/src/components/PersistentThreadTerminals.tsx @@ -8,10 +8,11 @@ import { import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; -import { memo, useEffect, useState } from "react"; +import { memo, useEffect } from "react"; import { useComposerDraftStore } from "../composerDraftStore"; import { useStableCallback } from "../hooks/useStableCallback"; import type { TerminalContextSelection } from "../lib/terminalContext"; +import { focusActiveDrawerTerminal, focusTerminal } from "../lib/terminalFocus"; import { cn } from "~/lib/utils"; import { selectThreadRightPanelState, @@ -78,7 +79,6 @@ interface PersistentThreadTerminalDrawerProps { active: boolean; composerHeight: number; launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; splitShortcutLabel: string | undefined; splitVerticalShortcutLabel: string | undefined; newShortcutLabel: string | undefined; @@ -93,7 +93,6 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm active, composerHeight, launchContext, - focusRequestId, splitShortcutLabel, splitVerticalShortcutLabel, newShortcutLabel, @@ -174,7 +173,6 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm } reconcileTerminalIds(threadRef, serverOrderedTerminalIds); }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); - const [localFocusRequestId, setLocalFocusRequestId] = useState(0); const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const effectiveWorktreePath = launchContext !== null ? launchContext.worktreePath : worktreePath; const cwd = @@ -192,13 +190,6 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm }) : {}; - const bumpFocusRequestId = () => { - if (!visible) { - return; - } - setLocalFocusRequestId((value) => value + 1); - }; - const setTerminalHeight = (height: number) => { storeSetTerminalHeight(threadRef, height); }; @@ -210,7 +201,7 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm location: { cwd, worktreePath: effectiveWorktreePath }, }); if (!launched) return; - bumpFocusRequestId(); + if (visible) focusTerminal(threadRef, launched.terminalId); void launched.result; }; const splitTerminal = useStableCallback(() => @@ -225,14 +216,14 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm const activateTerminal = (terminalId: string) => { storeSetActiveTerminal(threadRef, terminalId); - bumpFocusRequestId(); + if (visible) focusTerminal(threadRef, terminalId); }; const closeTerminal = useStableCallback((terminalId: string) => { if (!terminalActions.canWrite()) return; terminalActions.closeTerminalSession(terminalId); storeCloseTerminal(threadRef, terminalId); - bumpFocusRequestId(); + if (visible) focusActiveDrawerTerminal(threadRef); }); const handleAddTerminalContext = (selection: TerminalContextSelection) => { @@ -271,7 +262,6 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm activeTerminalId={terminalUiState.activeTerminalId} terminalGroups={terminalUiState.terminalGroups} activeTerminalGroupId={terminalUiState.activeTerminalGroupId} - focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} onSplitTerminal={splitTerminal} onSplitTerminalVertical={splitTerminalVertical} onNewTerminal={createNewTerminal} @@ -297,7 +287,6 @@ export interface PersistentThreadTerminalPanelProps { threadRef: ScopedThreadRef; surface: Extract; launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; keybindings: ResolvedKeybindingsConfig; onAddTerminalContext: (selection: TerminalContextSelection) => void; onSplitTerminal: () => void; @@ -316,7 +305,6 @@ export const PersistentThreadTerminalPanel = memo(function PersistentThreadTermi threadRef, surface, launchContext, - focusRequestId, keybindings, onAddTerminalContext, onSplitTerminal, @@ -408,7 +396,6 @@ export const PersistentThreadTerminalPanel = memo(function PersistentThreadTermi }, ]} activeTerminalGroupId={surface.id} - focusRequestId={focusRequestId} onSplitTerminal={onSplitTerminal} onSplitTerminalVertical={onSplitTerminalVertical} onNewTerminal={onNewTerminal} diff --git a/apps/web/src/components/Sidebar.logic.browser.test.tsx b/apps/web/src/components/Sidebar.logic.browser.test.tsx new file mode 100644 index 0000000000000..cc55efc0bf439 --- /dev/null +++ b/apps/web/src/components/Sidebar.logic.browser.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; +import { useSnoozeWakeClock } from "./Sidebar.logic"; + +const MOUNTED_AT_MS = Date.parse("2026-10-24T08:00:00.000Z"); + +function WakeClock(props: { readonly threads: ReadonlyArray<{ readonly snoozedUntil: string }> }) { + return {useSnoozeWakeClock(props.threads)}; +} + +describe("useSnoozeWakeClock", () => { + it("settles in one step when many distinct wakes passed during a sleep", async () => { + using now = vi.spyOn(Date, "now").mockReturnValue(MOUNTED_AT_MS); + // More distinct wakes than React's 25 render-phase re-render limit. + const threads = Array.from({ length: 40 }, (_, index) => ({ + snoozedUntil: new Date(MOUNTED_AT_MS + 1 + index * 60_000).toISOString(), + })); + await using screen = await render(, { + // A bare hook host; nothing here links or navigates. + router: false, + }); + await expect.element(screen.getByRole("status")).toHaveTextContent(String(MOUNTED_AT_MS)); + + // The machine resumes after every wake has passed; the first wake's timer fires late. + const resumedAtMs = MOUNTED_AT_MS + 24 * 60 * 60_000; + now.mockReturnValue(resumedAtMs); + + await expect.element(screen.getByRole("status")).toHaveTextContent(String(resumedAtMs)); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f2ff8d22e6a89..8a32dffa21893 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -30,6 +30,8 @@ import { import type { SidebarThreadSummary, Thread } from "../types"; import { normalizeSearchText } from "../lib/utils"; import { isLatestRunSettled } from "../session-logic"; +import { observeIntersection } from "../lib/observeIntersection"; +import { useDeadlinePassed } from "../hooks/useDeadline"; export function shouldNavigateAfterThreadPark(input: { readonly threadKey: string; @@ -68,34 +70,69 @@ const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160; export function useSidebarRowSubscriptionLease(isActive: boolean): { readonly leaseLiveStatus: boolean; - readonly rowRef: React.Dispatch>; + readonly rowRef: (row: HTMLElement | null) => (() => void) | undefined; } { - const [row, setRow] = React.useState(null); const [isNearViewport, setIsNearViewport] = React.useState(isActive); if (isActive && !isNearViewport) setIsNearViewport(true); const canObserve = typeof IntersectionObserver !== "undefined"; - React.useEffect(() => { - if (isActive || row === null || !canObserve) return; - - const scrollRoot = row.closest('[data-slot="scroll-area-viewport"]'); - const observer = new IntersectionObserver( - ([entry]) => setIsNearViewport(entry?.isIntersecting === true), - { - root: scrollRoot, - rootMargin: `${SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX}px 0px`, - }, - ); - observer.observe(row); - return () => observer.disconnect(); - }, [canObserve, isActive, row]); - return { leaseLiveStatus: isActive || isNearViewport || !canObserve, - rowRef: setRow, + rowRef: (row) => { + if (row === null || isActive || !canObserve) return undefined; + // The overscan has to apply to the sidebar's own scroller; a viewport-rooted + // margin is still clipped by it. + return observeIntersection(row, (entry) => setIsNearViewport(entry.isIntersecting), { + root: row.closest('[data-slot="scroll-area-viewport"]'), + rootMargin: `${SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX}px 0px`, + }); + }, }; } +const fileDragClears = new Set<(active: false) => void>(); +function clearFileDrags() { + const clears = [...fileDragClears]; + fileDragClears.clear(); + for (const clear of clears) clear(false); +} + +/** + * A file drag that ends outside every drop target (Escape, or a drop elsewhere) + * sends no dragleave to the row it last entered, so the window's dragend clears it. + */ +export function setFileDragActive(setActive: (active: boolean) => void, active: boolean) { + setActive(active); + if (!active || typeof window === "undefined") return; + if (fileDragClears.size === 0) window.addEventListener("dragend", clearFileDrags, { once: true }); + fileDragClears.add(setActive); +} + +/** + * A clock that advances exactly when a snooze wake time passes, so a woken + * thread leaves the shelf on time without a per-second clock. + */ +export function useSnoozeWakeClock( + threads: ReadonlyArray<{ readonly snoozedUntil?: string | null | undefined }>, +): number { + const [clockMs, setClockMs] = React.useState(() => Date.now()); + let nextWakeAt: number | null = null; + for (const thread of threads) { + const wakeAt = thread.snoozedUntil == null ? Number.NaN : Date.parse(thread.snoozedUntil); + if (wakeAt > clockMs && (nextWakeAt === null || wakeAt < nextWakeAt)) nextWakeAt = wakeAt; + } + const woke = useDeadlinePassed(nextWakeAt); + // Jumping to now (read only once a wake passed, like useNowAfter) skips every + // wake that elapsed meanwhile, e.g. across a laptop sleep. Stepping one wake + // at a time would re-render once per elapsed wake and hit React's limit. + if (woke && nextWakeAt !== null) { + const nextClockMs = Math.max(nextWakeAt, Date.now()); + setClockMs(nextClockMs); + return nextClockMs; + } + return clockMs; +} + export function useRetainedValue(key: string | null, value: T | null): T | null { const [retained, setRetained] = React.useState<{ readonly key: string; @@ -516,10 +553,14 @@ export function isSidebarSubagentThread(thread: Pick[], + threads: + | readonly Pick[] + | ((id: ThreadId) => Pick | null), ): ThreadId { const findThread = (id: ThreadId) => - threads.find((thread) => thread.id === id && thread.environmentId === environmentId); + typeof threads === "function" + ? (threads(id) ?? undefined) + : threads.find((thread) => thread.id === id && thread.environmentId === environmentId); const visited = new Set(); let thread = findThread(threadId); while (thread !== undefined && isSidebarSubagentThread(thread)) { @@ -614,89 +655,16 @@ type ThreadStatusInput = Pick< blockedOnThreadWait?: boolean | undefined; }; -export interface ThreadJumpHintVisibilityController { - sync: (shouldShow: boolean) => void; - dispose: () => void; -} - -function createThreadJumpHintVisibilityController(input: { - delayMs: number; - onVisibilityChange: (visible: boolean) => void; - setTimeoutFn?: typeof globalThis.setTimeout; - clearTimeoutFn?: typeof globalThis.clearTimeout; -}): ThreadJumpHintVisibilityController { - const setTimeoutFn = input.setTimeoutFn ?? globalThis.setTimeout; - const clearTimeoutFn = input.clearTimeoutFn ?? globalThis.clearTimeout; - let isVisible = false; - let timeoutId: NodeJS.Timeout | null = null; - - const clearPendingShow = () => { - if (timeoutId === null) { - return; - } - clearTimeoutFn(timeoutId); - timeoutId = null; - }; - - return { - sync: (shouldShow) => { - if (!shouldShow) { - clearPendingShow(); - if (isVisible) { - isVisible = false; - input.onVisibilityChange(false); - } - return; - } - - if (isVisible || timeoutId !== null) { - return; - } - - timeoutId = setTimeoutFn(() => { - timeoutId = null; - isVisible = true; - input.onVisibilityChange(true); - }, input.delayMs); - }, - dispose: () => { - clearPendingShow(); - }, - }; -} - -export function useThreadJumpHintVisibility(): { - showThreadJumpHints: boolean; - updateThreadJumpHintsVisibility: (shouldShow: boolean) => void; -} { - const [showThreadJumpHints, setShowThreadJumpHints] = React.useState(false); - const controllerRef = React.useRef(null); - - React.useEffect(() => { - const controller = createThreadJumpHintVisibilityController({ - delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS, - onVisibilityChange: (visible) => { - setShowThreadJumpHints(visible); - }, - setTimeoutFn: window.setTimeout.bind(window), - clearTimeoutFn: window.clearTimeout.bind(window), - }); - controllerRef.current = controller; - - return () => { - controller.dispose(); - controllerRef.current = null; - }; - }, []); - - const updateThreadJumpHintsVisibility = (shouldShow: boolean) => { - controllerRef.current?.sync(shouldShow); - }; - - return { - showThreadJumpHints, - updateThreadJumpHintsVisibility, - }; +/** + * Thread jump hints appear once the jump modifier has been held for a moment, + * so a quick shortcut never flashes them, and hide as soon as it is released. + */ +export function useThreadJumpHintsVisible(shouldShow: boolean): boolean { + const [showAt, setShowAt] = React.useState(null); + let nextShowAt = shouldShow ? showAt : null; + if (shouldShow && showAt === null) nextShowAt = Date.now() + THREAD_JUMP_HINT_SHOW_DELAY_MS; + if (nextShowAt !== showAt) setShowAt(nextShowAt); + return useDeadlinePassed(nextShowAt); } export function resolveThreadLastVisitedAt( diff --git a/apps/web/src/components/Sidebar.pointer.ts b/apps/web/src/components/Sidebar.pointer.ts index 282e8728a53a0..5a0ce373c0774 100644 --- a/apps/web/src/components/Sidebar.pointer.ts +++ b/apps/web/src/components/Sidebar.pointer.ts @@ -1,13 +1,7 @@ -import { useLayoutEffect, type PointerEvent as ReactPointerEvent } from "react"; +import { type PointerEvent as ReactPointerEvent } from "react"; import { type SensorProps } from "@dnd-kit/core"; import { getOwnerDocument, getWindow } from "@dnd-kit/utilities"; -// Search unmounts the drag context while its owning Sidebar remains mounted. -export function SidebarDragLifecycle({ onUnmount }: { onUnmount: () => void }) { - useLayoutEffect(() => onUnmount, [onUnmount]); - return null; -} - type Options = { distance: number; onAttach: (sensor: SidebarPointerSensor) => void; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2101d2ddae557..9bbf2608010dc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -40,7 +40,7 @@ import { type ShownOrder, } from "@t3tools/client-runtime/state/thread-search"; import { foldSearchQuery, rankMessageMatches } from "@t3tools/shared/paletteSearch"; -import { findForMessageMatch, requestThreadFindQuery } from "./chat/threadFindActionBus"; +import { findForMessageMatch, openThreadFindOnQuery } from "./chat/threadFindStore"; import { threadRuntimeCanArchive, type EnvironmentThreadShell, @@ -69,7 +69,7 @@ import { type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; -import { useRouter } from "@tanstack/react-router"; +import { type AnyRouter, useRouter } from "@tanstack/react-router"; import { isAtomCommandInterrupted, @@ -79,20 +79,16 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { isElectron } from "../env"; import { - resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; -import { isTerminalFocused } from "../lib/terminalFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isMacPlatform } from "~/lib/utils"; import { readLocalApi } from "../localApi"; -import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; +import { clearPendingFileDrop, queuePendingFileDrop } from "../sidebarPendingFileDropStore"; import { selectProjectGroupingSettings } from "../logicalProject"; import { projectGroupsSpanEnvironments, @@ -109,7 +105,7 @@ import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadAc import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; -import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; +import { openCommandPalette } from "../commandPaletteBus"; import { registerSettleAndAdvance } from "../settleAndAdvance"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; @@ -162,7 +158,6 @@ import { orderItemsByPreferredIds, planSidebarThreadDrop, reduceSidebarProjectScopeMenuState, - resolveAdjacentThreadId, resolveSidebarSweepKeys, resolveSidebarDropTarget, resolveSidebarDropVerb, @@ -176,7 +171,8 @@ import { sortPinnedThreadsForSidebar, sortThreadsForSidebar, sortWorkingThreadsBySend, - useThreadJumpHintVisibility, + useSnoozeWakeClock, + useThreadJumpHintsVisible, type SidebarListItem, type SidebarSection, resolveSidebarRowThreadId, @@ -186,7 +182,7 @@ import { createSidebarSortingStrategy, restrictBelowSidebarLabel, } from "./Sidebar.drag"; -import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; +import { SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; @@ -195,6 +191,7 @@ import { useStableCallback } from "../hooks/useStableCallback"; import { completeSidebarRouteReveal, pendingSidebarRouteReveal, + resolveSidebarRouteReveal, type SidebarRouteReveal, } from "./sidebar/routeReveal"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -222,6 +219,11 @@ import { type SidebarSweepAction, } from "./sidebar/SidebarThreadRow"; import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock"; +import { + readRouteThreadRef, + threadKeyList, + threadNavigationScopeRef, +} from "./sidebar/threadNavigationCommands"; import { SidebarDragBoundary, SidebarSectionHeader, @@ -362,24 +364,17 @@ function useSidebarThreads(input: { readonly scopedProjectKeys: ReadonlySet | null; readonly serverConfigs: ReadonlyMap; readonly optimisticDrop: SidebarOptimisticDrop | null; - readonly routeThreadRef: ScopedThreadRef | null; + readonly threads: readonly EnvironmentThreadShell[]; + readonly routeRowThreadKey: string | null; }) { - const { projectScopeKey, scopedProjectKeys, serverConfigs, optimisticDrop, routeThreadRef } = - input; - const threads = useQuietThreadShells(); - const routeRowThreadKey = - routeThreadRef === null - ? null - : scopedThreadKey( - scopeThreadRef( - routeThreadRef.environmentId, - resolveSidebarRowThreadId( - routeThreadRef.threadId, - routeThreadRef.environmentId, - threads, - ), - ), - ); + const { + projectScopeKey, + scopedProjectKeys, + serverConfigs, + optimisticDrop, + threads, + routeRowThreadKey, + } = input; const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); const inboxReturnsVersion = useSyncExternalStore( inboxReturns.subscribe, @@ -387,12 +382,7 @@ function useSidebarThreads(input: { ); const nowMinute = useNowMinute(); // Minute-quantized clocks would delay second-precise snooze wakes by up to a minute. - const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - const [nextSnoozeWakeAt, setNextSnoozeWakeAt] = useState(null); - useEffect( - () => scheduleSnoozeWake(nextSnoozeWakeAt, () => bumpSnoozeWakeTick((tick) => tick + 1)), - [nextSnoozeWakeAt], - ); + const snoozeWakeClock = useSnoozeWakeClock(threads); const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( SETTLED_SHELF_EXPANDED_KEY, false, @@ -424,10 +414,8 @@ function useSidebarThreads(input: { serverConfigs, optimisticDrop, workingShelfEnabled, - clockKey: `${nowMinute}:${snoozeWakeTick}:${inboxReturnsVersion}`, + clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); - const partitionNextWakeAt = partition.snoozedThreads[0]?.snoozedUntil ?? null; - if (partitionNextWakeAt !== nextSnoozeWakeAt) setNextSnoozeWakeAt(partitionNextWakeAt); const { pinnedThreads, activeThreads, workingThreads, snoozedThreads, settledThreads } = partition; @@ -469,7 +457,6 @@ function useSidebarThreads(input: { for (const thread of list) sectionByThreadKey.set(threadShellKey(thread), section); } return { - routeRowThreadKey, ...partition, threads, workingShelfEnabled, @@ -717,15 +704,6 @@ function useSidebarLookups() { }; } -function scheduleSnoozeWake(wakeAt: string | null, onWake: () => void): (() => void) | undefined { - const wakeAtMs = wakeAt === null ? Number.NaN : Date.parse(wakeAt); - if (Number.isNaN(wakeAtMs)) return undefined; - // Delays beyond signed 32-bit range fire immediately and loop for far-future snoozes. - const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); - const id = window.setTimeout(onWake, delayMs); - return () => window.clearTimeout(id); -} - function canOperateThreads( threads: ReadonlyArray>, ): boolean { @@ -750,6 +728,21 @@ const SIDEBAR_DRAG_DISTANCE = 6; const SIDEBAR_DRAG_LABEL_HEIGHT = 24; +// Continuations that outlive a render read the open thread from the router. +function readRouteThreadKey(router: AnyRouter): string | null { + const ref = readRouteThreadRef(router); + return ref === null ? null : scopedThreadKey(ref); +} + +// The key-time twin of useSidebarThreads' routeRowThreadKey. +function routeRowKeyAtKeyTime(threadRef: ScopedThreadRef): string { + const { environmentId } = threadRef; + const rowThreadId = resolveSidebarRowThreadId(threadRef.threadId, environmentId, (threadId) => + readThreadShell(scopeThreadRef(environmentId, threadId)), + ); + return scopedThreadKey(scopeThreadRef(environmentId, rowThreadId)); +} + export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { const { projects, @@ -875,9 +868,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const [searchHighlightKey, setSearchHighlightKey] = useState(null); const searchEnvironmentIds = useConnectedEnvironmentIds(); const threadSearch = useThreadSearch(searchEnvironmentIds, threadSearchQuery); - const handleNewThreadRef = useRef(newThreadContext.handleNewThread); - const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); - const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const settlingThreadKeysRef = useRef(new Set()); @@ -905,18 +895,78 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const openAddProjectCommandPalette = () => openCommandPalette({ open: "add-project" }); const routeThreadRef = resolveActiveThreadRouteRef(routeTarget, routeDraftThread); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const routeThreadKeyRef = useRef(routeThreadKey); // A selection made under one scope can hold threads the next scope hides. const setProjectScopeKey = (key: string | null) => { clearSelection(); setPersistedProjectScopeKey(key); }; - const projectScopeKey = resolveSidebarProjectScopeKey({ + // A scope whose project is gone reads as all projects once every snapshot has + // loaded. The stored key is left alone, so the scope returns with its project. + const chosenProjectScopeKey = resolveSidebarProjectScopeKey({ groups: projectGroups, key: persistedProjectScopeKey, settings: projectGroupingSettings, + canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady, }); + const chosenProjectGroup = + chosenProjectScopeKey === null + ? null + : (projectGroups.find((project) => project.projectKey === chosenProjectScopeKey) ?? null); + const chosenScopeProjectKeys = + chosenProjectGroup === null + ? null + : new Set( + chosenProjectGroup.memberProjectRefs.map( + (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, + ), + ); + const threads = useQuietThreadShells(); + const routeRowThreadKey = + routeThreadRef === null + ? null + : scopedThreadKey( + scopeThreadRef( + routeThreadRef.environmentId, + resolveSidebarRowThreadId( + routeThreadRef.threadId, + routeThreadRef.environmentId, + threads, + ), + ), + ); + const isSearchingThreads = threadSearchQuery.trim().length > 0; + + // History, the palette and links can open a thread the scope hides or the list + // scrolled away; a remount starts at the top, so each mount scrolls to the open + // thread once. The reveal record outlives this component; this copy tracks it + // between commits, and the active row writes it back. + const [routeRevealState, setRouteRevealState] = useState(() => ({ ...routeReveal })); + if (routeRowThreadKey !== null && routeRevealState.observedKey !== routeRowThreadKey) { + setRouteRevealState({ observedKey: routeRowThreadKey, pendingKey: routeRowThreadKey }); + } + // The row renders under the widened scope, and revealing it keeps that scope. + const { pendingKey: pendingRevealKey, widensScope: revealWidensScope } = + resolveSidebarRouteReveal({ + reveal: routeRevealState, + routeKey: routeRowThreadKey, + isSearching: isSearchingThreads, + scopedProjectKeys: chosenScopeProjectKeys, + findThread: (key) => threads.find((candidate) => threadShellKey(candidate) === key), + }); + const projectScopeKey = revealWidensScope ? null : chosenProjectScopeKey; + const mountRevealOwed = useRef(true); + const revealActiveRow = (row: HTMLElement) => { + if (pendingRevealKey === null && !mountRevealOwed.current) return; + if (revealWidensScope) setProjectScopeKey(null); + if (routeRowThreadKey !== null) { + pendingSidebarRouteReveal(routeReveal, routeRowThreadKey); + completeSidebarRouteReveal(routeReveal); + setRouteRevealState({ observedKey: routeRowThreadKey, pendingKey: null }); + } + mountRevealOwed.current = false; + row.scrollIntoView({ block: "nearest" }); + }; const showProjectEnvironments = projectGroupsSpanEnvironments(projectGroups); const scratchMachineLabelFor = ( thread: Pick, @@ -940,38 +990,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve matches: (item, query) => projectScopeFilter.contains(item, query, (candidate) => candidate.label), }); - const scopedProjectGroup = - projectScopeKey === null - ? null - : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null); - const scopedProjectKeys = - scopedProjectGroup === null - ? null - : new Set( - scopedProjectGroup.memberProjectRefs.map( - (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, - ), - ); - useEffect(() => { - const nextKey = resolveSidebarProjectScopeKey({ - groups: projectGroups, - key: persistedProjectScopeKey, - settings: projectGroupingSettings, - canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady, - }); - if (nextKey !== persistedProjectScopeKey) { - clearSelection(); - setPersistedProjectScopeKey(nextKey); - } - }, [ - allProjectConfigsReady, - allProjectSnapshotsReady, - clearSelection, - persistedProjectScopeKey, - projectGroups, - projectGroupingSettings, - setPersistedProjectScopeKey, - ]); + const scopedProjectGroup = revealWidensScope ? null : chosenProjectGroup; + const scopedProjectKeys = revealWidensScope ? null : chosenScopeProjectKeys; const routeDraftIdForRows = routeTarget?.kind === "draft" ? routeTarget.draftId : null; const visibleDraftSessionCount = useComposerDraftStore((store) => { let count = 0; @@ -1025,7 +1045,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); const { - threads, workingShelfEnabled, pinnedThreads, draggableThreadKeys, @@ -1057,53 +1076,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve activeKeys, pinnedKeysById, activeKeysById, - routeRowThreadKey, } = useSidebarThreads({ projectScopeKey, scopedProjectKeys, serverConfigs, optimisticDrop, - routeThreadRef, - }); - - const isSearchingThreads = threadSearchQuery.trim().length > 0; - - // History, the command palette, and links can open a thread the project - // scope hides or the list has scrolled away. A remount starts the list at - // the top, so each mount also scrolls to the open thread once, without - // widening the scope. - const mountRevealOwed = useRef(true); - const revealRouteRow = useStableCallback(() => { - const pendingKey = pendingSidebarRouteReveal(routeReveal, routeRowThreadKey); - // Search results replace the thread list; reveal once the list returns. - if (isSearchingThreads) return; - const revealKey = pendingKey ?? (mountRevealOwed.current ? routeRowThreadKey : null); - if (revealKey === null) return; - const thread = threads.find((candidate) => threadShellKey(candidate) === revealKey); - if (thread === undefined) return; - if ( - pendingKey !== null && - scopedProjectKeys !== null && - filterSidebarV2VisibleThreads([thread], scopedProjectKeys).length === 0 && - filterSidebarV2VisibleThreads([thread], null).length === 1 - ) { - // The row renders under the widened scope; scroll on that pass. - setProjectScopeKey(null); - return; - } - const activeRow = document.querySelector('[data-sidebar="content"] [aria-current="page"]'); - if (!activeRow) return; - completeSidebarRouteReveal(routeReveal); - mountRevealOwed.current = false; - activeRow.scrollIntoView({ block: "nearest" }); - }); - useEffect(revealRouteRow, [ - revealRouteRow, - routeRowThreadKey, threads, - projectScopeKey, - isSearchingThreads, - ]); + routeRowThreadKey, + }); // Ranked across environments as the palette ranks them, so one // environment's list does not sit above another's better matches; rows @@ -1172,15 +1152,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve typeof index === "function" ? index(activeSearchResultIndex) : index ] ?? null, ); - const activeSearchResultKey = isSearchingThreads - ? (threadSearchResultKeys[activeSearchResultIndex] ?? null) - : null; - useEffect(() => { - if (activeSearchResultKey === null) return; - document - .querySelector(`[data-search-result-key="${globalThis.CSS.escape(activeSearchResultKey)}"]`) - ?.scrollIntoView({ block: "nearest" }); - }, [activeSearchResultKey]); const orderedThreadKeysRef = useRef(orderedThreadKeys); // Read changing maps/sets through refs so streamed events preserve row callbacks. @@ -1198,8 +1169,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } return mapping; })(); - const { showThreadJumpHints: showJumpHints, updateThreadJumpHintsVisibility } = - useThreadJumpHintVisibility(); const navigateToThread = (threadRef: ScopedThreadRef) => { if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { @@ -1258,7 +1227,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const query = threadSearch.query; clearThreadSearch(); navigateToThread(ref); - if (match) requestThreadFindQuery(scopedThreadKey(ref), findForMessageMatch(match, query)); + if (match) openThreadFindOnQuery(scopedThreadKey(ref), findForMessageMatch(match, query)); }; const handleThreadSearchKeyDown = (event: ReactKeyboardEvent) => { if (event.nativeEvent.isComposing || event.keyCode === 229) return; @@ -1347,7 +1316,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve threadKey: string, coParkingKeys?: ReadonlySet, ): (() => void) | null => { - if (routeThreadKeyRef.current !== threadKey) return null; + if (readRouteThreadKey(router) !== threadKey) return null; const shell = threadByKeyRef.current.get(threadKey); const orderedKeys = orderedThreadKeysRef.current; const settledKeys = settledThreadKeysRef.current; @@ -1364,7 +1333,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) : shell ? () => - void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId)) + void newThreadContext.handleNewThread( + scopeProjectRef(shell.environmentId, shell.projectId), + ) : () => void router.navigate({ to: "/" }); }; @@ -1395,7 +1366,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if ( shouldNavigateAfterThreadPark({ threadKey, - currentThreadKey: routeThreadKeyRef.current, + currentThreadKey: readRouteThreadKey(router), action: "settle", now: new Date().toISOString(), thread: readThreadShell(threadRef), @@ -1451,11 +1422,19 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve restrictBelowPins, restrictToFirstScrollableAncestor, ]; - const attachListMotionRef = (node: HTMLUListElement | null) => { + const attachListMotionRef = (node: HTMLUListElement) => { threadListRef.current = node; - listMotionRef.current?.dispose(); - listMotionRef.current = node === null ? null : createSidebarListMotion(node); - listMotionRef.current?.update(false); + const motion = createSidebarListMotion(node); + listMotionRef.current = motion; + motion.update(false); + return () => { + // Search replaces this list while the Sidebar stays mounted, and a drag + // whose rows are gone can never drop. + dragSensorRef.current?.cancel(); + motion.dispose(); + if (listMotionRef.current === motion) listMotionRef.current = null; + if (threadListRef.current === node) threadListRef.current = null; + }; }; const isContextDrag = dragState?.contextDrag === true || dragState?.activeSection === "working"; @@ -1522,11 +1501,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const dndSensors = useSensors(useSensor(SidebarPointerSensor, pointerSensorOptions)); const sectionByThreadKeyRef = useRef(sectionByThreadKey); useLayoutEffect(() => { - routeThreadKeyRef.current = routeThreadKey; projectGroupsRef.current = projectGroups; orderedThreadKeysRef.current = orderedThreadKeys; threadByKeyRef.current = threadByKey; - handleNewThreadRef.current = newThreadContext.handleNewThread; settledThreadKeysRef.current = settledThreadKeys; snoozedThreadKeysRef.current = snoozedThreadKeys; sectionByThreadKeyRef.current = sectionByThreadKey; @@ -1895,7 +1872,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve settled && shouldNavigateAfterThreadPark({ threadKey: activeKey, - currentThreadKey: routeThreadKeyRef.current, + currentThreadKey: readRouteThreadKey(router), action: "settle", now: new Date().toISOString(), thread: readThreadShell(threadRef), @@ -1966,7 +1943,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if ( shouldNavigateAfterThreadPark({ threadKey, - currentThreadKey: routeThreadKeyRef.current, + currentThreadKey: readRouteThreadKey(router), action: "snooze", now: new Date().toISOString(), thread: readThreadShell(threadRef), @@ -2406,12 +2383,15 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return; case "new-thread-on-branch": { const result = await settlePromise(() => - handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), { - branch: thread.branch, - worktreePath: thread.worktreePath, - envMode: thread.worktreePath ? "worktree" : "local", - startFromOrigin: false, - }), + newThreadContext.handleNewThread( + scopeProjectRef(thread.environmentId, thread.projectId), + { + branch: thread.branch, + worktreePath: thread.worktreePath, + envMode: thread.worktreePath ? "worktree" : "local", + startFromOrigin: false, + }, + ), ); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); @@ -2573,55 +2553,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); - useEffect(() => { - const onWindowKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) { - return; - } - const command = resolveShortcutCommand(event, keybindings, { - platform: navigator.platform, - context: { - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - modelPickerOpen: isModelPickerOpen(), - isWeb: !isElectron, - isDesktop: isElectron, - }, - }); - const navigateToThreadKey = (targetThreadKey: string | null) => { - if (!targetThreadKey) return false; - const targetThread = threadByKey.get(targetThreadKey); - if (!targetThread) return false; - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return true; - }; - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - navigateToThreadKey( - resolveAdjacentThreadId({ - threadIds: orderedThreadKeys, - currentThreadId: routeRowThreadKey, - direction: traversalDirection, - }), - ); - return; - } - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) return; - navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); - }; - window.addEventListener("keydown", onWindowKeyDown); - return () => window.removeEventListener("keydown", onWindowKeyDown); - }, [ - keybindings, - navigateToThread, - orderedThreadKeys, - routeRowThreadKey, - routeTerminalOpen, - threadByKey, - ]); const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers( shortcutModifiers, @@ -2637,9 +2568,15 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, }, ); - useEffect(() => { - updateThreadJumpHintsVisibility(shouldShowJumpHintsNow); - }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]); + const showJumpHints = useThreadJumpHintsVisible(shouldShowJumpHintsNow); + const threadKeysText = threadKeyList(orderedThreadKeys); + const threadNavigationRef = threadNavigationScopeRef({ + traversalKeys: threadKeysText, + jumpKeys: threadKeysText, + router, + rowKeyForRoute: routeRowKeyAtKeyTime, + navigate: navigateToThread, + }); const handleNewThreadClick = (event?: ReactMouseEvent) => { if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { @@ -2665,6 +2602,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve @@ -2902,7 +2840,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve onDragOver={handleThreadDragOver} onDragEnd={handleThreadDragEnd} > -
    draftId === null ? null : store.getDraftSession(draftId), ); - const threadRefs = useThreadRefs(); - // Promotion can begin before the server creates the reserved thread. - const inferredThreadRef = draftSession - ? (threadRefs.find( - (ref) => - ref.environmentId === draftSession.environmentId && - ref.threadId === draftSession.threadId, - ) ?? null) - : null; - const serverThreadRef: ScopedThreadRef | null = - target.kind === "server" ? target.threadRef : inferredThreadRef; - const serverThread = useQuietThreadShell(serverThreadRef); - const backgroundSubmissionPending = useBackgroundDraftSubmissionPending( - target.kind === "draft" ? serverThreadRef : null, + const draftRedirect = useComposerDraftStore((store) => + draftId === null ? null : store.getPromotedDraftRedirect(draftId), ); - const canonicalThreadRef = - target.kind === "draft" - ? resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread, - backgroundSubmissionPending, - }) - : null; - + const serverThreadShell = useQuietThreadShell(serverThreadRef); const shellStatus = useEnvironmentShellStatus(serverThreadRef?.environmentId ?? null); - const serverThreadShell = serverThread; const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); const bootstrapComplete = isThreadRouteSnapshotAuthoritative(shellStatus); const draftThread = useComposerDraftStore((store) => serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null, ); + // The ChatView that carried a draft keeps its identity once the thread route takes over. const promotedDraftId = useComposerDraftStore((store) => - target.kind === "server" ? store.getDraftIdByRef(target.threadRef) : null, + serverThreadRef + ? (store.getDraftIdByRef(serverThreadRef) ?? store.getPromotedDraftIdByRef(serverThreadRef)) + : null, ); - // Latch the key through promotion because the draft record disappears after the route swap. - const [chatViewKey, setChatViewKey] = useState<{ threadKey: string; key: string } | null>(null); - const serverThreadKey = target.kind === "server" ? scopedThreadKey(target.threadRef) : null; - const nextChatViewKey = - serverThreadKey === null - ? null - : chatViewKey?.threadKey === serverThreadKey - ? chatViewKey - : promotedDraftId - ? { threadKey: serverThreadKey, key: promotedDraftId } - : null; - if (nextChatViewKey !== chatViewKey) { - setChatViewKey(nextChatViewKey); - } const environmentHasDraftThreads = useComposerDraftStore((store) => serverThreadRef ? store.hasDraftThreadsInEnvironment(serverThreadRef.environmentId) : false, ); @@ -86,8 +47,8 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { // decides whether it exists. Read only then: once listed, ChatView keeps // this stream open and its updates need not re-render the route. const unlistedThreadRef = - target.kind === "server" && serverThreadShell === null && draftThread === null - ? target.threadRef + serverThreadRef !== null && serverThreadShell === null && draftThread === null + ? serverThreadRef : null; const unlistedThreadDetail = useEnvironmentThread( unlistedThreadRef?.environmentId ?? null, @@ -100,64 +61,10 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { unlistedThreadDetail, draftThreadExists: draftThread !== null, }); - const serverThreadStarted = threadHasStarted(serverThreadShell); const environmentHasAnyThreads = environmentThreadRefs.length > 0 || environmentHasDraftThreads; - useEffect(() => { - if (!inferredThreadRef || draftSession?.promotedTo) { - return; - } - markPromotedDraftThreadByRef(inferredThreadRef); - }, [draftSession?.promotedTo, inferredThreadRef]); - - useEffect(() => { - if (!canonicalThreadRef) { - return; - } - let cancelled = false; - void waitForDraftHeroTransition().then(() => { - if (cancelled) { - return; - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(canonicalThreadRef), - replace: true, - }); - }); - return () => { - cancelled = true; - }; - }, [canonicalThreadRef, navigate]); - - useEffect(() => { - if (target.kind !== "draft" || draftSession || canonicalThreadRef) { - return; - } - void navigate({ to: "/", replace: true }); - }, [canonicalThreadRef, draftSession, navigate, target.kind]); - - useEffect(() => { - if (target.kind !== "server" || !bootstrapComplete) { - return; - } - if (renderState === "missing") { - const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState(); - clearPendingFileDropsForThread(target.threadRef); - if (environmentHasAnyThreads) { - void navigate({ to: "/", replace: true }); - } - } - }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, target]); - - useEffect(() => { - if (target.kind !== "server" || !serverThreadStarted || !draftThread) { - return; - } - finalizePromotedDraftThreadByRef(target.threadRef); - }, [draftThread, serverThreadStarted, target]); - let view: React.ReactNode = null; + let redirect: React.ReactNode = null; if (target.kind === "draft") { if (draftSession) { view = ( @@ -170,21 +77,43 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { forceExpandedMobileComposer /> ); + } else if (draftRedirect) { + // Same slot and key as the server route renders, so the swap keeps this instance. + view = ( + + ); + redirect = ( + + ); + } else { + redirect = ; } } else if (renderState === "ready" || (renderState === "loading" && serverThreadShell !== null)) { view = ( ); + } else if (renderState === "missing" && environmentHasAnyThreads) { + redirect = ; } return ( {view} + {redirect} ); } diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index ebc4c525efd7e..669754bbb3aae 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,4 +1,4 @@ -import { useAtomValue } from "@effect/atom-react"; +import { useAtomMount, useAtomValue } from "@effect/atom-react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -33,7 +33,6 @@ import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, type ReactNode, - type SetStateAction, useEffect, useEffectEvent, useLayoutEffect, @@ -79,7 +78,8 @@ import { readLocalApi } from "~/localApi"; import { confirmTerminalClose } from "~/lib/terminalCloseConfirm"; import { useClientSettings } from "../hooks/useSettings"; import { useLocalStorage } from "../hooks/useLocalStorage"; -import { useAttachedTerminalSession } from "../state/terminalSessions"; +import { attachedTerminalSessionAtom } from "../state/terminalSessions"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; import { readEnvironmentScope } from "../state/session"; @@ -89,6 +89,7 @@ import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; import { observeResize } from "../lib/observeResize"; +import { consumePendingTerminalFocus, registerTerminalFocusTarget } from "../lib/terminalFocus"; import { resolveTerminalFontPreference, resolveTerminalFontSizePreference, @@ -119,6 +120,8 @@ function resolveTerminalDrawerMaxHeight(input: DrawerLayout): number { ); } +const DRAG_HEIGHT_PROPERTY = "--terminal-drawer-drag-height"; + function clampDrawerHeight(height: number, maxHeight: number): number { const safeHeight = Number.isFinite(height) ? height : DEFAULT_THREAD_TERMINAL_HEIGHT; return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight); @@ -394,6 +397,19 @@ export function shouldClearTerminalSelectionAction(options: { return options.actionPending || options.openMenuRequestId === options.currentRequestId; } +/** + * A writable session's output generation. A new one is a fresh PTY (or a + * reconnect) that has not seen the surface's grid yet. + */ +function writableSessionGeneration( + session: TerminalSessionState, + canOperate: boolean, +): number | null { + return canOperate && session.version > 0 && session.status === "running" + ? session.output.generation + : null; +} + export function shouldHandleTerminalExit( current: TerminalSessionState["status"], synchronized: TerminalSessionState["status"], @@ -420,11 +436,9 @@ interface TerminalViewportProps { providerInstanceId?: ProviderInstanceId; onSessionExited: () => void; onAddTerminalContext?: (selection: TerminalContextSelection) => void; - focusRequestId: number; - autoFocus: boolean; + /** Focuses the viewport when it mounts; otherwise focus arrives through `focusTerminal`. */ + autoFocus?: boolean; visible: boolean; - resizeEpoch: number; - drawerHeight: number; keybindings: ResolvedKeybindingsConfig; } @@ -458,11 +472,8 @@ function TerminalSessionViewport({ providerInstanceId, onSessionExited, onAddTerminalContext, - focusRequestId, - autoFocus, + autoFocus = false, visible, - resizeEpoch, - drawerHeight, keybindings, }: TerminalViewportProps) { const containerRef = useRef(null); @@ -520,20 +531,23 @@ function TerminalSessionViewport({ }), ); const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); - const terminalSession = useAttachedTerminalSession({ - environmentId, - terminal: { - threadId, - terminalId, - cwd, - ...(worktreePath !== undefined ? { worktreePath } : {}), - ...(runtimeEnv ? { env: runtimeEnv } : {}), - ...(providerInstanceId ? { providerInstanceId } : {}), - }, + // Keyed by the launch (see TerminalViewport), so the atom is fixed for this surface. + const terminalSessionAtom = attachedTerminalSessionAtom(environmentId, { + threadId, + terminalId, + cwd, + ...(worktreePath !== undefined ? { worktreePath } : {}), + ...(runtimeEnv ? { env: runtimeEnv } : {}), + ...(providerInstanceId ? { providerInstanceId } : {}), }); - const canResizeTerminal = - canOperateTerminal && terminalSession.version > 0 && terminalSession.status === "running"; - const resizeSessionGeneration = canResizeTerminal ? terminalSession.output.generation : null; + // Attaching starts with the viewport, while the WASM surface is still loading; + // the surface reads the session itself, so output chunks never re-render here. + useAtomMount(terminalSessionAtom); + const readTerminalSession = useEffectEvent(() => appAtomRegistry.get(terminalSessionAtom)); + const subscribeTerminalSession = useEffectEvent( + (listener: (session: TerminalSessionState) => void) => + appAtomRegistry.subscribe(terminalSessionAtom, listener), + ); const writeTerminal = useEffectEvent((data: string) => runTerminalWrite({ environmentId, @@ -541,15 +555,14 @@ function TerminalSessionViewport({ }), ); const resizeTerminal = useEffectEvent((cols: number, rows: number) => { - if (!canResizeTerminal || !hasTerminalWriteAccess()) return; + if (writableSessionGeneration(readTerminalSession(), hasTerminalWriteAccess()) === null) { + return; + } return runTerminalResize({ environmentId, input: { threadId, terminalId, cols, rows }, }); }); - const terminalOutput = terminalSession.output; - const terminalError = terminalSession.error; - const terminalStatus = terminalSession.status; const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); const synchronizedStatusRef = useRef("closed"); const synchronizeTerminalStatus = useEffectEvent( @@ -571,19 +584,6 @@ function TerminalSessionViewport({ if (version > 0) synchronizedStatusRef.current = status; }, ); - const terminalVersion = terminalSession.version; - const previousSessionRef = useRef({ - output: terminalOutput, - error: terminalError, - status: terminalStatus, - version: terminalVersion, - }); - const readLatestSession = useEffectEvent(() => ({ - output: terminalOutput, - error: terminalError, - status: terminalStatus, - version: terminalVersion, - })); const openLinkInPreview = useEffectEvent( (url: string, forceBrowser: boolean, fallbackToBrowser: () => void) => openTerminalLinkInPreview({ url, threadRef, openPreview, fallbackToBrowser, forceBrowser }), @@ -591,7 +591,9 @@ function TerminalSessionViewport({ useLayoutEffect(() => { if (terminalRef.current) terminalRef.current.input.readOnly = !canOperateTerminal; - }, [canOperateTerminal]); + if (canOperateTerminal && visibleRef.current) + consumePendingTerminalFocus(threadRef, terminalId); + }, [canOperateTerminal, terminalId, threadRef]); // Links re-read the grant on activation; only the hovered link's styling needs a nudge. const linkActivationScopeRef = useRef(canOpenHostEditor); @@ -601,17 +603,12 @@ function TerminalSessionViewport({ terminalRef.current?.refreshLinkActivation(); }, [canOpenHostEditor]); - useEffect(() => { - if (resizeSessionGeneration === null) return; - // The first fit can precede authorization/attach; replay the grid for each writable session, including - // reconnects. - terminalRef.current?.resendSize(); - }, [resizeSessionGeneration]); - useLayoutEffect(() => { visibleRef.current = visible; terminalRef.current?.setVisible(visible); - }, [visible]); + // A request made while this viewport was hidden could not focus it. + if (visible) consumePendingTerminalFocus(threadRef, terminalId); + }, [terminalId, threadRef, visible]); useEffect(() => { const current = terminalFontRef.current; @@ -663,8 +660,7 @@ function TerminalSessionViewport({ if (currentFont.family !== setupFont.family || currentFont.size !== setupFont.size) { void terminal.setFont(terminalFontOptions(currentFont.family, currentFont.size)); } - const latestSession = readLatestSession(); - previousSessionRef.current = latestSession; + const latestSession = readTerminalSession(); const initialOutput = readTerminalOutputUpdate( latestSession.output, INITIAL_TERMINAL_OUTPUT_CURSOR, @@ -676,6 +672,34 @@ function TerminalSessionViewport({ if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error); synchronizedStatusRef.current = "closed"; synchronizeTerminalStatus(terminal, latestSession.status, latestSession.version); + // Read and subscribe in one synchronous step so no output lands between them. + let previousSession = latestSession; + let resizeGeneration = writableSessionGeneration(latestSession, hasTerminalWriteAccess()); + setupCleanups.push( + subscribeTerminalSession((session) => { + const nextResizeGeneration = writableSessionGeneration(session, hasTerminalWriteAccess()); + if (nextResizeGeneration !== resizeGeneration) { + resizeGeneration = nextResizeGeneration; + // The first fit can precede authorization/attach; replay the grid for each writable session, including + // reconnects. + if (nextResizeGeneration !== null) terminal.resendSize(); + } + const previous = previousSession; + synchronizeTerminalStatus(terminal, session.status, session.version); + if (session.version === previous.version && session.output === previous.output) { + return; + } + outputCursorRef.current = synchronizeTerminalOutput( + terminal, + session, + outputCursorRef.current, + ); + if (session.error !== null && session.error !== previous.error) { + writeSystemMessage(terminal, session.error); + } + previousSession = session; + }), + ); if ( hasTerminalWriteAccess() && visibleRef.current && @@ -1065,61 +1089,30 @@ function TerminalSessionViewport({ }; }, [cwd, environmentId, terminalId]); - useEffect(() => { - const terminal = terminalRef.current; - const current = { - output: terminalOutput, - error: terminalError, - status: terminalStatus, - version: terminalVersion, + const attachContainer = (mount: HTMLDivElement) => { + containerRef.current = mount; + // Focus lands on the mount until the surface exists or is shown; the + // surface takes it over from there. + const focus = () => { + if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) return false; + mount.focus({ preventScroll: true }); + terminalRef.current?.focus(); + return mount.contains(document.activeElement); }; - if (!terminal) { - previousSessionRef.current = current; - return; - } - - const previous = previousSessionRef.current; - synchronizeTerminalStatus(terminal, current.status, current.version); - if (current.version === previous.version && current.output === previous.output) { - return; - } - - outputCursorRef.current = synchronizeTerminalOutput(terminal, current, outputCursorRef.current); - - if (current.error !== null && current.error !== previous.error) { - writeSystemMessage(terminal, current.error); - } - - previousSessionRef.current = current; - }, [terminalOutput, terminalError, terminalStatus, terminalVersion]); - - const focusRequest = autoFocus && canOperateTerminal && visible ? focusRequestId : null; - useEffect(() => { - if (focusRequest === null) return; - (terminalRef.current ?? containerRef.current)?.focus(); - }, [focusRequest]); - - // A hidden surface skips fitting, and showing it again fits through setVisible. - const layoutRevision = visible ? `${drawerHeight}:${resizeEpoch}` : null; - useEffect(() => { - const terminal = terminalRef.current; - if (!terminal || layoutRevision === null) return; - const wasAtBottom = terminal.isAtBottom(); - // onResize alone sends PTY resize RPCs; fitting here only refreshes layout. - const frame = window.requestAnimationFrame(() => { - if (!visibleRef.current) return; - terminal.fit(); - if (wasAtBottom) { - terminal.scrollToBottom(); - } + if (autoFocus) focus(); + const unregister = registerTerminalFocusTarget({ + threadRef, + terminalId, + focus, }); return () => { - window.cancelAnimationFrame(frame); + unregister(); + containerRef.current = null; }; - }, [layoutRevision]); + }; return (
    @@ -1141,7 +1134,6 @@ interface ThreadTerminalDrawerProps { activeTerminalId: string; terminalGroups: ThreadTerminalGroup[]; activeTerminalGroupId: string; - focusRequestId: number; onSplitTerminal: () => void; onSplitTerminalVertical: () => void; onNewTerminal: () => void; @@ -1214,10 +1206,7 @@ function TerminalSplitPanes({ threadRef, threadId, onAddTerminalContext, - focusRequestId, visible, - resizeEpoch, - drawerHeight, keybindings, }: { terminalIds: readonly string[]; @@ -1231,10 +1220,7 @@ function TerminalSplitPanes({ threadRef: ScopedThreadRef; threadId: ThreadId; onAddTerminalContext: (selection: TerminalContextSelection) => void; - focusRequestId: number; visible: boolean; - resizeEpoch: number; - drawerHeight: number; keybindings: ResolvedKeybindingsConfig; }) { return ( @@ -1282,11 +1268,7 @@ function TerminalSplitPanes({ : {})} onSessionExited={() => onCloseTerminal(terminalId)} onAddTerminalContext={onAddTerminalContext} - focusRequestId={focusRequestId} - autoFocus={terminalId === activeTerminalId} visible={visible} - resizeEpoch={resizeEpoch} - drawerHeight={drawerHeight} keybindings={keybindings} />
    @@ -1408,7 +1390,6 @@ export default function ThreadTerminalDrawer({ activeTerminalId, terminalGroups, activeTerminalGroupId, - focusRequestId, onSplitTerminal, onSplitTerminalVertical, onNewTerminal, @@ -1431,36 +1412,19 @@ export default function ThreadTerminalDrawer({ false, Schema.Boolean, ); - const controlledDrawerHeight = height; const drawerRef = useRef(null); const [drawerLayout, setDrawerLayout] = useState(() => readDrawerLayout(null)); const maxDrawerHeight = resolveTerminalDrawerMaxHeight({ ...drawerLayout, composerHeight }); - const [drawerHeightState, setDrawerHeightState] = useState(() => ({ - threadId, - height: controlledDrawerHeight, - })); - const preferredDrawerHeight = - drawerHeightState.threadId === threadId ? drawerHeightState.height : controlledDrawerHeight; - const drawerHeight = clampDrawerHeight(preferredDrawerHeight, maxDrawerHeight); - const setDrawerHeight = (update: SetStateAction) => { - setDrawerHeightState((current) => { - const currentHeight = current.threadId === threadId ? current.height : controlledDrawerHeight; - const nextHeight = typeof update === "function" ? update(currentHeight) : update; - return nextHeight === currentHeight && current.threadId === threadId - ? current - : { threadId, height: nextHeight }; - }); - }; - const [resizeEpoch, setResizeEpoch] = useState(0); - const drawerHeightRef = useRef(drawerHeight); - const lastSyncedHeightRef = useRef({ threadId, height: controlledDrawerHeight }); + const drawerHeight = clampDrawerHeight(height, maxDrawerHeight); + // A drag only writes the drawer's style; the store hears the height once, at + // pointerup. The custom property outranks the rendered height while it is set. const resizeStateRef = useRef<{ pointerId: number; startY: number; startHeight: number; + height: number; drawer: HTMLElement | null; } | null>(null); - const didResizeDuringDragRef = useRef(false); const normalizedTerminalIds = (() => { const normalizedIds: string[] = []; @@ -1630,35 +1594,15 @@ export default function ThreadTerminalDrawer({ }); }; - useLayoutEffect(() => { - const resizeState = resizeStateRef.current; - if (resizeState) { - resizeState.drawer?.style.setProperty("height", `${drawerHeightRef.current}px`); - return; - } - drawerHeightRef.current = drawerHeight; - }, [drawerHeight]); - - const syncHeight = (nextHeight: number) => { - const lastSynced = lastSyncedHeightRef.current; - if (lastSynced.threadId === threadId && lastSynced.height === nextHeight) return; - lastSyncedHeightRef.current = { threadId, height: nextHeight }; - onHeightChange(nextHeight); - }; - - useEffect(() => { - lastSyncedHeightRef.current = { threadId, height: controlledDrawerHeight }; - }, [controlledDrawerHeight, threadId]); - const handleResizePointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); - didResizeDuringDragRef.current = false; resizeStateRef.current = { pointerId: event.pointerId, startY: event.clientY, - startHeight: drawerHeightRef.current, + startHeight: drawerHeight, + height: drawerHeight, drawer: event.currentTarget.parentElement, }; }; @@ -1671,12 +1615,11 @@ export default function ThreadTerminalDrawer({ resizeState.startHeight + (resizeState.startY - event.clientY), maxDrawerHeight, ); - if (clampedHeight === drawerHeightRef.current) { + if (clampedHeight === resizeState.height) { return; } - didResizeDuringDragRef.current = true; - drawerHeightRef.current = clampedHeight; - resizeState.drawer?.style.setProperty("height", `${clampedHeight}px`); + resizeState.height = clampedHeight; + resizeState.drawer?.style.setProperty(DRAG_HEIGHT_PROPERTY, `${clampedHeight}px`); }; const handleResizePointerEnd = (event: ReactPointerEvent) => { @@ -1686,19 +1629,11 @@ export default function ThreadTerminalDrawer({ if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } + resizeState.drawer?.style.removeProperty(DRAG_HEIGHT_PROPERTY); // A drag that ends where it started keeps the stored preference, which may // be taller than the capped height the drag started from. - if (!didResizeDuringDragRef.current || drawerHeightRef.current === resizeState.startHeight) { - // React skips the write when the rendered height did not change, so put - // back the preference as the current layout caps it. - const renderedHeight = clampDrawerHeight(preferredDrawerHeight, maxDrawerHeight); - drawerHeightRef.current = renderedHeight; - resizeState.drawer?.style.setProperty("height", `${renderedHeight}px`); - return; - } - setDrawerHeight(drawerHeightRef.current); - syncHeight(drawerHeightRef.current); - setResizeEpoch((value) => value + 1); + if (resizeState.height === resizeState.startHeight || resizeState.height === height) return; + onHeightChange(resizeState.height); }; const drawerLayoutRef = useRef(drawerLayout); @@ -1715,16 +1650,17 @@ export default function ThreadTerminalDrawer({ if (!heightBudgetChanged && source === "column") return; drawerLayoutRef.current = layout; const resizeState = resizeStateRef.current; - const liveHeight = clampDrawerHeight( - drawerHeightRef.current, - resolveTerminalDrawerMaxHeight({ ...layout, composerHeight }), - ); - if (resizeState && liveHeight !== drawerHeightRef.current) { - drawerHeightRef.current = liveHeight; - resizeState.drawer?.style.setProperty("height", `${liveHeight}px`); + if (resizeState) { + const liveHeight = clampDrawerHeight( + resizeState.height, + resolveTerminalDrawerMaxHeight({ ...layout, composerHeight }), + ); + if (liveHeight !== resizeState.height) { + resizeState.height = liveHeight; + resizeState.drawer?.style.setProperty(DRAG_HEIGHT_PROPERTY, `${liveHeight}px`); + } } if (heightBudgetChanged) setDrawerLayout(layout); - setResizeEpoch((value) => value + 1); }, ); useLayoutEffect(() => { @@ -1749,9 +1685,6 @@ export default function ThreadTerminalDrawer({ }; }, [isPanel, visible]); - const syncPreferredHeight = useEffectEvent(() => syncHeight(preferredDrawerHeight)); - useEffect(() => () => syncPreferredHeight(), []); - if (normalizedTerminalIds.length === 0) { return ( + ) : null} + {/* Rendered in both modes: base-ui keeps a popup that unmounts mid-open + mounted, because its exit animation never finishes. Staying in the tree + lets it close cleanly when the card switches inline. */} + + {mode === "popover" ? (
    {card}
    -
    - )} + ) : null} + ); } diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index cea257e464127..3e62dd8cd5a44 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -27,7 +27,7 @@ const GitActionsControl = memo(BaseGitActionsControl); export interface ThreadDetailsPanelProps extends Pick< ComponentProps, - "anchor" | "handle" | "onPresentationChange" + "anchor" | "handle" > { forceNewWorktree?: boolean; environmentId: EnvironmentId; @@ -105,7 +105,6 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { threadRef={{ environmentId: props.environmentId, threadId: props.threadId }} anchor={props.anchor} handle={props.handle} - onPresentationChange={props.onPresentationChange} > {(density) => ( <> diff --git a/apps/web/src/components/chat/ThreadFindProvider.tsx b/apps/web/src/components/chat/ThreadFindProvider.tsx index 2a13e64f0b6bb..af5a5d7067f61 100644 --- a/apps/web/src/components/chat/ThreadFindProvider.tsx +++ b/apps/web/src/components/chat/ThreadFindProvider.tsx @@ -1,33 +1,20 @@ -import { - createContext, - use, - useImperativeHandle, - useLayoutEffect, - type ReactNode, - type ComponentProps, - type Ref, -} from "react"; +import { createContext, use, type ReactNode, type ComponentProps } from "react"; import { useThreadFind } from "./useThreadFind"; import { ThreadFindBar } from "./ThreadFindBar"; import { ChatCanvas } from "./ChatCanvas"; type Find = ReturnType; -export type ThreadFindControls = Pick; export const ThreadFindTimelineContext = createContext(null); const ThreadFindBarContext = createContext(null); export function ThreadFindCanvas({ findOptions, - controlsRef, - onOpenChange, ...canvasProps }: ComponentProps & { findOptions: Parameters[0]; - controlsRef: Ref; - onOpenChange: (open: boolean) => void; }) { return ( - + ); @@ -35,21 +22,10 @@ export function ThreadFindCanvas({ /** Owns search updates below the chat view so cycling does not rerender the composer and panels. */ function ThreadFindProvider({ - controlsRef, - onOpenChange, children, ...options -}: Parameters[0] & { - controlsRef: Ref; - onOpenChange: (open: boolean) => void; - children: ReactNode; -}) { +}: Parameters[0] & { children: ReactNode }) { const find = useThreadFind(options); - useImperativeHandle(controlsRef, () => ({ open: find.open, close: find.close }), [ - find.open, - find.close, - ]); - useLayoutEffect(() => onOpenChange(find.isOpen), [find.isOpen, onOpenChange]); return ( {children} diff --git a/apps/web/src/components/chat/canvasStore.test.ts b/apps/web/src/components/chat/canvasStore.test.ts new file mode 100644 index 0000000000000..ad2fe38b044ba --- /dev/null +++ b/apps/web/src/components/chat/canvasStore.test.ts @@ -0,0 +1,83 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { + createChatCanvasStore, + resolveChatCanvasModel, + type ChatCanvasMeasurements, +} from "./canvasStore"; +import type { ChatCanvasPreview } from "./chatCanvasLayout"; + +const threadRef = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), +}; +const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") }; +const measurements = (width: number, height = 900): ChatCanvasMeasurements => ({ + width, + height, + padding: 48, + maxChatWidth: 768, + minChatWidth: 640, + composerHeight: 180, + timelineGutter: 0, + findBar: null, +}); +const preview: ChatCanvasPreview = { + key: "browser:one", + width: 320, + position: null, + source: { width: 1600, height: 1000 }, +}; + +describe("chat canvas model", () => { + it("shows the details card inline only where the canvas fits it beside the chat", () => { + const resolve = (width: number) => + resolveChatCanvasModel({ + measurements: measurements(width), + detailsCardMeasurement: null, + threadRef, + preview: null, + detailsCardInlineOpen: true, + }).detailsCardMode; + expect(resolve(1400)).toBe("inline"); + expect(resolve(900)).toBe("popover"); + }); + + it("moves the card into a popover when a preview below it leaves too little height", () => { + const resolve = (fullContentHeight: number) => + resolveChatCanvasModel({ + measurements: measurements(1100, 300), + detailsCardMeasurement: { threadRef, width: 280, fullContentHeight }, + threadRef, + preview: { ...preview, position: { x: 760, y: 150 }, lastInteraction: "drag" }, + detailsCardInlineOpen: true, + }); + const model = resolve(400); + expect(model.layout.overlapsDetailsCard).toBe(true); + expect(model.detailsCardMode).toBe("popover"); + }); + + it("ignores a details card measured for another thread", () => { + const model = resolveChatCanvasModel({ + measurements: measurements(1100, 300), + detailsCardMeasurement: { threadRef: otherThreadRef, width: 280, fullContentHeight: 400 }, + threadRef, + preview: { ...preview, position: { x: 760, y: 150 }, lastInteraction: "drag" }, + detailsCardInlineOpen: true, + }); + expect(model.layout.overlapsDetailsCard).toBe(false); + expect(model.detailsCardMode).toBe("inline"); + }); + + it("keeps state identity when a measurement repeats", () => { + const store = createChatCanvasStore(); + store.getState().setMeasurements(measurements(1400)); + const before = store.getState(); + store.getState().setMeasurements(measurements(1400)); + store.getState().measureDetailsCard({ threadRef, width: 280, fullContentHeight: 300 }); + const measured = store.getState(); + store.getState().measureDetailsCard({ threadRef, width: 280, fullContentHeight: 300 }); + expect(measured.measurements).toBe(before.measurements); + expect(store.getState()).toBe(measured); + }); +}); diff --git a/apps/web/src/components/chat/canvasStore.ts b/apps/web/src/components/chat/canvasStore.ts new file mode 100644 index 0000000000000..76fb540dc5d41 --- /dev/null +++ b/apps/web/src/components/chat/canvasStore.ts @@ -0,0 +1,157 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { createStore } from "zustand"; +import type { ThreadPanelPresentation } from "../../rightPanelLayout"; +import type { DetailsCardMeasurement } from "./ChatCanvasContext"; +import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; +import { resolveThreadDetailsCardLayout } from "./threadDetailsCardLayout"; + +interface CanvasEdges { + readonly left: number; + readonly right: number; + readonly bottom: number; +} + +export interface ChatCanvasMeasurements { + readonly width: number; + readonly height: number; + readonly padding: number; + readonly maxChatWidth: number; + readonly minChatWidth: number; + readonly composerHeight: number; + readonly timelineGutter: number; + /** The find bar's box in canvas coordinates while it is shown. */ + readonly findBar: CanvasEdges | null; +} + +interface ChatCanvasStoreState { + readonly measurements: ChatCanvasMeasurements; + readonly detailsCardMeasurement: DetailsCardMeasurement | null; + readonly setMeasurements: (next: ChatCanvasMeasurements) => void; + readonly measureDetailsCard: (next: DetailsCardMeasurement) => void; +} + +const INITIAL_MEASUREMENTS: ChatCanvasMeasurements = { + width: 0, + height: 0, + padding: 48, + maxChatWidth: 768, + minChatWidth: 640, + composerHeight: 0, + timelineGutter: 0, + findBar: null, +}; + +const sameEdges = (a: CanvasEdges | null, b: CanvasEdges | null) => + a === b || + (a !== null && b !== null && a.left === b.left && a.right === b.right && a.bottom === b.bottom); + +const sameMeasurements = (a: ChatCanvasMeasurements, b: ChatCanvasMeasurements) => + a.width === b.width && + a.height === b.height && + a.padding === b.padding && + a.maxChatWidth === b.maxChatWidth && + a.minChatWidth === b.minChatWidth && + a.composerHeight === b.composerHeight && + a.timelineGutter === b.timelineGutter && + sameEdges(a.findBar, b.findBar); + +/** + * One store per chat view: the canvas measures into it, and the chat view's + * header controls derive the details card presentation from the same inputs. + */ +export const createChatCanvasStore = () => + createStore()((set) => ({ + measurements: INITIAL_MEASUREMENTS, + detailsCardMeasurement: null, + setMeasurements: (next) => + set((state) => (sameMeasurements(state.measurements, next) ? state : { measurements: next })), + measureDetailsCard: (next) => + set((state) => { + const current = state.detailsCardMeasurement; + return current?.threadRef.environmentId === next.threadRef.environmentId && + current.threadRef.threadId === next.threadRef.threadId && + current.width === next.width && + current.fullContentHeight === next.fullContentHeight + ? state + : { detailsCardMeasurement: next }; + }), + })); + +export type ChatCanvasStore = ReturnType; + +export function resolveChatCanvasModel({ + measurements, + detailsCardMeasurement, + threadRef, + preview, + detailsCardInlineOpen, +}: { + measurements: ChatCanvasMeasurements; + detailsCardMeasurement: DetailsCardMeasurement | null; + /** The thread whose details card sits on the canvas; other threads' measurements are stale. */ + threadRef: ScopedThreadRef | null; + preview: ChatCanvasPreview | null; + detailsCardInlineOpen: boolean; +}) { + const container = { width: measurements.width, height: measurements.height }; + const { findBar } = measurements; + const detailsCardTopInset = findBar?.bottom ?? 0; + const lane = { padding: measurements.padding, minChatWidth: measurements.minChatWidth }; + // The preview avoids where an open inline card would sit, whether or not a + // preview overlap has pushed the card itself into popover mode. + const preferredDetailsCard = resolveThreadDetailsCardLayout({ + container, + lane, + frame: null, + topInset: detailsCardTopInset, + }); + // The canvas outlives thread switches, so a measurement only counts for the + // thread whose card is shown now. + const detailsCardMeasured = + threadRef !== null && + detailsCardMeasurement !== null && + detailsCardMeasurement.threadRef.environmentId === threadRef.environmentId && + detailsCardMeasurement.threadRef.threadId === threadRef.threadId + ? detailsCardMeasurement + : null; + const detailsCard = + detailsCardInlineOpen && + preferredDetailsCard && + detailsCardMeasured?.width === preferredDetailsCard.width && + detailsCardMeasured.fullContentHeight > 0 + ? { + left: preferredDetailsCard.x, + right: preferredDetailsCard.x + preferredDetailsCard.width, + bottom: + preferredDetailsCard.y + + Math.min(detailsCardMeasured.fullContentHeight, preferredDetailsCard.height), + } + : null; + const layout = resolveChatCanvasLayout({ + container, + preview, + padding: measurements.padding, + maxChatWidth: measurements.maxChatWidth, + minChatWidth: measurements.minChatWidth, + composerHeight: measurements.composerHeight, + detailsCard, + findBar, + }); + const detailsCardPlacement = resolveThreadDetailsCardLayout({ + container, + lane, + frame: layout.frame, + overlapsDetailsCard: layout.overlapsDetailsCard, + topInset: detailsCardTopInset, + }); + const detailsCardMode: ThreadPanelPresentation = detailsCardPlacement ? "inline" : "popover"; + return { + container, + lane, + layout, + detailsCardTopInset, + preferredDetailsCard, + detailsCardPlacement, + detailsCardMode, + }; +} diff --git a/apps/web/src/components/chat/composerCommandKey.ts b/apps/web/src/components/chat/composerCommandKey.ts new file mode 100644 index 0000000000000..c0018767d1ca3 --- /dev/null +++ b/apps/web/src/components/chat/composerCommandKey.ts @@ -0,0 +1,255 @@ +import type { ChatComposerProps } from "./ChatComposer"; +import { type ComposerCommandItem } from "./ComposerCommandMenu"; +import { + type ComposerDispatchMode, + resolveComposerDispatchMode, +} from "@t3tools/client-runtime/state/composer-dispatch"; +import { type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; +import { + type ComposerPromptHistoryPosition, + buildComposerPromptHistoryEntries, + stepComposerPromptHistory, +} from "./composerPromptHistory"; +import { + type ComposerSubmissionIntent, + type ComposerTrigger, + collapseExpandedComposerCursor, + composerSubmissionIntentForKey, +} from "../../composer-logic"; +import { type PendingUserInput } from "../../session-logic"; +import { type PreviewAnnotationPayload } from "@t3tools/contracts"; +import { type ReviewCommentContext } from "../../reviewCommentContext"; +import { type ComposerThreadDraftState, composerTargetKey } from "../../composerDraftStore"; +import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation"; +import type { Dispatch, RefObject, SetStateAction } from "react"; + +/** + * The composer's key handling inside the editor: send shortcuts, the slash/mention menu, + * prompt history on ArrowUp/ArrowDown, plan-mode toggle, and list continuation. + * Returns whether the key was handled. + */ +export function handleComposerCommandKey( + input: { + readonly keybindings: ChatComposerProps["keybindings"]; + readonly sendShortcut: ChatComposerProps["settings"]["sendShortcut"]; + readonly routeKind: ChatComposerProps["routeKind"]; + readonly phase: ChatComposerProps["phase"]; + readonly isCompacting: boolean; + readonly followUpBehavior: ChatComposerProps["settings"]["followUpBehavior"]; + readonly isSoftwareKeyboardOpen: boolean; + readonly planModeUiEnabled: boolean; + readonly isLiteralPendingAnswer: boolean; + readonly pendingUserInputs: ReadonlyArray; + readonly promptHistoryMessages: ChatComposerProps["promptHistoryMessages"]; + readonly promptHistoryPositionRef: RefObject<{ + targetKey: string; + position: ComposerPromptHistoryPosition; + } | null>; + readonly composerDraftTarget: ChatComposerProps["composerDraftTarget"]; + readonly readPrompt: () => string; + readonly readAttachmentDraft: () => Pick; + readonly readTerminalContexts: () => ComposerThreadDraftState["terminalContexts"]; + readonly composerPreviewAnnotations: ReadonlyArray; + readonly composerReviewComments: ReadonlyArray; + readonly composerEditorRef: RefObject; + readonly composerMenuOpen: boolean; + readonly composerMenuItems: ReadonlyArray; + readonly activeComposerMenuItem: ComposerCommandItem | null; + readonly setComposerDraftPrompt: ( + target: ChatComposerProps["composerDraftTarget"], + prompt: string, + ) => void; + readonly setComposerCursor: Dispatch>; + readonly setComposerTrigger: (trigger: ComposerTrigger | null) => void; + readonly setComposerHighlightedItemId: Dispatch>; + readonly dismissComposerTrigger: (trigger: ComposerTrigger | null) => void; + readonly toggleInteractionMode: () => void; + readonly nudgeComposerMenuHighlight: (key: "ArrowDown" | "ArrowUp") => void; + readonly onSelectComposerItem: (item: ComposerCommandItem) => void; + readonly resolveActiveComposerTrigger: () => { trigger: ComposerTrigger | null }; + readonly readComposerSnapshot: () => { value: string }; + readonly applyPromptReplacement: ( + rangeStart: number, + rangeEnd: number, + replacement: string, + options?: { expandedCursorAfterReplace?: number }, + ) => boolean; + readonly submitComposer: ( + event: undefined, + dispatchMode: ComposerDispatchMode, + submissionIntent: ComposerSubmissionIntent, + ) => void; + }, + key: string, + event: KeyboardEvent, + isTaskItem = false, +): boolean { + const { + keybindings, + sendShortcut, + routeKind, + phase, + isCompacting, + followUpBehavior, + isSoftwareKeyboardOpen, + planModeUiEnabled, + isLiteralPendingAnswer, + pendingUserInputs, + promptHistoryMessages, + promptHistoryPositionRef, + composerDraftTarget, + readPrompt, + readAttachmentDraft, + readTerminalContexts, + composerPreviewAnnotations, + composerReviewComments, + composerEditorRef, + composerMenuOpen, + composerMenuItems, + activeComposerMenuItem, + setComposerDraftPrompt, + setComposerCursor, + setComposerTrigger, + setComposerHighlightedItemId, + dismissComposerTrigger, + toggleInteractionMode, + nudgeComposerMenuHighlight, + onSelectComposerItem, + resolveActiveComposerTrigger, + readComposerSnapshot, + applyPromptReplacement, + submitComposer, + } = input; + // The composer persists across threads. A recall from thread A must not + // be treated as active in thread B, where the text-match fallback could + // otherwise turn B's own draft into a browsing position. + const promptHistoryTargetKey = composerTargetKey(composerDraftTarget); + + const replacePromptFromHistory = (nextPrompt: string) => { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + setComposerHighlightedItemId(null); + }; + + const navigatePromptHistory = ( + direction: "backward" | "forward", + event: KeyboardEvent, + ): boolean => { + if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { + return false; + } + if (pendingUserInputs.length > 0) return false; + const attachmentDraft = readAttachmentDraft(); + if ( + attachmentDraft.images.length > 0 || + attachmentDraft.files.length > 0 || + readTerminalContexts().length > 0 || + composerPreviewAnnotations.length > 0 || + composerReviewComments.length > 0 + ) { + return false; + } + const recalled = promptHistoryPositionRef.current; + const position = recalled?.targetKey === promptHistoryTargetKey ? recalled.position : null; + if (position === null && readPrompt().length > 0) { + return false; + } + const editor = composerEditorRef.current; + if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) { + return false; + } + const step = stepComposerPromptHistory({ + direction, + // Built on the keypress, not per render: the timeline changes on every streamed delta and + // ArrowUp is rare. + entries: buildComposerPromptHistoryEntries(promptHistoryMessages), + position, + currentPrompt: readPrompt(), + }); + if (!step) return false; + promptHistoryPositionRef.current = step.position + ? { targetKey: promptHistoryTargetKey, position: step.position } + : null; + replacePromptFromHistory(step.prompt); + return true; + }; + + const submissionIntent = composerSubmissionIntentForKey({ + event, + keybindings, + isSoftwareKeyboardOpen, + isDraftThread: routeKind === "draft", + isRunning: phase === "running", + sendShortcut, + prompt: readPrompt(), + }); + if (key === "Tab" && event.shiftKey && submissionIntent === null) { + if (!planModeUiEnabled) return false; + toggleInteractionMode(); + return true; + } + const { trigger } = resolveActiveComposerTrigger(); + const menuIsActive = !isLiteralPendingAnswer && (composerMenuOpen || trigger !== null); + if (key === "Escape") { + if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; + dismissComposerTrigger(trigger); + return true; + } + if (menuIsActive && (submissionIntent === null || submissionIntent === "foreground")) { + const currentItems = composerMenuItems; + const selectedItem = activeComposerMenuItem ?? currentItems[0]; + if (key === "ArrowDown" && currentItems.length > 0) { + nudgeComposerMenuHighlight("ArrowDown"); + return true; + } + if (key === "ArrowUp" && currentItems.length > 0) { + nudgeComposerMenuHighlight("ArrowUp"); + return true; + } + if ((key === "Enter" || key === "Tab") && selectedItem) { + onSelectComposerItem(selectedItem); + return true; + } + } + if ((key === "ArrowUp" || key === "ArrowDown") && submissionIntent === null) { + return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event); + } + if (submissionIntent) { + submitComposer( + undefined, + resolveComposerDispatchMode({ + running: phase === "running", + alternateModifier: submissionIntent === "alternate", + activeTurnDefault: followUpBehavior, + canSteer: !isCompacting, + }), + submissionIntent, + ); + return true; + } + if (key === "Enter" && isTaskItem) return false; + if ( + !isLiteralPendingAnswer && + !event.isComposing && + (key === "Enter" || (key === "Tab" && !event.shiftKey)) + ) { + const selection = composerEditorRef.current?.readSelectionRange(); + const snapshot = readComposerSnapshot(); + if (selection && selection.start === selection.end && snapshot.value === readPrompt()) { + const edit = + key === "Enter" + ? listContinuationForEnter(snapshot.value, selection.start) + : listIndentForTab(snapshot.value, selection.start, selection.end); + if ( + edit && + applyPromptReplacement(edit.start, edit.end, edit.replacement, { + expandedCursorAfterReplace: edit.cursorAfter, + }) + ) { + return true; + } + } + } + return false; +} diff --git a/apps/web/src/components/chat/composerOverlayMeasurer.ts b/apps/web/src/components/chat/composerOverlayMeasurer.ts new file mode 100644 index 0000000000000..d3fd4bb0ef1f2 --- /dev/null +++ b/apps/web/src/components/chat/composerOverlayMeasurer.ts @@ -0,0 +1,162 @@ +import { observeResize } from "~/lib/observeResize"; + +import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "../composerFooterLayout"; + +interface ComposerOverlayPublishers { + readonly setOverlayElement: (element: HTMLDivElement | null) => void; + readonly setTimelineInset: (inset: number) => void; + readonly setReservedHeight: (height: number) => void; + readonly setScrollToEndClearance: (clearance: number) => void; +} + +interface ComposerOverlayMeasurer { + /** Ref callback for the overlay element. Identity is stable for the measurer's lifetime. */ + readonly attach: (overlay: HTMLDivElement) => () => void; + /** Publishes a height the composer measured itself, such as a transition's destination. */ + readonly publishHeight: (height: number) => void; + /** Re-measures the overlay as it is now laid out. */ + readonly remeasure: () => void; + /** + * Starts a new measurement series (thread switch, composer mounted): the + * held heights start over from zero and the overlay is re-measured. + */ + readonly startSeries: () => void; + /** + * Ends the series while the composer is still in the DOM. The held heights + * reset without measuring, and the overlay observer re-measures once the + * composer has left the layout. + */ + readonly endSeries: () => void; + /** Starts a new series when the thread differs from the last one measured. */ + readonly showThread: (threadKey: string | null) => void; +} + +/** + * Owns the overlay's held heights and publishes the timeline inset, the + * reserved composer height, and the scroll-to-end clearance from the + * committed DOM, so a resize callback between renders sees the layout it measures. + */ +export function createComposerOverlayMeasurer( + publishers: ComposerOverlayPublishers, +): ComposerOverlayMeasurer { + const held = { inset: 0, reserved: 0, expanded: 0 }; + let current: HTMLDivElement | null = null; + let threadKey: string | null = null; + + const publish = (overlay: HTMLElement, height: number) => { + const nextHeight = Math.ceil(height); + if (nextHeight <= 0) return; + const isResting = overlay.querySelector('[data-composer-resting="true"]') !== null; + const modelStrip = overlay.querySelector('[data-composer-model-strip="true"]'); + const restingOnlyHeight = + modelStrip && isResting + ? Math.max( + 0, + modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop), + ) + : 0; + const noticeStack = overlay.querySelector('[data-composer-banner-stack="true"]'); + const expandedNoticesHeight = noticeStack + ? Math.max( + 0, + noticeStack.getBoundingClientRect().height - + Number.parseFloat(getComputedStyle(noticeStack).minHeight), + ) + : 0; + const nextInset = resolveComposerTimelineInset({ + currentInset: held.inset, + overlayHeight: height, + isResting, + restingOnlyHeight, + expandedNoticesHeight, + }); + if (held.inset !== nextInset) { + held.inset = nextInset; + publishers.setTimelineInset(nextInset); + } + // The terminal drawer and placeholder lay out against the expanded composer. + // Remembering it while the mobile strip is collapsed keeps the drawer still as + // focus moves; a collapsed strip can still grow, so the reservation follows it + // up. A hero composer's overlay fills the pane, so only its stack counts. + const stack = overlay.querySelector('[data-chat-composer-stack="true"]'); + const isDraftHero = overlay.dataset.draftHero === "true"; + const expandedComposerHeight = + isDraftHero && stack ? Math.ceil(stack.getBoundingClientRect().height) : nextInset; + const mobileCollapsed = Boolean( + stack?.querySelector('[data-chat-composer-mobile-collapsed="true"]'), + ); + if (!mobileCollapsed) held.expanded = expandedComposerHeight; + const reservedHeight = Math.max(held.expanded, expandedComposerHeight); + if (held.reserved !== reservedHeight) { + held.reserved = reservedHeight; + publishers.setReservedHeight(reservedHeight); + } + const mainSurface = overlay.querySelector( + '[data-chat-composer-main-surface="true"]', + ); + const button = overlay.parentElement?.querySelector( + 'button[aria-label="Scroll to end"]', + ); + const clearance = + mainSurface && button + ? resolveScrollToEndClearance({ + overlayHeight: nextHeight, + mainSurfaceTop: mainSurface.getBoundingClientRect().top, + button: button.getBoundingClientRect(), + attachments: Array.from( + overlay.querySelectorAll('[data-composer-banner-surface="attached"]'), + (element) => element.getBoundingClientRect(), + ), + }) + : nextHeight; + publishers.setScrollToEndClearance(clearance); + }; + + const measure = (overlay: HTMLElement) => + publish(overlay, overlay.getBoundingClientRect().height); + const resetHeld = () => { + held.inset = 0; + held.reserved = 0; + held.expanded = 0; + }; + const startSeries = () => { + resetHeld(); + if (current) measure(current); + }; + + return { + attach: (overlay) => { + current = overlay; + publishers.setOverlayElement(overlay); + measure(overlay); + // The hero overlay fills the pane, so the composer's own growth only + // shows on its stack. The stack always renders, so observing it in + // every layout keeps the observation independent of the hero state. + // Entering or leaving the hero layout resizes the overlay itself. + const stack = overlay.querySelector('[data-chat-composer-stack="true"]'); + const stopObserving = observeResize(stack ? [overlay, stack] : overlay, () => + measure(overlay), + ); + return () => { + stopObserving(); + if (current === overlay) { + current = null; + publishers.setOverlayElement(null); + } + }; + }, + publishHeight: (height) => { + if (current) publish(current, height); + }, + remeasure: () => { + if (current) measure(current); + }, + startSeries, + endSeries: resetHeld, + showThread: (nextThreadKey) => { + if (nextThreadKey === threadKey) return; + threadKey = nextThreadKey; + startSeries(); + }, + }; +} diff --git a/apps/web/src/components/chat/optimisticUserMessages.test.ts b/apps/web/src/components/chat/optimisticUserMessages.test.ts new file mode 100644 index 0000000000000..20d15941455eb --- /dev/null +++ b/apps/web/src/components/chat/optimisticUserMessages.test.ts @@ -0,0 +1,141 @@ +import { MessageId, ThreadId } from "@t3tools/contracts"; +import { AtomRegistry } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { ChatMessage } from "../../types"; +import { + applyOptimisticUserMessagesAction, + createOptimisticBlobPreviews, + dropOptimisticHandoff, + emptyOptimisticUserMessages, + loadedHandoffMessageIdsAtom, + reconcileOptimisticUserMessages, +} from "./optimisticUserMessages"; + +const threadA = ThreadId.make("thread-a"); +const threadB = ThreadId.make("thread-b"); + +function message(id: string, previewUrl?: string): ChatMessage { + return { + id: MessageId.make(id), + role: "user", + text: id, + runId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + streaming: false, + ...(previewUrl + ? { + attachments: [ + { + type: "image", + id: `${id}-image`, + name: "image.png", + mimeType: "image/png", + sizeBytes: 1, + previewUrl, + }, + ], + } + : {}), + } as ChatMessage; +} + +const ids = (...values: string[]) => new Set(values.map((value) => MessageId.make(value))); + +describe("optimistic user messages", () => { + it("an acknowledged message stays gone after the server drops its row", () => { + let state = applyOptimisticUserMessagesAction(emptyOptimisticUserMessages(threadA), threadA, [ + message("m1"), + message("m2", "blob:two"), + ]); + state = reconcileOptimisticUserMessages(state, threadA, ids("m1", "m2")); + expect(state.messages).toEqual([]); + expect(state.handoff).toEqual({ m2: ["blob:two"] }); + + // A cancelled queued run or a rewind removes the server's copy again. + state = reconcileOptimisticUserMessages(state, threadA, ids()); + expect(state.messages).toEqual([]); + + state = dropOptimisticHandoff(state, threadA, MessageId.make("m2")); + expect(state.handoff).toEqual({}); + }); + + it("releases the previous thread's messages when the thread changes", () => { + let state = applyOptimisticUserMessagesAction(emptyOptimisticUserMessages(threadA), threadA, [ + message("a1"), + ]); + state = reconcileOptimisticUserMessages(state, threadB, ids()); + state = reconcileOptimisticUserMessages(state, threadA, ids()); + expect(state).toEqual(emptyOptimisticUserMessages(threadA)); + }); + + it("drops a late update for a thread the view has left", () => { + const state = reconcileOptimisticUserMessages( + applyOptimisticUserMessagesAction(emptyOptimisticUserMessages(threadA), threadA, [ + message("a1"), + ]), + threadB, + ids(), + ); + expect(applyOptimisticUserMessagesAction(state, threadA, [message("a2")])).toBe(state); + }); + + it("revokes a thread's blob previews once, when their use ends or the thread is left", () => { + using revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + const previews = createOptimisticBlobPreviews(); + previews.track(threadA, message("a1", "blob:a1")); + previews.track(threadA, message("a2", "blob:a2")); + previews.track(threadB, message("b1", "blob:b1")); + + previews.revoke(threadA, ["blob:a1"]); + previews.revokeThread(threadA); + expect(revoke.mock.calls.map(([url]) => url)).toEqual(["blob:a1", "blob:a2"]); + }); +}); + +class FakeImage { + static readonly created: FakeImage[] = []; + src = ""; + private readonly listeners = new Map void>(); + constructor() { + FakeImage.created.push(this); + } + addEventListener(type: string, listener: () => void) { + this.listeners.set(type, listener); + } + fire(type: "load" | "error") { + this.listeners.get(type)?.(); + } +} + +describe("handoff preloads", () => { + it("reports a handoff loaded, and revokes its blobs, once every server preview loads", async () => { + vi.stubGlobal("Image", FakeImage); + try { + using revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + FakeImage.created.length = 0; + const registry = AtomRegistry.make(); + const atom = loadedHandoffMessageIdsAtom( + JSON.stringify([ + ["m-loaded", ["https://server/a.png", "https://server/b.png"], ["blob:a", "blob:b"]], + ["m-failed", ["https://server/c.png"], ["blob:c"]], + ]), + ); + const release = registry.mount(atom); + const image = (src: string) => FakeImage.created.find((candidate) => candidate.src === src); + + image("https://server/a.png")?.fire("load"); + image("https://server/c.png")?.fire("error"); + await Promise.resolve(); + expect(registry.get(atom)).toEqual([]); + + image("https://server/b.png")?.fire("load"); + await vi.waitFor(() => expect(registry.get(atom)).toEqual(["m-loaded"])); + expect(revoke.mock.calls.map(([url]) => url)).toEqual(["blob:a", "blob:b"]); + release(); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/web/src/components/chat/optimisticUserMessages.ts b/apps/web/src/components/chat/optimisticUserMessages.ts new file mode 100644 index 0000000000000..34b74ffff713f --- /dev/null +++ b/apps/web/src/components/chat/optimisticUserMessages.ts @@ -0,0 +1,160 @@ +import type { MessageId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { AsyncResult, Atom } from "effect/reactivity"; +import type { SetStateAction } from "react"; +import type { ChatMessage } from "../../types"; +import { collectUserMessageBlobPreviewUrls } from "../ChatView.logic"; + +/** + * Messages shown before the server echoes them. An acknowledged message leaves `messages` in the + * render its ack arrives, so it never reappears even if the server later drops its row. Its blob + * previews stay in `handoff` until the server's copies load (see useAttachmentPreviewHandoff). + */ +export interface OptimisticUserMessages { + readonly threadId: ThreadId; + readonly messages: ChatMessage[]; + readonly handoff: Readonly>>; +} + +export function emptyOptimisticUserMessages(threadId: ThreadId): OptimisticUserMessages { + return { threadId, messages: [], handoff: {} }; +} + +/** + * The view's state for `threadId`: the previous thread's messages are released when the thread + * changes, and acknowledged messages are moved out of `messages`. Returns `state` when nothing changes. + */ +export function reconcileOptimisticUserMessages( + state: OptimisticUserMessages, + threadId: ThreadId, + acknowledgedIds: ReadonlySet, +): OptimisticUserMessages { + if (state.threadId !== threadId) return emptyOptimisticUserMessages(threadId); + if (!state.messages.some((message) => acknowledgedIds.has(message.id))) return state; + const messages: ChatMessage[] = []; + const handoff: Record> = { ...state.handoff }; + for (const message of state.messages) { + if (!acknowledgedIds.has(message.id)) { + messages.push(message); + continue; + } + const previewUrls = collectUserMessageBlobPreviewUrls(message); + if (previewUrls.length > 0) handoff[message.id] = previewUrls; + } + return { ...state, messages, handoff }; +} + +/** Applies a send-path update. An update for a thread the view has since left is dropped. */ +export function applyOptimisticUserMessagesAction( + state: OptimisticUserMessages, + threadId: ThreadId, + action: SetStateAction, +): OptimisticUserMessages { + if (state.threadId !== threadId) return state; + const next = typeof action === "function" ? action(state.messages) : action; + const unchanged = + next.length === state.messages.length && + next.every((message, index) => message === state.messages[index]); + return unchanged ? state : { ...state, messages: next }; +} + +export function dropOptimisticHandoff( + state: OptimisticUserMessages, + threadId: ThreadId, + messageId: MessageId, +): OptimisticUserMessages { + if (state.threadId !== threadId || !(messageId in state.handoff)) return state; + const { [messageId]: _released, ...handoff } = state.handoff; + return { ...state, handoff }; +} + +/** + * The blob previews a chat view took from the composer for its optimistic messages, per thread. + * Revocation cannot run inside a state updater, so the handlers that end a preview's use revoke it + * here, and leaving the thread revokes whatever that thread still owns. + */ +export interface OptimisticBlobPreviews { + readonly track: (threadId: ThreadId, message: ChatMessage) => void; + readonly revoke: (threadId: ThreadId, previewUrls: ReadonlyArray) => void; + readonly release: (threadId: ThreadId, message: ChatMessage) => void; + readonly revokeThread: (threadId: ThreadId) => void; +} + +export function createOptimisticBlobPreviews(): OptimisticBlobPreviews { + const ownedByThread = new Map>(); + const revoke = (threadId: ThreadId, previewUrls: ReadonlyArray) => { + const owned = ownedByThread.get(threadId); + for (const previewUrl of previewUrls) { + owned?.delete(previewUrl); + if (typeof URL !== "undefined") URL.revokeObjectURL(previewUrl); + } + }; + return { + track: (threadId, message) => { + const previewUrls = collectUserMessageBlobPreviewUrls(message); + if (previewUrls.length === 0) return; + const owned = ownedByThread.get(threadId) ?? new Set(); + for (const previewUrl of previewUrls) owned.add(previewUrl); + ownedByThread.set(threadId, owned); + }, + revoke, + release: (threadId, message) => revoke(threadId, collectUserMessageBlobPreviewUrls(message)), + revokeThread: (threadId) => { + revoke(threadId, [...(ownedByThread.get(threadId) ?? [])]); + ownedByThread.delete(threadId); + }, + }; +} + +class ImagePreloadError extends Schema.TaggedError()("ImagePreloadError", { + url: Schema.String, +}) {} + +const preloadImage = (url: string) => + Effect.callback((resume) => { + const image = new Image(); + image.addEventListener("load", () => resume(Effect.void), { once: true }); + image.addEventListener("error", () => resume(Effect.fail(new ImagePreloadError({ url }))), { + once: true, + }); + image.src = url; + return Effect.sync(() => { + image.src = ""; + }); + }); + +/** + * Loads one handoff's server previews, keyed by `[serverUrls, blobUrls]`. Once they have all + * loaded the blobs are no longer shown, so they are revoked here, once per load. A failed load + * keeps the handoff, and its blobs, until the thread is left. + */ +const handoffPreloadAtom = Atom.family((key: string) => { + const [serverUrls, blobUrls] = JSON.parse(key) as [string[], string[]]; + return Atom.make( + typeof Image === "undefined" + ? Effect.never + : Effect.forEach(serverUrls, preloadImage, { concurrency: "unbounded", discard: true }).pipe( + Effect.tap(() => + Effect.sync(() => { + for (const blobUrl of blobUrls) URL.revokeObjectURL(blobUrl); + }), + ), + ), + ); +}); + +/** + * The message ids, among `[messageId, serverUrls, blobUrls]` entries, whose server previews have + * loaded. Each handoff preloads in its own atom, so a change to the set does not restart the rest. + */ +export const loadedHandoffMessageIdsAtom = Atom.family((key: string) => { + const preloads = JSON.parse(key) as Array<[MessageId, string[], string[]]>; + return Atom.make((get) => + preloads.flatMap(([messageId, serverUrls, blobUrls]) => + AsyncResult.isSuccess(get(handoffPreloadAtom(JSON.stringify([serverUrls, blobUrls])))) + ? [messageId] + : [], + ), + ); +}); diff --git a/apps/web/src/components/chat/pasteToFocusComposer.ts b/apps/web/src/components/chat/pasteToFocusComposer.ts new file mode 100644 index 0000000000000..e0b75720cf8ba --- /dev/null +++ b/apps/web/src/components/chat/pasteToFocusComposer.ts @@ -0,0 +1,48 @@ +import { pasteTextToFocusComposer, shouldRedirectInputToComposer } from "./composerFocusRedirect"; +import { readPastedComposerContext } from "../composerInlineTokenPaste"; +import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; +import { isMacPlatform } from "../../lib/utils"; +import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { getTerminalFocusOwner } from "../../lib/terminalFocus"; +import type { ComposerHandleRef } from "../../composerHandleContext"; + +/** + * Paste-to-focus: the resting composer blurs on a click into the timeline, so a paste that + * follows has no editable target and would be dropped. This sends it to the composer instead. + * Returns the cleanup, for the chat root's ref callback, which only mounts with an active thread. + */ +export function attachPasteToFocusComposer(composerRef: ComposerHandleRef): () => void { + let pasteAsTextShortcutUntil = 0; + const keyHandler = (event: KeyboardEvent) => { + if ( + shouldRedirectInputToComposer(event) && + isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) + ) { + pasteAsTextShortcutUntil = Date.now() + 1_000; + } + }; + const handler = (event: ClipboardEvent) => { + if (isCommandPaletteOpen()) return; + if (getTerminalFocusOwner() !== null) return; + if (composerRef.current?.isModelPickerOpen()) return; + const text = pasteTextToFocusComposer(event); + const clipboardData = event.clipboardData; + if (text === null || clipboardData === null) return; + const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntil; + pasteAsTextShortcutUntil = 0; + if ( + ((readPastedComposerContext(clipboardData)?.records.length ?? 0) === 0 && + composerRef.current?.pasteTextAtEnd(text, { bypassAutoAttachment })) || + composerRef.current?.insertTextAtEnd(text, { clipboardData }) + ) { + event.preventDefault(); + event.stopPropagation(); + } + }; + window.addEventListener("keydown", keyHandler, true); + window.addEventListener("paste", handler, true); + return () => { + window.removeEventListener("keydown", keyHandler, true); + window.removeEventListener("paste", handler, true); + }; +} diff --git a/apps/web/src/components/chat/threadFindActionBus.ts b/apps/web/src/components/chat/threadFindActionBus.ts deleted file mode 100644 index 9230e5fbd69f8..0000000000000 --- a/apps/web/src/components/chat/threadFindActionBus.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { OrchestrationThreadSearchMatch } from "@t3tools/contracts"; -import { - findSearchTokenRanges, - messagePhrase, - messageSearchTokens, -} from "@t3tools/shared/paletteSearch"; -import type { ThreadFindStart } from "./threadFind"; - -const THREAD_FIND_OPEN_EVENT = "t3:thread-find-open"; - -export function requestThreadFindOpen(): void { - window.dispatchEvent(new Event(THREAD_FIND_OPEN_EVENT)); -} - -export function subscribeThreadFindOpen(listener: () => void): () => void { - window.addEventListener(THREAD_FIND_OPEN_EVENT, listener); - return () => window.removeEventListener(THREAD_FIND_OPEN_EVENT, listener); -} - -const THREAD_FIND_QUERY_EVENT = "t3:thread-find-query"; -interface PendingFind { - readonly query: string; - /** Where find starts, such as the message a palette result came from. */ - readonly start?: ThreadFindStart; -} -// A find a palette message result asked for, until that thread's view takes -// it. One the view never took (the navigation failed) lets go after a while, -// so a later visit does not open an old find. -const PENDING_FIND_MS = 30_000; -let pendingFind: (PendingFind & { readonly threadKey: string; readonly at: number }) | null = null; - -/** Opens find on `query` in the thread's view, now or once it mounts. */ -export function requestThreadFindQuery(threadKey: string, find: PendingFind): void { - pendingFind = { threadKey, at: Date.now(), ...find }; - window.dispatchEvent(new Event(THREAD_FIND_QUERY_EVENT)); -} - -/** The find waiting for this thread's view; answers once. */ -export function takeThreadFindQuery(threadKey: string): PendingFind | null { - if (pendingFind?.threadKey !== threadKey) return null; - const { at, threadKey: _threadKey, ...find } = pendingFind; - pendingFind = null; - return Date.now() - at < PENDING_FIND_MS ? find : null; -} - -/** - * The find a message search result opens, at the matched message: the whole - * query when the snippet holds it as typed ("vp run dev"), else its longest - * word, as the most telling ("sidebar" in "fix sidebar"), written as the - * snippet writes it ("Café" for "cafe", since find matches accents). - */ -export function findForMessageMatch( - match: Pick, - query: string, -): PendingFind { - const start = match.messageId ? { start: { entryId: match.messageId, occurrence: 0 } } : {}; - // Apostrophes are straight in the query; straightening keeps offsets. - const together = messagePhrase(query)?.exec(match.snippet.replace(/[\u2018\u2019]/g, "'")); - if (together) { - return { - query: match.snippet.slice(together.index, together.index + together[0].length), - ...start, - }; - } - const word = - messageSearchTokens(query).long.reduce( - (longest, token) => (token.length > longest.length ? token : longest), - "", - ) || query; - const ranges = findSearchTokenRanges(match.snippet, word); - // A whole word first, as search ranked it: "Café", not "Cafe" in "Cafeteria". - const shown = - ranges.find((range) => !/[\p{L}\p{N}]/u.test(match.snippet[range.end] ?? "")) ?? ranges[0]; - return { query: shown ? match.snippet.slice(shown.start, shown.end) : word, ...start }; -} - -export function subscribeThreadFindQuery(listener: () => void): () => void { - window.addEventListener(THREAD_FIND_QUERY_EVENT, listener); - return () => window.removeEventListener(THREAD_FIND_QUERY_EVENT, listener); -} diff --git a/apps/web/src/components/chat/useAssistantCitationTarget.browser.test.tsx b/apps/web/src/components/chat/useAssistantCitationTarget.browser.test.tsx new file mode 100644 index 0000000000000..2e716631c8f11 --- /dev/null +++ b/apps/web/src/components/chat/useAssistantCitationTarget.browser.test.tsx @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; +import { render } from "../../../test/render"; +import type { AssistantCitationRequest } from "./AssistantCitationSource"; +import { useAssistantCitationTarget } from "./useAssistantCitationTarget"; + +const request = { + key: "activation-k", + citation: { messageId: "cited" }, +} as unknown as AssistantCitationRequest; + +function Harness({ request }: { request: AssistantCitationRequest | null }) { + const citation = useAssistantCitationTarget({ + request, + phase: request ? { _tag: "ready", rowId: "cited-row" } : null, + onManualNavigation: () => {}, + }); + return ( +
    + + + {String(citation.positioning)} + {citation.target?.key ?? "none"} +
    + ); +} + +describe("useAssistantCitationTarget", () => { + it("starts a restored activation key fresh after the citation was left", async () => { + await using screen = await render(, { + // The hook reads no route state. + router: false, + }); + await screen.getByRole("button", { name: "list loaded" }).click(); + await expect.element(screen.getByLabelText("target")).toHaveTextContent("activation-k"); + await screen.getByRole("button", { name: "complete" }).click(); + await expect.element(screen.getByLabelText("positioning")).toHaveTextContent("false"); + + await screen.rerender(); + await screen.rerender(); + + await expect.element(screen.getByLabelText("positioning")).toHaveTextContent("true"); + await expect.element(screen.getByLabelText("target")).toHaveTextContent("activation-k"); + }); +}); diff --git a/apps/web/src/components/chat/useAssistantCitationTarget.ts b/apps/web/src/components/chat/useAssistantCitationTarget.ts index 5b6c056eada63..9c8a88f887997 100644 --- a/apps/web/src/components/chat/useAssistantCitationTarget.ts +++ b/apps/web/src/components/chat/useAssistantCitationTarget.ts @@ -1,198 +1,138 @@ -import type { LegendListRef } from "@legendapp/list/react"; -import type { RunId } from "@t3tools/contracts"; -import { useEffect, useRef, useState, type RefObject } from "react"; -import type { TimelineEntry } from "../../session-logic"; -import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; +import { useState } from "react"; import type { AssistantCitationRequest, AssistantCitationTarget } from "./AssistantCitationSource"; -import { toastManager } from "../ui/toast"; +import type { CitationPhase } from "./citationProcedure"; +import { registerCommands } from "../../shortcuts/dispatcher"; -export interface CitationHistoryPage { - readonly loading: boolean; - readonly cursor?: string | null; - readonly onLoadEarlier: () => void; +const CITATION_SCROLL_KEYS = new Set([ + "ArrowUp", + "ArrowDown", + "PageUp", + "PageDown", + "Home", + "End", + " ", +]); + +/** One activation of a citation; virtual row remounts and listeners share it. */ +interface CitationNavigation { + readonly target: AssistantCitationTarget; + /** + * Attaches to the citation viewport (it includes the minimap) for this + * activation. Owned by the activation so the viewport's ref callback only + * re-attaches when the activation changes. + */ + readonly attachDismissal: (citationViewport: HTMLElement) => () => void; +} + +interface CitationSettlement { + /** Per activation, not per key: Back/Forward can restore an earlier key. */ + readonly target: AssistantCitationTarget; + /** Dismissed citations also drop their highlight; completed ones keep it. */ + readonly dismissed: boolean; } export function useAssistantCitationTarget({ request, - entries, - rows, - listRef, - viewport, - historyLoading, - loadEarlier, - getTurnFoldRunId, - onExpandTurn, + phase, onManualNavigation, }: { request: AssistantCitationRequest | null; - entries: ReadonlyArray; - rows: ReadonlyArray; - listRef: RefObject; - viewport: HTMLElement | null; - historyLoading: boolean; - loadEarlier: CitationHistoryPage | null; - getTurnFoldRunId: (entryId: string) => RunId | undefined; - onExpandTurn: (runId: RunId) => void; + phase: CitationPhase | null; onManualNavigation: () => void; }) { - const [ready, setReady] = useState(null); - const [finishedKey, setFinishedKey] = useState(null); + const [settlement, setSettlement] = useState(null); const [listLoaded, setListLoaded] = useState(false); const onListLoad = () => setListLoaded(true); - const navigationRef = useRef<{ - target: AssistantCitationTarget; - requestedPages: Set; - done: boolean; - } | null>(null); - if (ready !== null && ready.key !== request?.key) { - setReady(null); + // Rows and listeners mutate the activation, so it lives as long as its + // request; a target belongs to one request, so returning to an earlier key + // never reuses it. + const [navigation, setNavigation] = useState(null); + let current = navigation; + if ((request?.key ?? null) !== (navigation?.target.key ?? null)) { + current = request ? createCitationNavigation(request) : null; + setNavigation(current); } - if (!request && finishedKey !== null) { - setFinishedKey(null); - } - - useEffect(() => { - if (navigationRef.current && navigationRef.current.target.key !== request?.key) { - navigationRef.current.target.activationRef.current.dismissed = true; - navigationRef.current.target.activationRef.current.cancelScroll?.(); - } - if (!request) { - navigationRef.current = null; - return; - } - if (navigationRef.current?.target.key !== request.key) { - const target: AssistantCitationTarget = { - ...request, - activationRef: { current: { scrolled: false, dismissed: false } }, - onComplete: () => { - if (navigationRef.current?.target !== target) return; - navigationRef.current.done = true; - // ChatView's thread-open effect can run after our initial opt-out. - onManualNavigation(); - setFinishedKey(target.key); - }, - }; - navigationRef.current = { - target, - requestedPages: new Set(), - done: false, - }; - onManualNavigation(); - } - if (!viewport || historyLoading) return; - const navigation = navigationRef.current; - if (navigation.done || navigation.target.activationRef.current.dismissed) return; - const fail = (title: string, description: string) => { - navigation.done = true; - setFinishedKey(navigation.target.key); - toastManager.add({ type: "warning", title, description }); + function createCitationNavigation(next: AssistantCitationRequest): CitationNavigation { + let done = false; + let tookOver = false; + const activationRef: AssistantCitationTarget["activationRef"] = { + current: { scrolled: false, dismissed: false }, }; - const source = entries.find( - (entry) => - entry.kind === "message" && entry.message.id === navigation.target.citation.messageId, - ); - if (!source) { - if (loadEarlier) { - if (loadEarlier.loading) return; - const cursor = loadEarlier.cursor ?? entries[0]?.id ?? "first"; - if (navigation.requestedPages.has(cursor) || navigation.requestedPages.size >= 20) { - fail( - "Could not load the cited response", - "Load earlier turns, then click the citation to try again. Your saved quote is unchanged.", - ); - return; - } - navigation.requestedPages.add(cursor); - loadEarlier.onLoadEarlier(); - return; - } - fail( - "The cited response is unavailable", - "It may have been removed. The selected text is still saved in your citation.", - ); - return; - } - if (source.kind !== "message" || source.message.role !== "assistant") { - fail( - "The citation does not refer to an assistant response", - "The selected text is still saved in your citation.", - ); - return; - } - const index = rows.findIndex( - (row) => row.kind === "message" && row.message.id === navigation.target.citation.messageId, - ); - if (index < 0) { - const foldRunId = getTurnFoldRunId(source.id); - if (foldRunId) onExpandTurn(foldRunId); - return; - } - if (listLoaded && listRef.current) setReady(navigation.target); - }, [ - entries, - getTurnFoldRunId, - historyLoading, - listLoaded, - listRef, - loadEarlier, - onExpandTurn, - onManualNavigation, - request, - rows, - viewport, - ]); - - useEffect(() => { - if (!request) return; - const dismiss = (onlyPending: boolean) => { - const navigation = navigationRef.current; - if (!navigation || navigation.target.key !== request.key) return; - const activation = navigation.target.activationRef.current; - if (activation.dismissed || (onlyPending && (activation.scrolled || navigation.done))) return; - activation.dismissed = true; - activation.cancelScroll?.(); - if (!activation.scrolled && !navigation.done) onManualNavigation(); - navigation.done = true; - setReady(null); - setFinishedKey(request.key); + const target: AssistantCitationTarget = { + ...next, + activationRef, + onComplete: () => { + done = true; + // The thread-open follow can run after the scroll started. + onManualNavigation(); + setSettlement({ target, dismissed: false }); + }, }; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - dismiss(false); - } else if ( - viewport?.contains(event.target as Node) && - ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key) - ) { - dismiss(true); + const attachDismissal = (citationViewport: HTMLElement) => { + // The follow must yield before the citation row starts scrolling, and the + // release travels through the viewport, so it happens once per activation + // when the activation first meets a mounted viewport. + if (!tookOver && !done) { + tookOver = true; + onManualNavigation(); } + // User navigation stops a pending citation scroll; Escape dismisses it outright. + const dismiss = (onlyPending: boolean) => { + const activation = activationRef.current; + if (activation.dismissed || (onlyPending && (activation.scrolled || done))) return; + activation.dismissed = true; + activation.cancelScroll?.(); + if (!activation.scrolled && !done) onManualNavigation(); + done = true; + setSettlement({ target, dismissed: true }); + }; + const onNavigation = () => dismiss(true); + const onWheel = (event: WheelEvent) => { + if (event.deltaY !== 0) dismiss(true); + }; + const stopKeys = registerCommands( + {}, + { + observe: (event) => { + if (event.key === "Escape") { + dismiss(false); + } else if ( + CITATION_SCROLL_KEYS.has(event.key) && + event.target instanceof Node && + citationViewport.contains(event.target) + ) { + dismiss(true); + } + }, + }, + ); + document.addEventListener("pointerdown", onNavigation, true); + citationViewport.addEventListener("wheel", onWheel, { passive: true }); + citationViewport.addEventListener("touchmove", onNavigation, { passive: true }); + return () => { + stopKeys(); + document.removeEventListener("pointerdown", onNavigation, true); + citationViewport.removeEventListener("wheel", onWheel); + citationViewport.removeEventListener("touchmove", onNavigation); + }; }; - const onWheel = (event: WheelEvent) => { - if (event.deltaY !== 0) dismiss(true); - }; - const onNavigation = () => dismiss(true); - document.addEventListener("keydown", onKeyDown); - document.addEventListener("pointerdown", onNavigation, true); - viewport?.addEventListener("wheel", onWheel, { passive: true }); - viewport?.addEventListener("touchmove", onNavigation, { passive: true }); - return () => { - document.removeEventListener("keydown", onKeyDown); - document.removeEventListener("pointerdown", onNavigation, true); - viewport?.removeEventListener("wheel", onWheel); - viewport?.removeEventListener("touchmove", onNavigation); - }; - }, [onManualNavigation, request, viewport]); + return { target, attachDismissal }; + } + + const settled = current !== null && settlement?.target === current.target ? settlement : null; + const target = + current !== null && phase?._tag === "ready" && listLoaded && settled?.dismissed !== true + ? current.target + : null; + const positioning = current !== null && phase?._tag !== "failed" && settled === null; - const target = ready?.key === request?.key ? ready : null; - const positioning = request !== null && finishedKey !== request.key; - const sourceRow = - target && positioning - ? rows.find((row) => row.kind === "message" && row.message.id === target.citation.messageId) - : undefined; return { target, positioning, onListLoad, - alwaysRender: sourceRow ? { keys: [sourceRow.id] } : undefined, + attachDismissal: current?.attachDismissal ?? null, + alwaysRender: + target && positioning && phase?._tag === "ready" ? { keys: [phase.rowId] } : undefined, }; } diff --git a/apps/web/src/components/chat/useAttachmentPreviewHandoff.ts b/apps/web/src/components/chat/useAttachmentPreviewHandoff.ts index 4556a3180c286..c42047f2f4843 100644 --- a/apps/web/src/components/chat/useAttachmentPreviewHandoff.ts +++ b/apps/web/src/components/chat/useAttachmentPreviewHandoff.ts @@ -1,106 +1,53 @@ -import { useStableCallback } from "../../hooks/useStableCallback"; -import { type EnvironmentId, type MessageId } from "@t3tools/contracts"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { type EnvironmentId, type MessageId, type ThreadId } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { type Dispatch, type SetStateAction, useEffect } from "react"; import { selectHandoffImageResources } from "../../session-logic"; -import { type ChatMessage } from "../../types"; import type { useQuietThreadVisibleTurnItems } from "../../state/entities"; -import { revokeBlobPreviewUrl, revokeUserMessagePreviewUrls } from "../ChatView.logic"; import { useAssetUrls } from "../../assets/assetUrls"; +import { + dropOptimisticHandoff, + loadedHandoffMessageIdsAtom, + type OptimisticBlobPreviews, + type OptimisticUserMessages, +} from "./optimisticUserMessages"; + interface AttachmentPreviewHandoffInput { readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; readonly serverVisibleTurnItems: ReturnType; - readonly optimisticUserMessages: ReadonlyArray; + /** Blob previews of acknowledged messages, by message id; they stand in for the server's copy. */ + readonly handoff: OptimisticUserMessages["handoff"]; + readonly blobPreviews: OptimisticBlobPreviews; + readonly setOptimisticUserMessages: Dispatch>; } /** * A sent image keeps showing its local blob preview until the server's copy has loaded, so the - * timeline does not flash an empty frame. Returns the attachment URLs the timeline renders and - * the handoff for a message whose optimistic row was replaced by the server's. + * timeline does not flash an empty frame. The handoff ends, and its blobs are revoked, once the + * server previews load. */ export function useAttachmentPreviewHandoff(input: AttachmentPreviewHandoffInput) { - const { environmentId, serverVisibleTurnItems, optimisticUserMessages } = input; + const { + environmentId, + threadId, + serverVisibleTurnItems, + blobPreviews, + setOptimisticUserMessages, + } = input; + const handoffByMessageId = input.handoff; - const optimisticUserMessagesRef = useRef(optimisticUserMessages); - useLayoutEffect(() => { - optimisticUserMessagesRef.current = optimisticUserMessages; - }); - const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< - Record - >({}); - const attachmentPreviewHandoffByMessageIdRef = useRef>({}); - const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); - useEffect(() => { - attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; - }, [attachmentPreviewHandoffByMessageId]); - const clearAttachmentPreviewHandoff = ( - messageId: MessageId, - previewUrls?: ReadonlyArray, - ) => { - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - const currentPreviewUrls = - previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - setAttachmentPreviewHandoffByMessageId((existing) => { - if (!(messageId in existing)) { - return existing; - } - const next = { ...existing }; - delete next[messageId]; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - for (const previewUrl of currentPreviewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - }; - const clearAttachmentPreviewHandoffStable = useStableCallback(clearAttachmentPreviewHandoff); - const clearAttachmentPreviewHandoffs = () => { - attachmentPreviewPromotionInFlightByMessageIdRef.current = {}; - for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { - for (const previewUrl of previewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - } - attachmentPreviewHandoffByMessageIdRef.current = {}; - setAttachmentPreviewHandoffByMessageId({}); - }; - const clearAttachmentPreviewHandoffsStable = useStableCallback(clearAttachmentPreviewHandoffs); - useEffect(() => { - return () => { - clearAttachmentPreviewHandoffsStable(); - for (const message of optimisticUserMessagesRef.current) { - revokeUserMessagePreviewUrls(message); - } - }; - }, [clearAttachmentPreviewHandoffsStable]); - const handoffAttachmentPreviews = (messageId: MessageId, previewUrls: string[]) => { - if (previewUrls.length === 0) return; + // Blob URLs are a browser resource owned per thread; leaving the thread or the view ends them. + useEffect(() => () => blobPreviews.revokeThread(threadId), [blobPreviews, threadId]); - const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - const nextPreviewUrlSet = new Set(previewUrls); - for (const previewUrl of previousPreviewUrls) { - if (!nextPreviewUrlSet.has(previewUrl)) { - revokeBlobPreviewUrl(previewUrl); - } - } - setAttachmentPreviewHandoffByMessageId((existing) => { - const next = { - ...existing, - [messageId]: previewUrls, - }; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - }; - const handoffAttachmentPreviewsStable = useStableCallback(handoffAttachmentPreviews); const serverAttachmentResources = selectHandoffImageResources( - Object.keys(attachmentPreviewHandoffByMessageId).length === 0 + Object.keys(handoffByMessageId).length === 0 ? undefined : serverVisibleTurnItems.flatMap(({ item }) => item.type === "user_message" ? [{ id: item.messageId, role: "user" as const, attachments: item.attachments }] : [], ), - attachmentPreviewHandoffByMessageId, + handoffByMessageId, ); const serverAttachmentUrls = useAssetUrls(environmentId, serverAttachmentResources); const serverAttachmentUrlById = new Map( @@ -109,104 +56,47 @@ export function useAttachmentPreviewHandoff(input: AttachmentPreviewHandoffInput return url ? [[resource.attachmentId, url] as const] : []; }), ); - useEffect(() => { - if (typeof Image === "undefined" || serverVisibleTurnItems.length === 0) { - return; - } - const cleanups: Array<() => void> = []; - const userMessagesById = new Map( - serverVisibleTurnItems.flatMap((row) => - row.item.type === "user_message" ? [[String(row.item.messageId), row.item] as const] : [], + // Each handoff whose server previews are all minted, as [messageId, serverUrls, blobUrls]. + const pendingPreloads: Array, ReadonlyArray]> = + []; + for (const row of serverVisibleTurnItems) { + if (row.item.type !== "user_message") continue; + const handoffUrls = handoffByMessageId[row.item.messageId]; + if (handoffUrls === undefined || row.item.attachments.length === 0) continue; + const serverPreviewUrls = row.item.attachments.flatMap((attachment) => { + if (attachment.type !== "image") return []; + const url = serverAttachmentUrlById.get(attachment.id); + return url === undefined ? [] : [url]; + }); + if ( + serverPreviewUrls.length === 0 || + serverPreviewUrls.length !== handoffUrls.length || + serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) + ) { + continue; + } + pendingPreloads.push([row.item.messageId, serverPreviewUrls, handoffUrls]); + } + const loadedHandoffMessageIds = useAtomValue( + loadedHandoffMessageIdsAtom(JSON.stringify(pendingPreloads)), + ); + // The handoff is state of the rendering ChatView, so dropping a loaded one here is React's + // adjust-state-while-rendering pattern; the preload atom has already revoked its blobs. + if (loadedHandoffMessageIds.length > 0) { + setOptimisticUserMessages((state) => + loadedHandoffMessageIds.reduce( + (next, messageId) => dropOptimisticHandoff(next, threadId, messageId), + state, ), ); + } - for (const [messageId, handoffPreviewUrls] of Object.entries( - attachmentPreviewHandoffByMessageId, - )) { - if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) { - continue; - } - - const serverMessage = userMessagesById.get(messageId); - if (serverMessage === undefined || serverMessage.attachments.length === 0) { - continue; - } - - const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => - attachment.type === "image" - ? [serverAttachmentUrlById.get(attachment.id)].filter( - (previewUrl): previewUrl is string => previewUrl !== undefined, - ) - : [], - ); - if ( - serverPreviewUrls.length === 0 || - serverPreviewUrls.length !== handoffPreviewUrls.length || - serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) - ) { - continue; - } - - attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId] = true; - - let cancelled = false; - const imageInstances: HTMLImageElement[] = []; - - const preloadServerPreviews = Promise.all( - serverPreviewUrls.map( - (previewUrl) => - new Promise((resolve, reject) => { - const image = new Image(); - imageInstances.push(image); - const handleLoad = () => resolve(); - const handleError = () => - reject(new Error(`Failed to load server preview for ${messageId}.`)); - image.addEventListener("load", handleLoad, { once: true }); - image.addEventListener("error", handleError, { once: true }); - image.src = previewUrl; - }), - ), - ); - - void preloadServerPreviews - .then(() => { - if (cancelled) { - return; - } - clearAttachmentPreviewHandoffStable(messageId as MessageId, handoffPreviewUrls); - }) - .catch(() => { - if (!cancelled) { - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - } - }); - - cleanups.push(() => { - cancelled = true; - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - for (const image of imageInstances) { - image.src = ""; - } - }); - } - - return () => { - for (const cleanup of cleanups) { - cleanup(); - } - }; - }, [ - attachmentPreviewHandoffByMessageId, - clearAttachmentPreviewHandoffStable, - serverAttachmentUrlById, - serverVisibleTurnItems, - ]); const timelineAttachmentUrlById = (() => { const urls = new Map(serverAttachmentUrlById); for (const row of serverVisibleTurnItems) { if (row.item.type !== "user_message") continue; - const handoffUrls = attachmentPreviewHandoffByMessageId[row.item.messageId]; + const handoffUrls = handoffByMessageId[row.item.messageId]; if (handoffUrls === undefined) continue; let imageIndex = 0; for (const attachment of row.item.attachments) { @@ -218,8 +108,5 @@ export function useAttachmentPreviewHandoff(input: AttachmentPreviewHandoffInput } return urls; })(); - return { - handoffAttachmentPreviewsStable, - timelineAttachmentUrlById, - }; + return { timelineAttachmentUrlById }; } diff --git a/apps/web/src/components/chat/useChatTerminalActions.ts b/apps/web/src/components/chat/useChatTerminalActions.ts index 02b6fcd9851c8..bba2c27e5868a 100644 --- a/apps/web/src/components/chat/useChatTerminalActions.ts +++ b/apps/web/src/components/chat/useChatTerminalActions.ts @@ -31,6 +31,11 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { confirmTerminalClose } from "../../lib/terminalCloseConfirm"; import { findReusableProjectScriptTerminal } from "../../lib/projectScriptTerminal"; import { type TerminalContextSelection } from "../../lib/terminalContext"; +import { + focusActiveDrawerTerminal, + focusActivePanelTerminal, + focusTerminal, +} from "../../lib/terminalFocus"; import { useTerminalUiStateStore } from "../../terminalUiStateStore"; import { LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -66,7 +71,6 @@ interface ChatTerminalActionsInput { readonly panelTerminalIds: ReadonlySet; readonly canReuseTerminal: boolean; readonly gitCwd: string | null; - readonly setTerminalFocusRequestId: Dispatch>; readonly setTerminalUiLaunchContext: Dispatch>; readonly setThreadError: ReturnType; } @@ -91,7 +95,6 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { panelTerminalIds, canReuseTerminal, gitCwd, - setTerminalFocusRequestId, setTerminalUiLaunchContext, setThreadError, } = input; @@ -130,6 +133,8 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { const setTerminalOpen = (open: boolean) => { if (!activeThreadRef) return; storeSetTerminalOpen(activeThreadRef, open); + focusActiveDrawerTerminal(activeThreadRef); + if (!open) composerRef.current?.focusAtEnd(); }; const setTerminalOpenStable = useStableCallback(setTerminalOpen); const toggleTerminalVisibility = () => { @@ -149,11 +154,13 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { activeServerOrderedTerminalIds.find((id) => !panelTerminalIds.has(id)); if (existingTerminalId !== undefined) { storeEnsureTerminal(activeThreadRef, existingTerminalId, { open: true, active: true }); + focusActiveDrawerTerminal(activeThreadRef); return; } void terminalActions.launchTerminal({ place: (terminalId) => storeEnsureTerminal(activeThreadRef, terminalId, { open: true }), })?.result; + focusActiveDrawerTerminal(activeThreadRef); return; } setTerminalOpen(nextOpen); @@ -168,7 +175,7 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { : storeSplitTerminal(activeThreadRef, terminalId), }); if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); + focusTerminal(activeThreadRef, launched.terminalId); void launched.result; }; const splitTerminalStable = useStableCallback(splitTerminal); @@ -178,7 +185,7 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { place: (terminalId) => storeNewTerminal(activeThreadRef, terminalId), }); if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); + focusTerminal(activeThreadRef, launched.terminalId); void launched.result; }; const createNewTerminalStable = useStableCallback(createNewTerminal); @@ -186,7 +193,7 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { if (!hasTerminalWriteAccess() || !activeThreadRef) return; terminalActions.closeTerminalSession(terminalId); storeCloseTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); + focusActiveDrawerTerminal(activeThreadRef); }; const runProjectScript = async ( script: ProjectScript, @@ -232,7 +239,6 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { if (!activeThreadRef) { return; } - setTerminalFocusRequestId((value) => value + 1); const launched = terminalActions.launchTerminal({ // The drawer opens with the target already placed: opening it empty would claim @@ -249,6 +255,7 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { }); if (!launched) return; const targetTerminalId = launched.terminalId; + focusTerminal(activeThreadRef, targetTerminalId); const openResult = await launched.result; if (openResult._tag === "Failure") { if (!isAtomCommandInterrupted(openResult)) { @@ -318,7 +325,7 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId), }); if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); + focusTerminal(activeThreadRef, launched.terminalId); void launched.result; }; const addTerminalSurfaceStable = useStableCallback(addTerminalSurface); @@ -338,7 +345,7 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { .splitTerminal(activeThreadRef, surfaceId, terminalId, direction), }); if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); + focusTerminal(activeThreadRef, launched.terminalId); void launched.result; }; const splitPanelTerminalStable = useStableCallback(splitPanelTerminal); @@ -351,7 +358,7 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { useRightPanelStore .getState() .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); - setTerminalFocusRequestId((value) => value + 1); + focusTerminal(activeThreadRef, terminalId); }; const activatePanelTerminalStable = useStableCallback(activatePanelTerminal); const closePanelTerminal = (terminalId: string) => { @@ -366,7 +373,9 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { useRightPanelStore .getState() .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); - setTerminalFocusRequestId((value) => value + 1); + // Closing a surface's last terminal falls back to a neighbouring surface, + // which can be another terminal surface. + focusActivePanelTerminal(activeThreadRef); }; const closePanelTerminalStable = useStableCallback(closePanelTerminal); const requestCloseTerminal = (terminalId: string) => { diff --git a/apps/web/src/components/chat/useComposerAttachmentIntake.ts b/apps/web/src/components/chat/useComposerAttachmentIntake.ts index 63a865c4fc941..167f60026b757 100644 --- a/apps/web/src/components/chat/useComposerAttachmentIntake.ts +++ b/apps/web/src/components/chat/useComposerAttachmentIntake.ts @@ -13,6 +13,7 @@ import { import { type ComposerFileAttachment, type ComposerImageAttachment, + type ComposerThreadDraftState, type DraftId, composerFileMatchesReattachMarker, composerFileNeedsReattach, @@ -68,11 +69,10 @@ export function useComposerAttachmentIntake(input: { readonly questionAttachmentTarget: DraftId | null; readonly attachmentTargetKey: string; readonly fileStagingLimit: number | null; - readonly promptRef: ChatComposerProps["promptRef"]; - readonly composerImagesRef: ChatComposerProps["composerImagesRef"]; - readonly composerFilesRef: ChatComposerProps["composerFilesRef"]; + readonly readPrompt: () => string; + readonly readAttachmentDraft: () => Pick; readonly composerEditorRef: RefObject; - readonly isRevertingCheckpointRef: RefObject; + readonly isRevertingCheckpoint: () => boolean; readonly setThreadError: ChatComposerProps["setThreadError"]; readonly countReservedAttachments: () => number; readonly addComposerImage: (image: ComposerImageAttachment) => string[]; @@ -96,11 +96,10 @@ export function useComposerAttachmentIntake(input: { questionAttachmentTarget, attachmentTargetKey, fileStagingLimit, - promptRef, - composerImagesRef, - composerFilesRef, + readPrompt, + readAttachmentDraft, composerEditorRef, - isRevertingCheckpointRef, + isRevertingCheckpoint, setThreadError, countReservedAttachments, addComposerImage, @@ -122,7 +121,7 @@ export function useComposerAttachmentIntake(input: { readonly skipImageInlineChip?: boolean; }, ): Promise => { - if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return false; + if (!activeThreadId || files.length === 0 || isRevertingCheckpoint()) return false; if ( pendingUserInputs.length > 0 && (!supportsQuestionAttachments || @@ -145,7 +144,7 @@ export function useComposerAttachmentIntake(input: { isConnecting || pendingUserInputs.length > 0 || projectSelectionRequired || - stripInlineContextReferences(promptRef.current).trim().length > 0); + stripInlineContextReferences(readPrompt()).trim().length > 0); // Validation happens synchronously so concurrent pastes see each other: // accepted files reserve their attachment slots (as preparing images) @@ -154,7 +153,7 @@ export function useComposerAttachmentIntake(input: { // A pick that matches a needs-reattach marker replaces it in the draft, so // it must not consume a slot; a draft full of markers would otherwise hit // the capacity error before the replacement path could run. - const reattachMarkers = composerFilesRef.current.filter(composerFileNeedsReattach); + const reattachMarkers = readAttachmentDraft().files.filter(composerFileNeedsReattach); const replacedReattachMarkerIds = new Set(); const acceptedImages: File[] = []; const acceptedFiles: ComposerFileAttachment[] = []; @@ -338,8 +337,8 @@ export function useComposerAttachmentIntake(input: { changeQuestionAttachmentPreparation(questionAttachmentTarget, -1); return; } - const image = composerImagesRef.current.find((candidate) => candidate.id === imageId); - const referenced = collectInlineContextIds(promptRef.current).includes( + const image = readAttachmentDraft().images.find((candidate) => candidate.id === imageId); + const referenced = collectInlineContextIds(readPrompt()).includes( image ? imageContextReference(image).contextId : "", ); if (!referenced) { @@ -373,7 +372,7 @@ export function useComposerAttachmentIntake(input: { const hasAttachmentSlot = countReservedAttachments() < PROVIDER_SEND_TURN_MAX_ATTACHMENTS; const selection = selectionOverride ?? composerEditorRef.current?.readSelectionRange(); const wouldExceedInputLimit = wouldTextPasteExceedLimit({ - valueLength: promptRef.current.length, + valueLength: readPrompt().length, selection: selection ?? { start: 0, end: 0 }, textLength: plainText.length, maxLength: PROVIDER_SEND_TURN_MAX_INPUT_CHARS, @@ -390,10 +389,7 @@ export function useComposerAttachmentIntake(input: { } const canStageAttachment = - Boolean(activeThreadId) && - !isRevertingCheckpointRef.current && - questionCanAttach && - hasAttachmentSlot; + Boolean(activeThreadId) && !isRevertingCheckpoint() && questionCanAttach && hasAttachmentSlot; if (!canStageAttachment || fileStagingLimit === null) { if (!wouldExceedInputLimit) { return false; @@ -411,7 +407,7 @@ export function useComposerAttachmentIntake(input: { pastedTextFileNamesRef.current = { targetKey: attachmentTargetKey, names: new Set() }; } const reservedNames = pastedTextFileNamesRef.current.names; - for (const file of composerFilesRef.current) reservedNames.add(file.name); + for (const file of readAttachmentDraft().files) reservedNames.add(file.name); const foldedFileName = nextPastedTextFileName([...reservedNames]); reservedNames.add(foldedFileName); const foldedFile = new File([plainText], foldedFileName, { diff --git a/apps/web/src/components/chat/useComposerCommandKey.ts b/apps/web/src/components/chat/useComposerCommandKey.ts deleted file mode 100644 index c4166b4370530..0000000000000 --- a/apps/web/src/components/chat/useComposerCommandKey.ts +++ /dev/null @@ -1,264 +0,0 @@ -import type { ChatComposerProps } from "./ChatComposer"; -import { type ComposerCommandItem } from "./ComposerCommandMenu"; -import { - type ComposerDispatchMode, - resolveComposerDispatchMode, -} from "@t3tools/client-runtime/state/composer-dispatch"; -import { type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; -import { - type ComposerPromptHistoryPosition, - buildComposerPromptHistoryEntries, - stepComposerPromptHistory, -} from "./composerPromptHistory"; -import { - type ComposerSubmissionIntent, - type ComposerTrigger, - collapseExpandedComposerCursor, - composerSubmissionIntentForKey, -} from "../../composer-logic"; -import { type PendingUserInput } from "../../session-logic"; -import { type PreviewAnnotationPayload } from "@t3tools/contracts"; -import { type ReviewCommentContext } from "../../reviewCommentContext"; -import { composerTargetKey } from "../../composerDraftStore"; -import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation"; -import { type Dispatch, type RefObject, type SetStateAction, useLayoutEffect, useRef } from "react"; - -/** - * The composer's key handling inside the editor: send shortcuts, the slash/mention menu, - * prompt history on ArrowUp/ArrowDown, plan-mode toggle, and list continuation. - */ -export function useComposerCommandKey(input: { - readonly keybindings: ChatComposerProps["keybindings"]; - readonly sendShortcut: ChatComposerProps["settings"]["sendShortcut"]; - readonly routeKind: ChatComposerProps["routeKind"]; - readonly phase: ChatComposerProps["phase"]; - readonly isCompacting: boolean; - readonly followUpBehavior: ChatComposerProps["settings"]["followUpBehavior"]; - readonly isSoftwareKeyboardOpen: boolean; - readonly planModeUiEnabled: boolean; - readonly isLiteralPendingAnswer: boolean; - readonly pendingUserInputs: ReadonlyArray; - readonly promptHistoryMessages: ChatComposerProps["promptHistoryMessages"]; - readonly promptHistoryPositionRef: RefObject<{ - targetKey: string; - position: ComposerPromptHistoryPosition; - } | null>; - readonly composerDraftTarget: ChatComposerProps["composerDraftTarget"]; - readonly promptRef: ChatComposerProps["promptRef"]; - readonly composerImagesRef: ChatComposerProps["composerImagesRef"]; - readonly composerFilesRef: ChatComposerProps["composerFilesRef"]; - readonly composerTerminalContextsRef: ChatComposerProps["composerTerminalContextsRef"]; - readonly composerPreviewAnnotations: ReadonlyArray; - readonly composerReviewComments: ReadonlyArray; - readonly composerEditorRef: RefObject; - readonly composerMenuOpenRef: RefObject; - readonly composerMenuItemsRef: RefObject; - readonly activeComposerMenuItemRef: RefObject; - readonly setComposerDraftPrompt: ( - target: ChatComposerProps["composerDraftTarget"], - prompt: string, - ) => void; - readonly setComposerCursor: Dispatch>; - readonly setComposerTrigger: (trigger: ComposerTrigger | null) => void; - readonly setComposerHighlightedItemId: Dispatch>; - readonly dismissComposerTrigger: (trigger: ComposerTrigger | null) => void; - readonly toggleInteractionMode: () => void; - readonly nudgeComposerMenuHighlight: (key: "ArrowDown" | "ArrowUp") => void; - readonly onSelectComposerItem: (item: ComposerCommandItem) => void; - readonly resolveActiveComposerTrigger: () => { trigger: ComposerTrigger | null }; - readonly readComposerSnapshot: () => { value: string }; - readonly applyPromptReplacement: ( - rangeStart: number, - rangeEnd: number, - replacement: string, - options?: { expandedCursorAfterReplace?: number }, - ) => boolean; - readonly submitComposer: ( - event: undefined, - dispatchMode: ComposerDispatchMode, - submissionIntent: ComposerSubmissionIntent, - ) => void; -}) { - const { - keybindings, - sendShortcut, - routeKind, - phase, - isCompacting, - followUpBehavior, - isSoftwareKeyboardOpen, - planModeUiEnabled, - isLiteralPendingAnswer, - pendingUserInputs, - promptHistoryMessages, - promptHistoryPositionRef, - composerDraftTarget, - promptRef, - composerImagesRef, - composerFilesRef, - composerTerminalContextsRef, - composerPreviewAnnotations, - composerReviewComments, - composerEditorRef, - composerMenuOpenRef, - composerMenuItemsRef, - activeComposerMenuItemRef, - setComposerDraftPrompt, - setComposerCursor, - setComposerTrigger, - setComposerHighlightedItemId, - dismissComposerTrigger, - toggleInteractionMode, - nudgeComposerMenuHighlight, - onSelectComposerItem, - resolveActiveComposerTrigger, - readComposerSnapshot, - applyPromptReplacement, - submitComposer, - } = input; - // Entries are built on the keypress, not per render: the timeline changes - // on every streamed delta and ArrowUp is rare. - const promptHistoryMessagesRef = useRef(promptHistoryMessages); - useLayoutEffect(() => { - promptHistoryMessagesRef.current = promptHistoryMessages; - }); - - // The composer persists across threads. A recall from thread A must not - // be treated as active in thread B, where the text-match fallback could - // otherwise turn B's own draft into a browsing position. - const promptHistoryTargetKey = composerTargetKey(composerDraftTarget); - - const replacePromptFromHistory = (nextPrompt: string) => { - promptRef.current = nextPrompt; - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); - setComposerTrigger(null); - setComposerHighlightedItemId(null); - }; - - const navigatePromptHistory = ( - direction: "backward" | "forward", - event: KeyboardEvent, - ): boolean => { - if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { - return false; - } - if (pendingUserInputs.length > 0) return false; - if ( - composerImagesRef.current.length > 0 || - composerFilesRef.current.length > 0 || - composerTerminalContextsRef.current.length > 0 || - composerPreviewAnnotations.length > 0 || - composerReviewComments.length > 0 - ) { - return false; - } - const recalled = promptHistoryPositionRef.current; - const position = recalled?.targetKey === promptHistoryTargetKey ? recalled.position : null; - if (position === null && promptRef.current.length > 0) { - return false; - } - const editor = composerEditorRef.current; - if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) { - return false; - } - const step = stepComposerPromptHistory({ - direction, - entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), - position, - currentPrompt: promptRef.current, - }); - if (!step) return false; - promptHistoryPositionRef.current = step.position - ? { targetKey: promptHistoryTargetKey, position: step.position } - : null; - replacePromptFromHistory(step.prompt); - return true; - }; - - const onComposerCommandKey = (key: string, event: KeyboardEvent, isTaskItem = false) => { - const submissionIntent = composerSubmissionIntentForKey({ - event, - keybindings, - isSoftwareKeyboardOpen, - isDraftThread: routeKind === "draft", - isRunning: phase === "running", - sendShortcut, - prompt: promptRef.current, - }); - if (key === "Tab" && event.shiftKey && submissionIntent === null) { - if (!planModeUiEnabled) return false; - toggleInteractionMode(); - return true; - } - const { trigger } = resolveActiveComposerTrigger(); - const menuIsActive = - !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null); - if (key === "Escape") { - if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; - dismissComposerTrigger(trigger); - composerMenuOpenRef.current = false; - return true; - } - if (menuIsActive && (submissionIntent === null || submissionIntent === "foreground")) { - const currentItems = composerMenuItemsRef.current; - const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; - if (key === "ArrowDown" && currentItems.length > 0) { - nudgeComposerMenuHighlight("ArrowDown"); - return true; - } - if (key === "ArrowUp" && currentItems.length > 0) { - nudgeComposerMenuHighlight("ArrowUp"); - return true; - } - if ((key === "Enter" || key === "Tab") && selectedItem) { - onSelectComposerItem(selectedItem); - return true; - } - } - if ((key === "ArrowUp" || key === "ArrowDown") && submissionIntent === null) { - return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event); - } - if (submissionIntent) { - submitComposer( - undefined, - resolveComposerDispatchMode({ - running: phase === "running", - alternateModifier: submissionIntent === "alternate", - activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, - }), - submissionIntent, - ); - return true; - } - if (key === "Enter" && isTaskItem) return false; - if ( - !isLiteralPendingAnswer && - !event.isComposing && - (key === "Enter" || (key === "Tab" && !event.shiftKey)) - ) { - const selection = composerEditorRef.current?.readSelectionRange(); - const snapshot = readComposerSnapshot(); - if (selection && selection.start === selection.end && snapshot.value === promptRef.current) { - const edit = - key === "Enter" - ? listContinuationForEnter(snapshot.value, selection.start) - : listIndentForTab(snapshot.value, selection.start, selection.end); - if ( - edit && - applyPromptReplacement(edit.start, edit.end, edit.replacement, { - expandedCursorAfterReplace: edit.cursorAfter, - }) - ) { - return true; - } - } - } - return false; - }; - - return { - onComposerCommandKey, - }; -} diff --git a/apps/web/src/components/chat/useComposerContextImport.ts b/apps/web/src/components/chat/useComposerContextImport.ts index da38e753ecc92..626174285de57 100644 --- a/apps/web/src/components/chat/useComposerContextImport.ts +++ b/apps/web/src/components/chat/useComposerContextImport.ts @@ -69,12 +69,11 @@ export function useComposerContextImport(input: { readonly composerDraftTarget: ChatComposerProps["composerDraftTarget"]; readonly attachmentDraftTarget: ChatComposerProps["composerDraftTarget"]; readonly attachmentTargetKey: string; - readonly attachmentTargetKeyRef: RefObject; + readonly isAttachmentTargetDisplaced: (targetKey: string) => boolean; readonly questionAttachmentTarget: DraftId | null; readonly pendingUserInputs: ReadonlyArray; readonly composerContextRecords: ComposerDraftContextRecords; readonly composerImages: ReadonlyArray; - readonly composerFilesRef: ChatComposerProps["composerFilesRef"]; readonly pendingThreadImportsRef: RefObject>; readonly addComposerFilesToDraft: (files: ComposerFileAttachment[]) => string[]; }) { @@ -85,12 +84,11 @@ export function useComposerContextImport(input: { composerDraftTarget, attachmentDraftTarget, attachmentTargetKey, - attachmentTargetKeyRef, + isAttachmentTargetDisplaced, questionAttachmentTarget, pendingUserInputs, composerContextRecords, composerImages, - composerFilesRef, pendingThreadImportsRef, addComposerFilesToDraft, } = input; @@ -172,12 +170,12 @@ export function useComposerContextImport(input: { } file = new File([blob], record.name, { type: record.mimeType || blob.type }); } - // The draft these bytes belong to may have been sent or switched away from while they - // downloaded. Dropping them here keeps them out of whatever draft is open now. - if (attachmentTargetKeyRef.current !== importTargetKey) return; + // The composer may have switched to another draft while these bytes downloaded. Dropping + // them here keeps them out of whatever draft is open now. + if (isAttachmentTargetDisplaced(importTargetKey)) return; const replacesFileMarker = record.kind === "file" && - composerFilesRef.current.some( + (getComposerDraft(attachmentDraftTarget)?.files ?? []).some( (candidate) => composerFileNeedsReattach(candidate) && composerFileMatchesReattachMarker(candidate, { @@ -213,6 +211,7 @@ export function useComposerContextImport(input: { getComposerDraft(composerDraftTarget)?.previewAnnotations.some( (annotation) => annotation.id === localId, ) ?? false, + environmentId, }, ); if (!accepted.includes(localId)) diff --git a/apps/web/src/components/chat/useComposerMultilinePrompt.ts b/apps/web/src/components/chat/useComposerMultilinePrompt.ts index b5025b1becf9b..c09c371aa997a 100644 --- a/apps/web/src/components/chat/useComposerMultilinePrompt.ts +++ b/apps/web/src/components/chat/useComposerMultilinePrompt.ts @@ -1,4 +1,4 @@ -import { useLayoutEffect, useState } from "react"; +import { useState } from "react"; import { observeResize } from "../../lib/observeResize"; function measureComposerMultilinePrompt(body: HTMLElement): boolean | null { @@ -19,10 +19,13 @@ function measureComposerMultilinePrompt(body: HTMLElement): boolean | null { return bounds.height > lineHeight + 1 || bounds.width > expandedWidth + 1; } -export function useComposerMultilinePrompt(body: HTMLElement | null): boolean { +/** Whether the prompt wraps, and the ref callback that measures it on the composer body. */ +export function useComposerMultilinePrompt(): readonly [ + boolean, + (body: HTMLElement | null) => (() => void) | undefined, +] { const [isMultiline, setIsMultiline] = useState(false); - - useLayoutEffect(() => { + const observeBody = (body: HTMLElement | null) => { if (!body) return; const measure = () => { const next = measureComposerMultilinePrompt(body); @@ -37,7 +40,6 @@ export function useComposerMultilinePrompt(body: HTMLElement | null): boolean { stopObserving(); mutationObserver.disconnect(); }; - }, [body]); - - return isMultiline; + }; + return [isMultiline, observeBody] as const; } diff --git a/apps/web/src/components/chat/useComposerRestingTransition.ts b/apps/web/src/components/chat/useComposerRestingTransition.ts index 64b18b6778019..f96d5ca65b397 100644 --- a/apps/web/src/components/chat/useComposerRestingTransition.ts +++ b/apps/web/src/components/chat/useComposerRestingTransition.ts @@ -1,6 +1,6 @@ import { observeResize } from "~/lib/observeResize"; import { shouldAnimateComposerRestingTransition } from "../composerFooterLayout"; -import { useEffect, useLayoutEffect, useRef } from "react"; +import { useLayoutEffect, useRef } from "react"; const COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS = 50; const COMPOSER_RESTING_TRANSITION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; @@ -11,11 +11,12 @@ export function useComposerRestingTransition( isResting: boolean, restingControlsRef: React.RefObject, onOverlayHeightChange: (height: number) => void, + onOverlaySeriesStart: () => void, + onOverlaySeriesEnd: () => void, animationsActive: boolean, animationDurationMs: number, ) { - const elementRef = useRef(null); - const isCollapsedRef = useRef(isCollapsed); + const elementRef = useRef(null); const previousCollapsedRef = useRef(isCollapsed); const previousRestingRef = useRef(isResting); const previousHeightRef = useRef(null); @@ -90,16 +91,16 @@ export function useComposerRestingTransition( clearOverlayPin(); }; - useLayoutEffect(() => { - isCollapsedRef.current = isCollapsed; - }); + // Read from the committed DOM, so resize callbacks between renders see the layout they measure. + const isCollapsedInDom = (element: HTMLElement) => + element.dataset.composerControlsCollapsed === "true"; const transitionToCurrentGeometry = (stateChanged: boolean) => { const element = elementRef.current; const surface = element?.querySelector('[data-chat-composer-surface="true"]'); if (!element || !surface) return; - const nextIsCollapsed = isCollapsedRef.current; + const nextIsCollapsed = isCollapsedInDom(element); const visibleTransitionElement = (selector: string) => Array.from(element.querySelectorAll(selector)).find( @@ -421,6 +422,7 @@ export function useComposerRestingTransition( animationTargetHeightRef.current = null; } + hasCompletedInitialLayoutRef.current = true; previousCollapsedRef.current = nextIsCollapsed; previousHeightRef.current = nextHeight; previousModelStripHeightRef.current = nextModelStripHeight; @@ -452,6 +454,8 @@ export function useComposerRestingTransition( }; }, [isCollapsed, transitionToCurrentGeometry]); + // Resting also flips on a breakpoint crossing that keeps the controls + // collapsed, which runs no transition to republish the overlay. useLayoutEffect(() => { if (previousRestingRef.current === isResting) return; previousRestingRef.current = isResting; @@ -460,12 +464,13 @@ export function useComposerRestingTransition( if (overlay) onOverlayHeightChange(overlay.getBoundingClientRect().height); }, [isResting, onOverlayHeightChange]); - useLayoutEffect(() => { - const element = elementRef.current; - if (!element) return; - + return (element: HTMLDivElement) => { + elementRef.current = element; + // The overlay's held heights describe this composer; mounting or + // unmounting it starts a new measurement series. + onOverlaySeriesStart(); const body = element.querySelector('[data-chat-composer-body="true"]'); - return observeResize(body ? [element, body] : element, (entries) => { + const stopObserving = observeResize(body ? [element, body] : element, (entries) => { if (animationRef.current) { if (body && entries.some((entry) => entry.target === body)) { transitionToCurrentGeometry(false); @@ -484,7 +489,7 @@ export function useComposerRestingTransition( '[data-chat-composer-transition-actions="true"]', )?.getBoundingClientRect().top; const controlsRect = ( - isCollapsedRef.current + isCollapsedInDom(element) ? restingControlsRef.current : visibleTransitionElement('[data-chat-composer-controls="left"]') )?.getBoundingClientRect(); @@ -502,11 +507,8 @@ export function useComposerRestingTransition( ?.getBoundingClientRect().bottom ?? elementRect.bottom) - elementRect.bottom, }; }); - }, [restingControlsRef, transitionToCurrentGeometry]); - - useEffect(() => { - hasCompletedInitialLayoutRef.current = true; return () => { + stopObserving(); if (transitionCleanupTimeoutRef.current !== null) { window.clearTimeout(transitionCleanupTimeoutRef.current); transitionCleanupTimeoutRef.current = null; @@ -518,9 +520,10 @@ export function useComposerRestingTransition( contentAnimationsRef.current = []; for (const animation of stateChangeAnimationsRef.current) animation.cancel(); stateChangeAnimationsRef.current = []; + // The element is still attached here, so its styles and the overlay pin can be cleared. clearTransitionStyles(); + elementRef.current = null; + onOverlaySeriesEnd(); }; - }, [clearTransitionStyles]); - - return elementRef; + }; } diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 597cafb35e92f..784ead35c7ad2 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -5,7 +5,6 @@ import { restorePlanFollowUpComposer, buildExpiredTerminalContextToastCopy, deriveComposerSendState, - revokeUserMessagePreviewUrls, boundAttachmentIds, formatOutgoingPrompt, optimisticMessageAttachments, @@ -43,7 +42,7 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { createModelSelection } from "@t3tools/shared/model"; -import { type SetStateAction, useLayoutEffect, useRef } from "react"; +import { type SetStateAction, useRef } from "react"; import { flushSync } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { @@ -70,13 +69,13 @@ import { markPromotedDraftThreadByRef, restoreFailedDraftThread, composerDraftHasUserContent, - type ComposerFileAttachment, type ComposerImageAttachment, type DraftThreadEnvMode, useComposerDraftStore, DraftId, + selectEffectiveDraftBranch, } from "../../composerDraftStore"; -import { type TerminalContextDraft } from "../../lib/terminalContext"; +import { readLoadedDefaultRefName } from "../../state/queries"; import { ensureInlineContextReferences } from "../../lib/composerContextReferences"; import { buildMessageContext, @@ -134,7 +133,11 @@ import type { useLogicalProjectEnvironments } from "../../hooks/useLogicalProjec import type { LatestProposedPlanState } from "../../session-logic"; import type { useDraftEnvironmentRouting } from "./useDraftEnvironmentRouting"; import type { useLocalDispatchState } from "./useLocalDispatchState"; -import type { usePendingThreadRequests } from "./usePendingThreadRequests"; +import { + getPendingApprovals, + getRespondingApprovalIds, + type usePendingThreadRequests, +} from "./usePendingThreadRequests"; import type { useQueuedRunEdit } from "./useQueuedRunEdit"; import type { useTimelineFollow } from "./useTimelineFollow"; import type { useUsageLimitsPanel } from "./useUsageLimitsPanel"; @@ -168,6 +171,7 @@ interface ComposerSendInput { readonly activeThread: EnvironmentThreadShell | undefined; readonly activeThreadKey: string | null; readonly activeThreadBranch: string | null; + readonly gitStatus: { readonly refName: string | null } | null; readonly activeProject: EnvironmentProject | null; readonly activeProjectDefaultModelSelection: ModelSelection | null; readonly activeProposedPlan: LatestProposedPlanState | null; @@ -187,10 +191,6 @@ interface ComposerSendInput { readonly providerInstanceEntries: ReadonlyArray; readonly composerRef: ComposerHandleRef; readonly composerDraftTarget: ComposerThreadTarget; - readonly promptRef: { current: string }; - readonly composerImagesRef: { current: ComposerImageAttachment[] }; - readonly composerFilesRef: { current: ComposerFileAttachment[] }; - readonly composerTerminalContextsRef: { current: TerminalContextDraft[] }; readonly feedbackUploadsInFlightRef: { readonly current: Set }; readonly queuedRunEdit: ReturnType; readonly isConnecting: boolean; @@ -212,8 +212,6 @@ interface ComposerSendInput { readonly needsLoadBalancing: boolean; readonly loadBalancing: DraftEnvironmentRouting["loadBalancing"]; readonly readPendingEnvironmentChange: DraftEnvironmentRouting["readPendingEnvironmentChange"]; - readonly pendingApprovals: PendingThreadRequests["pendingApprovals"]; - readonly respondingRequestIds: PendingThreadRequests["respondingRequestIds"]; readonly activePendingProgress: PendingThreadRequests["activePendingProgress"]; readonly onAdvanceActivePendingUserInput: PendingThreadRequests["onAdvanceActivePendingUserInput"]; readonly onRespondToApproval: PendingThreadRequests["onRespondToApproval"]; @@ -224,6 +222,11 @@ interface ComposerSendInput { readonly setDockedDraftHeroThreadKey: Dispatch>; readonly setSetupQueueSendThreadKey: Dispatch>; readonly setOptimisticUserMessages: Dispatch>; + /** Takes ownership of, and later releases, an optimistic message's blob previews. */ + readonly optimisticBlobPreviews: { + readonly track: (message: ChatMessage) => void; + readonly release: (message: ChatMessage) => void; + }; readonly setFeedbackSubmissionsByThreadKey: Dispatch< SetStateAction>> >; @@ -258,6 +261,7 @@ export function useComposerSend(input: ComposerSendInput) { activeThread, activeThreadKey, activeThreadBranch, + gitStatus, activeProject, activeProjectDefaultModelSelection, activeProposedPlan, @@ -274,10 +278,6 @@ export function useComposerSend(input: ComposerSendInput) { providerInstanceEntries, composerRef, composerDraftTarget, - promptRef, - composerImagesRef, - composerFilesRef, - composerTerminalContextsRef, feedbackUploadsInFlightRef, queuedRunEdit, isConnecting, @@ -299,8 +299,6 @@ export function useComposerSend(input: ComposerSendInput) { needsLoadBalancing, loadBalancing, readPendingEnvironmentChange, - pendingApprovals, - respondingRequestIds, activePendingProgress, onAdvanceActivePendingUserInput, onRespondToApproval: onRespondToApprovalStable, @@ -311,6 +309,7 @@ export function useComposerSend(input: ComposerSendInput) { setDockedDraftHeroThreadKey, setSetupQueueSendThreadKey, setOptimisticUserMessages, + optimisticBlobPreviews, setFeedbackSubmissionsByThreadKey, setThreadError, setKeepFullHistory, @@ -347,7 +346,6 @@ export function useComposerSend(input: ComposerSendInput) { draftId, composerDraftTarget, composerRef, - promptRef, runtimeMode, startFromOrigin, resetLocalDispatch, @@ -375,14 +373,13 @@ export function useComposerSend(input: ComposerSendInput) { const { sendInFlight: sendInFlightRef } = draftSendState(routeThreadKey); const composerSendGenerationRef = useRef(0); const environmentUnavailableSendToastSlotRef = useRef(0); - // A send awaits uploads before it declines, so it reads the approvals pending by then. - const pendingApprovalsRef = useRef(pendingApprovals); - const respondingRequestIdsRef = useRef(respondingRequestIds); - useLayoutEffect(() => { - pendingApprovalsRef.current = pendingApprovals; - respondingRequestIdsRef.current = respondingRequestIds; - }, [pendingApprovals, respondingRequestIds]); + const sendBranchCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot ?? null; + // The composer reads what its editor shows; with no composer mounted, the draft is all there is. + const readPrompt = () => + composerRef.current?.readPrompt() ?? + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? + ""; const onSend = async ( e?: { preventDefault: () => void }, dispatchMode: ComposerDispatchMode = "auto", @@ -395,16 +392,15 @@ export function useComposerSend(input: ComposerSendInput) { e?.preventDefault(); // What the user pressed send on; uploads below can take a while, and a redirect must not // decline approvals for a draft that changed meanwhile. - const promptAtSend = promptRef.current; + const promptAtSend = readPrompt(); if ( usageLimitsOffered && usageLimitsKey !== null && !directAnnotation && !draftHasNonPromptContent(composerDraftTarget) && - isUsageLimitsCommand(promptRef.current) + isUsageLimitsCommand(readPrompt()) ) { if (openUsageLimits()) { - promptRef.current = ""; setComposerDraftPrompt(composerDraftTarget, ""); composerRef.current?.resetCursorState(); } @@ -478,7 +474,33 @@ export function useComposerSend(input: ComposerSendInput) { return; } const multipleModelSelections = sendCtx.multipleModelSelections; - const approvalFeedbackTarget = pendingApprovalsRef.current.find( + const baseBranchCandidate = + sendBranchCwd === null + ? null + : readLoadedDefaultRefName(activeThread.environmentId, sendBranchCwd); + // An empty server thread's unpicked worktree base is derived like a draft's + // and reaches the server through the send's prepareWorktree bootstrap. + const sendBranch = isLocalDraftThread + ? selectEffectiveDraftBranch( + (draftId ? useComposerDraftStore.getState().getDraftSession(draftId) : null) ?? + draftThread, + gitStatus, + baseBranchCandidate, + ) + : sendEnvMode === "worktree" && !activeThread.worktreePath + ? selectEffectiveDraftBranch( + { + branch: activeThreadBranch, + worktreePath: activeThread.worktreePath, + envMode: sendEnvMode, + }, + gitStatus, + baseBranchCandidate, + ) + : activeThreadBranch; + // A send awaits uploads before it declines, so it reads the approvals pending by then. + const approvalThreadId = activeThread.id; + const approvalFeedbackTarget = getPendingApprovals(environmentId, approvalThreadId).find( (approval) => approval.supportsFeedback && approval.requestId === sendCtx.shownPendingApprovalIds[0], ); @@ -492,7 +514,7 @@ export function useComposerSend(input: ComposerSendInput) { .capabilities.requiredWorktreeBootstrap === true, isLocalDraftThread, isGitRepo, - branch: activeThreadBranch, + branch: sendBranch, }); if (multipleModelBlock === "server-outdated") { setThreadError(activeThread.id, "Update this server before starting multiple models."); @@ -534,10 +556,10 @@ export function useComposerSend(input: ComposerSendInput) { // A direct "send annotation" writes the draft and sends in the same tick; the reference // must be in the text now, not after the next render. const promptForSend = directAnnotation - ? ensureInlineContextReferences(promptRef.current, [ + ? ensureInlineContextReferences(readPrompt(), [ previewAnnotationContextReference(directAnnotation.annotation), ]) - : promptRef.current; + : readPrompt(); const strandedThread = threadContextsOutsideEnvironment({ text: promptForSend, records: composerThreadContexts, @@ -616,7 +638,6 @@ export function useComposerSend(input: ComposerSendInput) { createdAt: new Date().toISOString(), }, clearDraft: () => { - promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); }, @@ -665,12 +686,11 @@ export function useComposerSend(input: ComposerSendInput) { if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) { return; } - const followUpPromptSnapshot = promptRef.current; + const followUpPromptSnapshot = readPrompt(); const followUpTerminalContexts = [...sendableComposerTerminalContexts]; const followUpReviewComments = [...composerReviewComments]; const followUpPreviewAnnotations = [...composerPreviewAnnotations]; const followUpThreadContexts = [...composerThreadContexts]; - promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); const followUpSent = await submitPlanFollowUp({ @@ -684,8 +704,6 @@ export function useComposerSend(input: ComposerSendInput) { interactionMode: followUp.interactionMode, }); if (!followUpSent) { - promptRef.current = followUpPromptSnapshot; - composerTerminalContextsRef.current = [...followUpTerminalContexts]; restorePlanFollowUpComposer({ snapshot: { prompt: followUpPromptSnapshot, @@ -725,7 +743,6 @@ export function useComposerSend(input: ComposerSendInput) { : null; if (standaloneSlashCommand && multipleModelSelections === null) { handleInteractionModeChange(standaloneSlashCommand); - promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); return; @@ -789,7 +806,7 @@ export function useComposerSend(input: ComposerSendInput) { activeMessageCount, sendEnvMode, worktreePath: activeThread.worktreePath, - activeThreadBranch, + activeThreadBranch: sendBranch, resumeCompactionTokens, keepFullHistory, phase, @@ -948,7 +965,7 @@ export function useComposerSend(input: ComposerSendInput) { // are another thread's, so this send leaves them alone too. const approvalsToDecline = currentRouteThreadKeyRef.current === routeThreadKey - ? pendingApprovalsRef.current.filter((approval) => + ? getPendingApprovals(environmentId, approvalThreadId).filter((approval) => sendCtx?.shownPendingApprovalIds.includes(approval.requestId), ) : []; @@ -970,8 +987,8 @@ export function useComposerSend(input: ComposerSendInput) { const attachmentIds = sendContext ? [...sendContext.images, ...sendContext.files].map((attachment) => attachment.id) : attachmentIdsAtSend; - const promptUnchanged = - promptRef.current === promptAtSend || promptRef.current === promptForSend; + const promptNow = readPrompt(); + const promptUnchanged = promptNow === promptAtSend || promptNow === promptForSend; const attachmentsUnchanged = sameIds(attachmentIds, attachmentIdsAtSend) || (annotationImageId !== undefined && @@ -982,11 +999,11 @@ export function useComposerSend(input: ComposerSendInput) { .getState() .getComposerDraft(composerDraftTarget); const changedBeforeDecline = draftChangedSinceSend(); - const pendingIdsBeforeDecline = pendingApprovalsRef.current.map( + const pendingIdsBeforeDecline = getPendingApprovals(environmentId, approvalThreadId).map( (approval) => approval.requestId, ); const approvalResponding = approvalsToDecline.some((approval) => - respondingRequestIdsRef.current.includes(approval.requestId), + getRespondingApprovalIds(environmentId, approvalThreadId).includes(approval.requestId), ); if (approvalFeedbackTarget !== undefined) { if (composerAttachmentsSnapshot.length > 0 || outgoingMessageContext !== undefined) { @@ -1018,7 +1035,6 @@ export function useComposerSend(input: ComposerSendInput) { useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) === draftBeforeDecline ) { - promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); } @@ -1037,7 +1053,7 @@ export function useComposerSend(input: ComposerSendInput) { draftBeforeDecline; // An approval that arrived during the decline would block the redirect; keep the draft so // the next send declines it too. - const approvalArrived = pendingApprovalsRef.current.some( + const approvalArrived = getPendingApprovals(environmentId, approvalThreadId).some( (approval) => !pendingIdsBeforeDecline.includes(approval.requestId), ); if ( @@ -1092,7 +1108,7 @@ export function useComposerSend(input: ComposerSendInput) { await sendToMultipleModels({ threadId: threadIdForSend, project: activeProject, - branch: activeThreadBranch!, + branch: sendBranch!, selections: multipleModelSelections, targets: multipleTargets, sendSnapshot, @@ -1112,25 +1128,24 @@ export function useComposerSend(input: ComposerSendInput) { messageIdForSend, ); } - setOptimisticUserMessages((existing) => [ - ...existing, - { - id: messageIdForSend, - role: "user", - text: outgoingMessageText, - ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), - ...(outgoingMessageContext !== undefined ? { context: outgoingMessageContext } : {}), - runId: null, - createdAt: messageCreatedAt, - updatedAt: messageCreatedAt, - streaming: false, - ...(shouldQueueBehindActiveRun - ? { inputIntent: "queued_turn" as const } - : phase === "running" && dispatchMode === "steer" - ? { inputIntent: "steer" as const } - : {}), - }, - ]); + const optimisticMessage: ChatMessage = { + id: messageIdForSend, + role: "user", + text: outgoingMessageText, + ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), + ...(outgoingMessageContext !== undefined ? { context: outgoingMessageContext } : {}), + runId: null, + createdAt: messageCreatedAt, + updatedAt: messageCreatedAt, + streaming: false, + ...(shouldQueueBehindActiveRun + ? { inputIntent: "queued_turn" as const } + : phase === "running" && dispatchMode === "steer" + ? { inputIntent: "steer" as const } + : {}), + }; + optimisticBlobPreviews.track(optimisticMessage); + setOptimisticUserMessages((existing) => [...existing, optimisticMessage]); setThreadError(threadIdForSend, null); if (expiredTerminalContextCount > 0) { const toastCopy = buildExpiredTerminalContextToastCopy( @@ -1150,7 +1165,6 @@ export function useComposerSend(input: ComposerSendInput) { : null; // Keep new-thread flows from reusing this draft while attachments and worktree setup settle. if (sentDraftThreadRef) markPromotedDraftThreadByRef(sentDraftThreadRef); - promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -1230,7 +1244,7 @@ export function useComposerSend(input: ComposerSendInput) { modelSelection: threadCreateModelSelection, runtimeMode, interactionMode: sendInteractionMode, - branch: activeThreadBranch, + branch: sendBranch, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, }, @@ -1289,7 +1303,7 @@ export function useComposerSend(input: ComposerSendInput) { scopeProjectRef(activeProject.environmentId, activeProject.id), resolveBackgroundDraftWorkspaceOptions({ envMode: sendEnvMode, - branch: activeThreadBranch, + branch: sendBranch, startFromOrigin, }), ), @@ -1369,19 +1383,13 @@ export function useComposerSend(input: ComposerSendInput) { ) ) { setOptimisticUserMessages((existing) => { - const removed = existing.filter((message) => message.id === messageIdForSend); - for (const message of removed) { - revokeUserMessagePreviewUrls(message); - } const next = existing.filter((message) => message.id !== messageIdForSend); return next.length === existing.length ? existing : next; }); + // The restored draft clones its images, so the sent copies are no longer used. + optimisticBlobPreviews.release(optimisticMessage); const retryComposerImages = restoreComposerDraft(composerDraftTarget, sendSnapshot); if (currentRouteThreadKeyRef.current === routeThreadKey) { - promptRef.current = messageTextForSend; - composerImagesRef.current = retryComposerImages; - composerFilesRef.current = composerFilesSnapshot; - composerTerminalContextsRef.current = composerTerminalContextsSnapshot; composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(messageTextForSend, messageTextForSend.length), prompt: messageTextForSend, diff --git a/apps/web/src/components/chat/useComposerStash.ts b/apps/web/src/components/chat/useComposerStash.ts index 470a729f81f67..c2ddd8b4226b8 100644 --- a/apps/web/src/components/chat/useComposerStash.ts +++ b/apps/web/src/components/chat/useComposerStash.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import { type ComposerFileAttachment, + type ComposerThreadDraftState, type PersistedComposerFileAttachment, type PersistedComposerImageAttachment, composerFileDedupKey, @@ -29,7 +30,6 @@ import { type ReviewCommentContext } from "../../reviewCommentContext"; import { collapseExpandedComposerCursor } from "../../composer-logic"; import { compressImageForStash } from "../../lib/imageCompression"; import { formatInlineContextReference } from "~/lib/composerContextReferences"; -import { getTerminalFocusOwner } from "../../lib/terminalFocus"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; import { pendingDraftWork } from "./pendingDraftWork"; import { @@ -47,17 +47,11 @@ import { import { remainingComposerAttachmentSlots } from "./composerThreadImport"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { resizeSnapShotSource } from "../../lib/snapShotSource"; -import { resolveShortcutCommand } from "../../keybindings"; +import { registerCommands } from "../../shortcuts/dispatcher"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../../terminalUiStateStore"; import { toastManager } from "../ui/toast"; import type { useComposerTriggerState } from "./useComposerTriggerState"; -import { - type Dispatch, - type RefObject, - type SetStateAction, - useEffect, - useRef, - useState, -} from "react"; +import { type Dispatch, type RefObject, type SetStateAction, useRef, useState } from "react"; import { useStableCallback } from "../../hooks/useStableCallback"; /** @@ -67,27 +61,23 @@ import { useStableCallback } from "../../hooks/useStableCallback"; export function useComposerStash(input: { readonly environmentId: EnvironmentId; readonly composerDraftTarget: ChatComposerProps["composerDraftTarget"]; - readonly promptRef: ChatComposerProps["promptRef"]; - readonly composerImagesRef: ChatComposerProps["composerImagesRef"]; - readonly composerFilesRef: ChatComposerProps["composerFilesRef"]; - readonly composerTerminalContextsRef: ChatComposerProps["composerTerminalContextsRef"]; - readonly keybindings: ChatComposerProps["keybindings"]; - readonly terminalOpen: boolean; + readonly readPrompt: () => string; + readonly readAttachmentDraft: () => Pick; + readonly readTerminalContexts: () => ComposerThreadDraftState["terminalContexts"]; + readonly routeThreadRef: ChatComposerProps["routeThreadRef"]; readonly projectSelectionRequired: boolean; - readonly isRevertingCheckpoint: boolean; readonly pendingUserInputs: ReadonlyArray; readonly activePendingProgress: ChatComposerProps["activePendingProgress"]; readonly prompt: string; readonly composerMenuOpen: boolean; - readonly isComposerModelPickerOpen: boolean; readonly isComposerCollapsedMobile: boolean; readonly composerPreviewAnnotations: ReadonlyArray; readonly composerReviewComments: ReadonlyArray; readonly composerThreadContexts: ReadonlyArray; - readonly attachmentTargetKeyRef: RefObject; - readonly composerDraftTargetKeyRef: RefObject; + readonly attachmentTargetKey: string; + readonly isComposerDraftTargetOpen: (targetKey: string) => boolean; readonly composerEditorRef: RefObject; - readonly isRevertingCheckpointRef: RefObject; + readonly isRevertingCheckpoint: () => boolean; readonly pendingThreadImportsRef: RefObject>; readonly setComposerCursor: Dispatch>; readonly setComposerTrigger: ReturnType["setTrigger"]; @@ -101,27 +91,23 @@ export function useComposerStash(input: { const { environmentId, composerDraftTarget, - promptRef, - composerImagesRef, - composerFilesRef, - composerTerminalContextsRef, - keybindings, - terminalOpen, + readPrompt, + readAttachmentDraft, + readTerminalContexts, + routeThreadRef, projectSelectionRequired, - isRevertingCheckpoint, pendingUserInputs, activePendingProgress, prompt, composerMenuOpen, - isComposerModelPickerOpen, isComposerCollapsedMobile, composerPreviewAnnotations, composerReviewComments, composerThreadContexts, - attachmentTargetKeyRef, - composerDraftTargetKeyRef, + attachmentTargetKey, + isComposerDraftTargetOpen, composerEditorRef, - isRevertingCheckpointRef, + isRevertingCheckpoint, pendingThreadImportsRef, setComposerCursor, setComposerTrigger, @@ -146,12 +132,8 @@ export function useComposerStash(input: { ); const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); - const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ - key: 0, - active: false, - }); - const stashPulseKeyRef = useRef(0); - const stashPulseTimeoutRef = useRef(null); + // Each stash bumps the key; the badge replays its one-shot pulse animation per key. + const [stashPulseKey, setStashPulseKey] = useState(0); const stashInFlightRef = useRef>(new Set()); const stashQueue = usePromptStashStore((state) => state.entries); @@ -159,24 +141,8 @@ export function useComposerStash(input: { const takeStashEntry = usePromptStashStore((state) => state.takeEntry); const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages); - useEffect(() => { - return () => { - if (stashPulseTimeoutRef.current !== null) { - window.clearTimeout(stashPulseTimeoutRef.current); - } - }; - }, []); - const pulseStashBadge = () => { - stashPulseKeyRef.current += 1; - setStashPulse({ key: stashPulseKeyRef.current, active: true }); - if (stashPulseTimeoutRef.current !== null) { - window.clearTimeout(stashPulseTimeoutRef.current); - } - stashPulseTimeoutRef.current = window.setTimeout(() => { - stashPulseTimeoutRef.current = null; - setStashPulse((current) => ({ ...current, active: false })); - }, 1200); + setStashPulseKey((key) => key + 1); }; const restoreStashEntry = async (menuEntry: PromptStashEntry) => { @@ -210,8 +176,8 @@ export function useComposerStash(input: { // prompt with this invocation's captured target. Nothing was taken yet, // so abort and leave the entry restorable where the user now is. if ( - isRevertingCheckpointRef.current || - composerTargetKey(composerDraftTarget) !== composerDraftTargetKeyRef.current + isRevertingCheckpoint() || + !isComposerDraftTargetOpen(composerTargetKey(composerDraftTarget)) ) { return; } @@ -241,7 +207,7 @@ export function useComposerStash(input: { }) : reference.source; }); - const currentPrompt = promptRef.current; + const currentPrompt = readPrompt(); const nextPrompt = restoredPrompt.length === 0 ? currentPrompt @@ -250,7 +216,6 @@ export function useComposerStash(input: { : restoredPrompt; let promptChanged = nextPrompt !== currentPrompt; if (promptChanged) { - promptRef.current = nextPrompt; setComposerDraftPrompt(composerDraftTarget, nextPrompt); setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); setComposerTrigger(null); @@ -261,7 +226,7 @@ export function useComposerStash(input: { let restoredFileCount = 0; const stashedFiles = entry.files ?? []; if (stashedFiles.length > 0) { - const composerFilesNow = composerFilesRef.current; + const composerFilesNow = readAttachmentDraft().files; const existingFileIds = new Set(composerFilesNow.map((file) => file.id)); const retainedUploadIds = new Set( composerFilesNow.flatMap((file) => @@ -327,7 +292,7 @@ export function useComposerStash(input: { appendedFiles.push(restored); } const capacity = remainingComposerAttachmentSlots( - composerImagesRef.current.length + composerFilesNow.length, + readAttachmentDraft().images.length + composerFilesNow.length, pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0, ); const filesToAppend = appendedFiles.slice(0, capacity); @@ -357,8 +322,7 @@ export function useComposerStash(input: { if (restoredFiles.length > 0) { addComposerDraftFiles(composerDraftTarget, restoredFiles, { appendReference: true }); const restoredFilePrompt = getComposerDraft(composerDraftTarget)?.prompt; - if (restoredFilePrompt !== undefined && restoredFilePrompt !== promptRef.current) { - promptRef.current = restoredFilePrompt; + if (restoredFilePrompt !== undefined && restoredFilePrompt !== nextPrompt) { setComposerCursor( collapseExpandedComposerCursor(restoredFilePrompt, restoredFilePrompt.length), ); @@ -371,18 +335,19 @@ export function useComposerStash(input: { let unrestoredImageNames: string[] = []; if (entry.attachments.length > 0) { - const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); + const attachmentDraft = readAttachmentDraft(); + const existingIds = new Set(attachmentDraft.images.map((image) => image.id)); // The draft store also dedupes by mimeType+sizeBytes+name, so filter // on the same key here. Counting a duplicate against capacity would // burn a slot the store then refuses to fill, pushing a genuinely // unique image into the overflow list for nothing. const existingDedupKeys = new Set( - composerImagesRef.current.map( + attachmentDraft.images.map( (image) => `${image.mimeType}\0${image.sizeBytes}\0${image.name}`, ), ); const capacity = remainingComposerAttachmentSlots( - composerImagesRef.current.length + composerFilesRef.current.length + restoredFileCount, + attachmentDraft.images.length + attachmentDraft.files.length + restoredFileCount, pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0, ); const pending = entry.attachments.filter( @@ -470,7 +435,7 @@ export function useComposerStash(input: { const stashCurrentPrompt = async () => { // Stashing clears the draft. A pasted attachment still downloading would then land in the // emptied composer instead of travelling with the entry it belongs to. - if (pendingDraftWork.has(attachmentTargetKeyRef.current)) { + if (pendingDraftWork.has(attachmentTargetKey)) { toastManager.add({ type: "info", title: "Still bringing attached context into this message.", @@ -478,12 +443,13 @@ export function useComposerStash(input: { }); return; } - const prompt = promptRef.current.trim(); - const images = [...composerImagesRef.current]; - const files = [...composerFilesRef.current]; + const prompt = readPrompt().trim(); + const attachmentDraft = readAttachmentDraft(); + const images = [...attachmentDraft.images]; + const files = [...attachmentDraft.files]; const stashedRecords: ComposerContextRecord[] = [ ...composerThreadContexts, - ...composerTerminalContextsRef.current.map(terminalContextRecord), + ...readTerminalContexts().map(terminalContextRecord), ...composerReviewComments.map(reviewCommentContextRecord), ...composerPreviewAnnotations.map((annotation) => previewAnnotationContextRecord(annotation, { @@ -582,7 +548,6 @@ export function useComposerStash(input: { }); } - promptRef.current = ""; clearComposerDraftPromptAndImages(stashTarget); clearComposerDraftTerminalContexts(stashTarget); setComposerDraftThreadContexts(stashTarget, []); @@ -691,50 +656,52 @@ export function useComposerStash(input: { if (stashMenuClosesOn.prompt !== prompt || composerMenuOpen) setIsStashMenuOpen(false); } - useEffect(() => { - const handler = (event: globalThis.KeyboardEvent) => { - const command = resolveShortcutCommand(event, keybindings, { - context: { - terminalFocus: getTerminalFocusOwner() !== null, - terminalOpen, - modelPickerOpen: isComposerModelPickerOpen, + // pendingUserInputs, activePendingProgress and projectSelectionRequired are derived in + // ChatView with no store the handler could read at event time, so the ref closure captures + // them as booleans and re-registers only when one flips. Everything else is read when the + // key arrives: keybindings and modelPickerOpen by the dispatcher, the rest from their stores. + const hasPendingUserInput = pendingUserInputs.length > 0; + const stashBlocked = projectSelectionRequired || activePendingProgress !== null; + // Capture phase while the composer is mounted, so Mod+S never reaches the browser's save + // dialog even when a focused widget (the model picker search) stops the key. + const stashShortcutScopeRef = (node: HTMLElement | null) => { + if (!node) return; + return registerCommands( + { + "composer.stash": (event) => { + // Always claim the shortcut so the browser save dialog never opens, + // even when the composer is in a state that can't stash. + event.preventDefault(); + event.stopPropagation(); + if (isCommandPaletteOpen() || isRevertingCheckpoint()) return; + if (hasPendingUserInput) { + setIsStashMenuOpen((open) => !open); + return; + } + if (stashBlocked) return; + void stashCurrentPromptStable(); }, - }); - if (command !== "composer.stash") return; - // Always claim the shortcut so the browser save dialog never opens, - // even when the composer is in a state that can't stash. - event.preventDefault(); - event.stopPropagation(); - if (isCommandPaletteOpen() || isRevertingCheckpoint) { - return; - } - if (pendingUserInputs.length > 0) { - setIsStashMenuOpen((open) => !open); - return; - } - if (projectSelectionRequired || activePendingProgress !== null) { - return; - } - void stashCurrentPromptStable(); - }; - window.addEventListener("keydown", handler, true); - return () => window.removeEventListener("keydown", handler, true); - }, [ - activePendingProgress, - isComposerModelPickerOpen, - keybindings, - pendingUserInputs.length, - projectSelectionRequired, - stashCurrentPromptStable, - isRevertingCheckpoint, - terminalOpen, - ]); + }, + { + capture: true, + context: () => ({ + terminalOpen: Boolean( + selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + routeThreadRef, + ).terminalOpen, + ), + }), + }, + ); + }; return { stashQueue, isStashMenuOpen, setIsStashMenuOpen, - stashPulse, + stashPulseKey, + stashShortcutScopeRef, restoreStashEntryStable, deleteStashEntryStable, toggleStashMenuStable, diff --git a/apps/web/src/components/chat/useComposerTypingGuard.browser.test.tsx b/apps/web/src/components/chat/useComposerTypingGuard.browser.test.tsx new file mode 100644 index 0000000000000..c0b9493f8eb5f --- /dev/null +++ b/apps/web/src/components/chat/useComposerTypingGuard.browser.test.tsx @@ -0,0 +1,91 @@ +import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests"; +import { + EnvironmentId, + RuntimeRequestId, + ThreadId, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { isRequestHeldByTypingGuard, useComposerTypingGuard } from "./useComposerTypingGuard"; + +const pendingRequests = vi.hoisted(() => ({ atoms: new Map() })); + +vi.mock("../../state/threads", () => ({ + environmentThreadDetails: { + pendingRequestsAtom: (ref: ScopedThreadRef) => pendingRequestsAtomFor(ref), + }, +})); + +function pendingRequestsAtomFor(ref: ScopedThreadRef) { + const key = `${ref.environmentId}/${ref.threadId}`; + let atom = pendingRequests.atoms.get(key) as + | Atom.Writable + | undefined; + if (!atom) { + atom = Atom.make(null).pipe(Atom.keepAlive); + pendingRequests.atoms.set(key, atom); + } + return atom; +} + +const environmentId = EnvironmentId.make("environment-1"); +const threadA = { environmentId, threadId: ThreadId.make("thread-a") }; +const threadB = { environmentId, threadId: ThreadId.make("thread-b") }; + +function askQuestion(threadRef: ScopedThreadRef, requestId: string) { + appAtomRegistry.set(pendingRequestsAtomFor(threadRef), { + approvals: [], + userInputs: [ + { + requestId: RuntimeRequestId.make(requestId), + createdAt: "2026-10-24T00:00:00.000Z", + questions: [], + responseCapability: "live" as const, + dismissible: true, + }, + ], + }); +} + +type Guard = ReturnType; + +// Mirrors ChatComposer: the form's ref cleanup releases the guard. +function Composer({ + threadRef, + expose, +}: { + threadRef: ScopedThreadRef; + expose: (g: Guard) => void; +}) { + const guard = useComposerTypingGuard(threadRef.threadId, threadRef); + expose(guard); + const { onUnmount } = guard; + return onUnmount} />; +} + +describe("useComposerTypingGuard", () => { + it("keeps holding requests when a focused composer switches threads", async () => { + let guard: Guard | null = null; + const expose = (next: Guard) => { + guard = next; + }; + await using screen = await render(); + await act(() => { + guard!.onFocus(); + guard!.onDraftChange(); + }); + + await screen.rerender(); + await act(() => { + guard!.onDraftChange(); + askQuestion(threadB, "question-b"); + }); + + expect(isRequestHeldByTypingGuard("question-b")).toBe(true); + await act(() => guard!.onBlur()); + }); +}); diff --git a/apps/web/src/components/chat/useComposerTypingGuard.test.ts b/apps/web/src/components/chat/useComposerTypingGuard.test.ts new file mode 100644 index 0000000000000..2e18fbd562d64 --- /dev/null +++ b/apps/web/src/components/chat/useComposerTypingGuard.test.ts @@ -0,0 +1,112 @@ +import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests"; +import { + EnvironmentId, + RuntimeRequestId, + ThreadId, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { COMPOSER_TYPING_IDLE_MS } from "./composerTypingGuard"; +import { composerTypingGuardHandlers, isRequestHeldByTypingGuard } from "./useComposerTypingGuard"; + +const pendingRequests = vi.hoisted(() => { + const atoms = new Map(); + return { atoms }; +}); + +vi.mock("../../state/threads", () => ({ + environmentThreadDetails: { + pendingRequestsAtom: (ref: ScopedThreadRef) => pendingRequestsAtomFor(ref), + }, +})); + +function pendingRequestsAtomFor(ref: ScopedThreadRef) { + const key = `${ref.environmentId}/${ref.threadId}`; + let atom = pendingRequests.atoms.get(key) as + | Atom.Writable + | undefined; + if (!atom) { + atom = Atom.make(null).pipe(Atom.keepAlive); + pendingRequests.atoms.set(key, atom); + } + return atom; +} + +const environmentId = EnvironmentId.make("environment-1"); +const threadA = { environmentId, threadId: ThreadId.make("thread-a") }; +const threadB = { environmentId, threadId: ThreadId.make("thread-b") }; + +function askQuestion(threadRef: ScopedThreadRef, ...requestIds: string[]) { + appAtomRegistry.set(pendingRequestsAtomFor(threadRef), { + approvals: [], + userInputs: requestIds.map((requestId) => ({ + requestId: RuntimeRequestId.make(requestId), + createdAt: "2026-10-24T00:00:00.000Z", + questions: [], + responseCapability: "live" as const, + dismissible: true, + })), + }); +} + +function setup(): Disposable { + vi.useFakeTimers(); + for (const atom of pendingRequests.atoms.values()) { + appAtomRegistry.set(atom as Atom.Writable, null); + } + return { + [Symbol.dispose]() { + vi.useRealTimers(); + }, + }; +} + +describe("composer typing guard store", () => { + it("holds a question that arrives mid-sentence until the user goes idle", () => { + using _timers = setup(); + const guard = composerTypingGuardHandlers({}, "a", threadA); + guard.onFocus(); + guard.onDraftChange(); + askQuestion(threadA, "question-1"); + expect(isRequestHeldByTypingGuard("question-1")).toBe(true); + + vi.advanceTimersByTime(COMPOSER_TYPING_IDLE_MS); + expect(isRequestHeldByTypingGuard("question-1")).toBe(false); + guard.onUnmount(); + }); + + it("follows a focused composer that switches threads", () => { + using _timers = setup(); + const owner = {}; + composerTypingGuardHandlers(owner, "a", threadA).onFocus(); + const onThreadB = composerTypingGuardHandlers(owner, "b", threadB); + onThreadB.onDraftChange(); + + askQuestion(threadA, "question-a"); + askQuestion(threadB, "question-b"); + expect(isRequestHeldByTypingGuard("question-a")).toBe(false); + expect(isRequestHeldByTypingGuard("question-b")).toBe(true); + onThreadB.onUnmount(); + }); + + it("releases and stops watching when the focused composer unmounts", () => { + using _timers = setup(); + const guard = composerTypingGuardHandlers({}, "a", threadA); + const other = composerTypingGuardHandlers({}, "b", threadB); + guard.onFocus(); + guard.onDraftChange(); + askQuestion(threadA, "question-1"); + + // A composer that does not own the guard cannot release it. + other.onUnmount(); + expect(isRequestHeldByTypingGuard("question-1")).toBe(true); + + guard.onUnmount(); + expect(isRequestHeldByTypingGuard("question-1")).toBe(false); + guard.onDraftChange(); + askQuestion(threadA, "question-1", "question-2"); + expect(isRequestHeldByTypingGuard("question-2")).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/useComposerTypingGuard.ts b/apps/web/src/components/chat/useComposerTypingGuard.ts index 22c517f4ed1db..60663c6337f06 100644 --- a/apps/web/src/components/chat/useComposerTypingGuard.ts +++ b/apps/web/src/components/chat/useComposerTypingGuard.ts @@ -1,51 +1,123 @@ -import { useEffect, useState } from "react"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useState } from "react"; +import { create } from "zustand"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { environmentThreadDetails } from "../../state/threads"; import { COMPOSER_TYPING_IDLE_MS, createComposerTypingGuardState, updateComposerTypingGuard, } from "./composerTypingGuard"; -export function useComposerTypingGuard(scope: string, requestIds: readonly string[]) { - const requestsKey = JSON.stringify(requestIds); - const [snapshot, setSnapshot] = useState(() => ({ +type TypingGuardEvent = Parameters[1]; + +const NO_HELD_REQUEST_IDS: ReadonlySet = new Set(); + +// Only one composer can hold focus, so a single guard follows the focused composer. It watches +// that composer's thread for new requests while focused, so it holds what arrives mid-sentence. +const useTypingGuard = create(() => ({ + owner: null as object | null, + scope: null as string | null, + state: createComposerTypingGuardState(), +})); + +let stopWatchingRequests: (() => void) | null = null; +let idleTimer: ReturnType | null = null; + +function dispatch(event: TypingGuardEvent): void { + useTypingGuard.setState(({ state }) => ({ state: updateComposerTypingGuard(state, event) })); + if (idleTimer !== null) clearTimeout(idleTimer); + idleTimer = null; + const { state } = useTypingGuard.getState(); + if (state.held.size === 0) return; + idleTimer = setTimeout( + () => { + idleTimer = null; + dispatch({ type: "idle" }); + }, + Math.max(0, state.lastTypedAt + COMPOSER_TYPING_IDLE_MS - Date.now()), + ); +} + +function enterScope(owner: object, scope: string, threadRef: ScopedThreadRef | null): void { + const current = useTypingGuard.getState(); + if (current.owner !== owner) useTypingGuard.setState({ owner }); + if (current.scope === scope) return; + stopWatchingRequests?.(); + stopWatchingRequests = null; + useTypingGuard.setState({ scope, - requestsKey, - state: updateComposerTypingGuard(createComposerTypingGuardState(), { - type: "requests", - requestIds, - now: Date.now(), - }), - })); - const state = snapshot.state; - if (snapshot.scope !== scope || snapshot.requestsKey !== requestsKey) { - setSnapshot((current) => ({ - scope, - requestsKey, - state: updateComposerTypingGuard( - current.scope === scope - ? current.state - : { ...createComposerTypingGuardState(), focused: current.state.focused }, - { type: "requests", requestIds, now: Date.now() }, - ), - })); - } - const dispatch = (event: Parameters[1]) => { - setSnapshot((current) => ({ - ...current, - state: updateComposerTypingGuard(current.state, event), - })); + state: { ...createComposerTypingGuardState(), focused: current.state.focused }, + }); + if (!threadRef) return; + stopWatchingRequests = appAtomRegistry.subscribe( + environmentThreadDetails.pendingRequestsAtom(threadRef), + (pending) => + dispatch({ + type: "requests", + requestIds: pending + ? [...pending.approvals, ...pending.userInputs].map((request) => request.requestId) + : [], + now: Date.now(), + }), + { immediate: true }, + ); +} + +function leaveScope(owner: object): void { + if (useTypingGuard.getState().owner !== owner) return; + dispatch({ type: "blur" }); + stopWatchingRequests?.(); + stopWatchingRequests = null; + useTypingGuard.setState({ owner: null, scope: null }); +} + +/** Whether the typing guard is holding `requestId` back from the focused composer right now. */ +export function isRequestHeldByTypingGuard(requestId: string): boolean { + return useTypingGuard.getState().state.held.has(requestId); +} + +/** + * The guard's events for one composer. `owner` identifies the composer, so a composer that lost + * the guard to another cannot release or re-scope it. + */ +export function composerTypingGuardHandlers( + owner: object, + scope: string, + threadRef: ScopedThreadRef | null, +) { + const ownsGuard = () => useTypingGuard.getState().owner === owner; + return { + onDraftChange: () => { + if (!ownsGuard()) return; + // The composer can switch threads while it keeps focus. + enterScope(owner, scope, threadRef); + dispatch({ type: "type", now: Date.now() }); + }, + onFocus: () => { + enterScope(owner, scope, threadRef); + dispatch({ type: "focus" }); + }, + onBlur: () => leaveScope(owner), + onSend: () => { + if (ownsGuard()) dispatch({ type: "send" }); + }, + /** A composer removed while focused gets no blur event. */ + onUnmount: () => leaveScope(owner), }; - const onDraftChange = () => dispatch({ type: "type", now: Date.now() }); - const onFocus = () => dispatch({ type: "focus" }); - const onBlur = () => dispatch({ type: "blur" }); - const onSend = () => dispatch({ type: "send" }); - useEffect(() => { - if (state.held.size === 0) return; - const timeout = window.setTimeout( - () => dispatch({ type: "idle" }), - Math.max(0, state.lastTypedAt + COMPOSER_TYPING_IDLE_MS - Date.now()), - ); - return () => window.clearTimeout(timeout); - }, [state.held, state.lastTypedAt, dispatch]); - return { heldRequestIds: state.held, onDraftChange, onFocus, onBlur, onSend }; +} + +/** + * Holds back approvals and questions that arrive while the user is typing in the composer for + * `scope`, so a keystroke never lands in a control that appeared under the cursor. + */ +export function useComposerTypingGuard(scope: string, threadRef: ScopedThreadRef | null) { + const [owner] = useState(() => ({})); + // Release depends only on the owner. A scoped release would change identity on every thread + // switch, re-running the form's ref cleanup and releasing a guard the focused composer holds. + const [onUnmount] = useState(() => () => leaveScope(owner)); + const heldRequestIds = useTypingGuard((guard) => + guard.owner === owner && guard.scope === scope ? guard.state.held : NO_HELD_REQUEST_IDS, + ); + return { heldRequestIds, ...composerTypingGuardHandlers(owner, scope, threadRef), onUnmount }; } diff --git a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts index 1ef648e48c90d..9a1012ca113fd 100644 --- a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts +++ b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts @@ -10,7 +10,10 @@ import { type ThreadContextRecord, } from "@t3tools/contracts"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { useRouter } from "@tanstack/react-router"; +import { Atom } from "effect/reactivity"; +import { useEffect } from "react"; import { toastManager } from "../ui/toast"; import { deriveLogicalProjectKeyFromSettings, @@ -19,8 +22,21 @@ import { import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { type EnvironmentPresentation, useEnvironmentProviders } from "../../state/environments"; import { useProjects } from "../../state/entities"; +import { resolveThreadRouteTarget } from "../../threadRoutes"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; import type { DraftSessionState } from "../../composerDraftStore"; +interface PendingEnvironmentChange { + readonly environmentId: EnvironmentId; + readonly scope: string; +} + +// Kept per draft, not per ChatView instance, so a view remounted on the same draft while a +// switch is in flight still shows it as busy and blocks sends until it settles. +const pendingEnvironmentChangeAtom = Atom.family((_draftId: DraftId) => + Atom.make(null).pipe(Atom.keepAlive), +); + interface DraftEnvironmentRoutingInput { readonly draftId: DraftId | null; readonly draftThread: DraftSessionState | null; @@ -72,6 +88,7 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) (store) => store.setLogicalProjectDraftThreadId, ); const allProjects = useProjects(); + const router = useRouter(); const { openScratchProject } = useScratchProject(); const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; const loadBalancingCandidates = needsLoadBalancing @@ -111,6 +128,9 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) loadBalancingCandidates, loadBalancingSettings.loadBalancingWeights, ); + // Pins the choice into the draft once host snapshots settle. It cannot be derived at render: + // snapshots go stale after 15s, and the whole draft view (branch, providers, attachments, + // sidebar) reads the pinned project, so the decision has to persist the moment it exists. useEffect(() => { if (!needsLoadBalancing || loadBalancing.pending || !draftId || sendInFlightRef.current) return; const target = logicalProjectEnvironments.find( @@ -164,36 +184,18 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) : "Auto balance" : undefined; - // A change in flight belongs to the draft and project it started on. + // A change in flight is reported only while the draft is on the project it started from. If + // the draft moves to another project and back before the switch completes, the switch is + // reported and applied again: the draft is back where the user asked for the switch. const environmentChangeScope = `${draftId ?? ""}:${activeProjectKey ?? ""}`; - const environmentChangeRef = useRef<{ - readonly environmentId: EnvironmentId; - readonly scope: string; - } | null>(null); - const environmentChangeScopeRef = useRef(""); - useLayoutEffect(() => { - environmentChangeScopeRef.current = environmentChangeScope; - }); - // Leaving the scope, or unmounting, abandons a change still in flight. - useLayoutEffect( - () => () => { - if (environmentChangeRef.current?.scope === environmentChangeScope) { - environmentChangeRef.current = null; - } - }, - [environmentChangeScope], - ); + const pendingAtom = pendingEnvironmentChangeAtom(draftId ?? DraftId.make("")); + const pendingEnvironmentChange = useAtomValue(pendingAtom); const readPendingEnvironmentChange = () => { - const pending = environmentChangeRef.current; - return pending !== null && pending.scope === environmentChangeScopeRef.current ? pending : null; + const pending = draftId ? appAtomRegistry.get(pendingAtom) : null; + return pending !== null && pending.scope === environmentChangeScope ? pending : null; }; - // The busy state belongs to the request that set it, so an older request settling late - // (abandoned, or superseded by a newer switch) can never clear a newer one's. - const [busyEnvironmentChange, setBusyEnvironmentChange] = useState<{ - readonly environmentId: EnvironmentId; - readonly scope: string; - } | null>(null); - const isEnvironmentChanging = busyEnvironmentChange?.scope === environmentChangeScope; + const isEnvironmentChanging = + draftId !== null && pendingEnvironmentChange?.scope === environmentChangeScope; const onEnvironmentChange = (nextEnvironmentId: EnvironmentId) => { if (envLocked || !draftId || sendInFlightRef.current) return; @@ -203,14 +205,21 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) (env) => env.environmentId === nextEnvironmentId, ); if (!target) return; - const request = { environmentId: target.environmentId, scope: environmentChangeScope }; - environmentChangeRef.current = request; - setBusyEnvironmentChange(null); + const request: PendingEnvironmentChange = { + environmentId: target.environmentId, + scope: environmentChangeScope, + }; + // The request object is the token: a newer switch replaces it, and an older one settling + // late then neither applies nor clears the newer one. + appAtomRegistry.set(pendingAtom, request); const retarget = (project: (typeof allProjects)[number]) => { const currentDraft = getDraftSession(draftId); + // Read at completion: leaving the draft, or the draft leaving its project, abandons it. + const routeTarget = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); if ( - environmentChangeRef.current !== request || - environmentChangeScopeRef.current !== request.scope || + appAtomRegistry.get(pendingAtom) !== request || + routeTarget?.kind !== "draft" || + routeTarget.draftId !== draftId || sendInFlightRef.current || !currentDraft || currentDraft.promotedTo || @@ -235,8 +244,7 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) } }; const finish = () => { - if (environmentChangeRef.current === request) environmentChangeRef.current = null; - setBusyEnvironmentChange((busy) => (busy === request ? null : busy)); + if (appAtomRegistry.get(pendingAtom) === request) appAtomRegistry.set(pendingAtom, null); }; if (target.projectId !== null) { const project = allProjects.find( @@ -247,7 +255,6 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) finish(); return; } - setBusyEnvironmentChange(request); void openScratchProject(target.environmentId, "Could not switch machine") .then((project) => { if (project) retarget(project); diff --git a/apps/web/src/components/chat/useMultipleModelSend.ts b/apps/web/src/components/chat/useMultipleModelSend.ts index a2d80ee04a65a..0792654573c79 100644 --- a/apps/web/src/components/chat/useMultipleModelSend.ts +++ b/apps/web/src/components/chat/useMultipleModelSend.ts @@ -49,7 +49,6 @@ interface MultipleModelSendInput { readonly draftId: DraftId | null; readonly composerDraftTarget: ComposerThreadTarget; readonly composerRef: ComposerHandleRef; - readonly promptRef: { current: string }; readonly runtimeMode: RuntimeMode; readonly startFromOrigin: boolean; readonly resetLocalDispatch: ReturnType["resetLocalDispatch"]; @@ -69,7 +68,6 @@ export function useMultipleModelSend(input: MultipleModelSendInput) { draftId, composerDraftTarget, composerRef, - promptRef, runtimeMode, startFromOrigin, resetLocalDispatch, @@ -133,7 +131,6 @@ export function useMultipleModelSend(input: MultipleModelSendInput) { text: messageText, attachments: attachmentsSnapshot, }); - promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); clearedDraft = true; @@ -239,7 +236,6 @@ export function useMultipleModelSend(input: MultipleModelSendInput) { if (clearedDraft) { restoreComposerDraft(composerDraftTarget, sendSnapshot); if (composerRef.current && currentRouteThreadKeyRef.current === routeThreadKey) { - promptRef.current = messageText; composerRef.current.resetCursorState({ cursor: collapseExpandedComposerCursor(messageText, messageText.length), prompt: messageText, diff --git a/apps/web/src/components/chat/usePasteToFocusComposer.ts b/apps/web/src/components/chat/usePasteToFocusComposer.ts deleted file mode 100644 index 7311b3f4c0edb..0000000000000 --- a/apps/web/src/components/chat/usePasteToFocusComposer.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { pasteTextToFocusComposer, shouldRedirectInputToComposer } from "./composerFocusRedirect"; -import { readPastedComposerContext } from "../composerInlineTokenPaste"; -import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; -import { useEffect, useRef } from "react"; -import { isMacPlatform } from "../../lib/utils"; -import { isCommandPaletteOpen } from "../../commandPaletteBus"; -import { getTerminalFocusOwner } from "../../lib/terminalFocus"; -import type { ThreadId } from "@t3tools/contracts"; -import type { ComposerHandleRef } from "../../composerHandleContext"; -/** - * Paste-to-focus: the resting composer blurs on a click into the timeline, so a paste that - * follows has no editable target and would be dropped. This sends it to the composer instead. - */ -export function usePasteToFocusComposer( - activeThreadId: ThreadId | null, - composerRef: ComposerHandleRef, -) { - const pasteAsTextShortcutUntilRef = useRef(0); - useEffect(() => { - const keyHandler = (event: KeyboardEvent) => { - if ( - shouldRedirectInputToComposer(event) && - isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) - ) { - pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; - } - }; - const handler = (event: ClipboardEvent) => { - if (!activeThreadId || isCommandPaletteOpen()) return; - if (getTerminalFocusOwner() !== null) return; - if (composerRef.current?.isModelPickerOpen()) return; - const text = pasteTextToFocusComposer(event); - const clipboardData = event.clipboardData; - if (text === null || clipboardData === null) return; - const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current; - pasteAsTextShortcutUntilRef.current = 0; - if ( - ((readPastedComposerContext(clipboardData)?.records.length ?? 0) === 0 && - composerRef.current?.pasteTextAtEnd(text, { bypassAutoAttachment })) || - composerRef.current?.insertTextAtEnd(text, { clipboardData }) - ) { - event.preventDefault(); - event.stopPropagation(); - } - }; - window.addEventListener("keydown", keyHandler, true); - window.addEventListener("paste", handler, true); - return () => { - window.removeEventListener("keydown", keyHandler, true); - window.removeEventListener("paste", handler, true); - }; - }, [activeThreadId, composerRef]); -} diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index 3f87eeffa57a8..b53b417ee8a77 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -10,21 +10,17 @@ import { } from "@t3tools/contracts"; import { derivePendingThreadRequests, + onThreadRequestsResolved, + pruneResolvedUserInputDrafts, seedUserInputDraftAnswers, + userInputDraftKey, } from "@t3tools/client-runtime/state/thread-requests"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { create } from "zustand"; -import { - useLayoutEffect, - useEffect, - useRef, - useState, - type RefObject, - type SetStateAction, -} from "react"; +import { useRef, type RefObject, type SetStateAction } from "react"; import { useShallow } from "zustand/react/shallow"; import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import { useStableCallback } from "../../hooks/useStableCallback"; @@ -34,17 +30,18 @@ import { carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, projectPendingUserInputAnswer, - pendingUserInputRequestKey, - pruneResolvedUserInputDrafts, setPendingUserInputCustomAnswer, threadContextsReferencedByAnswer, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../../pendingUserInput"; import { - clearQuestionAttachmentDraft, + countQuestionAttachments, + pruneResolvedQuestionAttachmentDrafts, questionAttachmentDraftId, - questionAttachmentDraftPrefix, + questionAttachmentDraftThreadRef, + resolveOpenQuestionAttachmentDraft, + type OpenQuestionAttachmentDraft, useQuestionAttachmentPreparation, } from "../../questionAttachments"; import { @@ -52,11 +49,14 @@ import { derivePendingUserInputs, type PendingUserInput, } from "../../session-logic"; +import { environmentServerConfigsAtom } from "../../state/server"; import { readEnvironmentScope } from "../../state/session"; -import { threadEnvironment } from "../../state/threads"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useOrchestrationCommand } from "../../state/use-orchestration-command"; import type { ChatComposerHandle } from "./ChatComposer"; +import { isRequestHeldByTypingGuard } from "./useComposerTypingGuard"; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; const usePendingUserInputAnswers = create< @@ -66,11 +66,173 @@ const usePendingUserInputQuestionIndex = create>(() => ({ const NO_PENDING_REQUESTS = { approvals: [], userInputs: [] } as const; const NO_PENDING_APPROVALS: ReturnType = []; const NO_PENDING_USER_INPUTS: ReturnType = []; +const NO_RESPONDING_REQUEST_IDS: RuntimeRequestId[] = []; +// Keyed by thread so a send that resumes after an await reads the approvals +// being answered at that moment. +const useRespondingApprovalIds = create>(() => ({})); + +function threadRequestsKey(environmentId: EnvironmentId, threadId: ThreadId | null): string { + return JSON.stringify([environmentId, threadId]); +} + +// Keyed like useRespondingApprovalIds so captures and sends that resume after an await see a +// question whose answer is already on its way. +const useRespondingUserInputIds = create>(() => ({})); + +function setResponding( + store: typeof useRespondingApprovalIds, + environmentId: EnvironmentId, + threadId: ThreadId, + requestId: RuntimeRequestId, + responding: boolean, +) { + const key = threadRequestsKey(environmentId, threadId); + store.setState((existing) => { + const current = existing[key] ?? NO_RESPONDING_REQUEST_IDS; + if (current.includes(requestId) === responding) return existing; + const next = responding ? [...current, requestId] : current.filter((id) => id !== requestId); + if (next.length > 0) return { ...existing, [key]: next }; + const { [key]: _dropped, ...rest } = existing; + return rest; + }, true); +} + +/** + * The question draft a capture for `threadRef` belongs to right now: the question the composer + * shows, while it still takes attachments. Read at capture time, so a question that closed or + * moved on never claims a late capture. + */ +export function openQuestionAttachmentDraft( + threadRef: ScopedThreadRef, +): OpenQuestionAttachmentDraft | null { + const { environmentId, threadId } = threadRef; + const pending = appAtomRegistry.get(environmentThreadDetails.pendingRequestsAtom(threadRef)); + const request = + pending && pending.userInputs.length > 0 + ? (derivePendingUserInputs(pending.userInputs)[0] ?? null) + : null; + return resolveOpenQuestionAttachmentDraft({ + threadRef, + request, + questionIndex: request + ? (usePendingUserInputQuestionIndex.getState()[ + userInputDraftKey(environmentId, threadId, request.requestId) + ] ?? 0) + : 0, + held: request !== null && isRequestHeldByTypingGuard(request.requestId), + responding: + request !== null && + ( + useRespondingUserInputIds.getState()[threadRequestsKey(environmentId, threadId)] ?? + NO_RESPONDING_REQUEST_IDS + ).includes(request.requestId), + questionAttachmentsEnabled: + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .questionAttachments === true, + }); +} + +/** + * The custom answer the composer editor shows for a pending question, read from the answer store + * at call time so a handler running before the next render sees the latest keystroke. + */ +export function getPendingCustomAnswer( + environmentId: EnvironmentId, + threadId: ThreadId, + request: PendingUserInput, + questionId: string, +): string { + const question = request.questions.find((entry) => entry.id === questionId); + if (!question || question.allowCustomAnswer === false) return ""; + const stored = + usePendingUserInputAnswers.getState()[ + userInputDraftKey(environmentId, threadId, request.requestId) + ] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; + return seedUserInputDraftAnswers(request.questions, stored)[questionId]?.customAnswer ?? ""; +} + +/** Attachments across the whole request of `draftId`, or null when it is not the open question. */ +export function countQuestionRequestAttachments(draftId: DraftId): number | null { + const threadRef = questionAttachmentDraftThreadRef(draftId); + const open = threadRef ? openQuestionAttachmentDraft(threadRef) : null; + return open?.draftId === draftId ? countQuestionAttachments(open.requestKeys) : null; +} + +/** Approvals the thread is waiting on now, for continuations that resume after an await. */ +export function getPendingApprovals( + environmentId: EnvironmentId, + threadId: ThreadId, +): ReturnType { + const current = appAtomRegistry.get( + environmentThreadDetails.pendingRequestsAtom({ environmentId, threadId }), + ); + return current === null || current.approvals.length === 0 + ? NO_PENDING_APPROVALS + : derivePendingApprovals(current.approvals); +} + +/** Approvals whose response is in flight now, for continuations that resume after an await. */ +export function getRespondingApprovalIds( + environmentId: EnvironmentId, + threadId: ThreadId, +): ReadonlyArray { + return ( + useRespondingApprovalIds.getState()[threadRequestsKey(environmentId, threadId)] ?? + NO_RESPONDING_REQUEST_IDS + ); +} + +/** Drops a thread's answer, question-position and attachment drafts for every request outside `pendingRequestIds`. */ +function pruneResolvedRequestDrafts( + environmentId: EnvironmentId, + threadId: ThreadId, + pendingRequestIds: ReadonlyArray, +) { + usePendingUserInputAnswers.setState( + (existing) => + pruneResolvedUserInputDrafts(existing, environmentId, threadId, pendingRequestIds), + true, + ); + usePendingUserInputQuestionIndex.setState( + (existing) => + pruneResolvedUserInputDrafts(existing, environmentId, threadId, pendingRequestIds), + true, + ); + pruneResolvedQuestionAttachmentDrafts(environmentId, threadId, pendingRequestIds); +} + +// Resolution can come from anywhere: another device, an interrupt, the turn +// ending, or a reload over drafts for requests that resolved meanwhile. +onThreadRequestsResolved(({ environmentId, threadId, pendingRequestIds }) => + pruneResolvedRequestDrafts(environmentId, threadId, pendingRequestIds), +); + +/** + * Drops `answeredRequestId`'s drafts as soon as the server accepts the + * response, before the projection push that resolves it lands. + */ +function collectAnsweredRequestDrafts( + environmentId: EnvironmentId, + threadId: ThreadId, + answeredRequestId: RuntimeRequestId, +) { + const current = appAtomRegistry.get( + environmentThreadDetails.pendingRequestsAtom({ environmentId, threadId }), + ); + // Missing detail is not resolution. + if (current === null) return; + pruneResolvedRequestDrafts( + environmentId, + threadId, + [...current.approvals, ...current.userInputs] + .map((request) => request.requestId) + .filter((requestId) => requestId !== answeredRequestId), + ); +} function makePendingCustomAnswerSetter(input: { readonly activePendingUserInput: PendingUserInput | null; readonly activePendingRequestKey: string; - readonly writePrompt: (prompt: string) => void; readonly setPendingUserInputAnswersByRequestId: ( update: SetStateAction>>, ) => void; @@ -84,7 +246,6 @@ function makePendingCustomAnswerSetter(input: { if (!question || question.allowCustomAnswer === false) { return; } - input.writePrompt(value); input.setPendingUserInputAnswersByRequestId((existing) => ({ ...existing, [activePendingRequestKey]: { @@ -103,24 +264,20 @@ function makePendingCustomAnswerSetter(input: { export function usePendingThreadRequests(input: { readonly environmentId: EnvironmentId; readonly activeThreadId: ThreadId | null; - readonly activeThread: { readonly id: ThreadId } | undefined; readonly serverProjection: ThreadStructure | null; readonly supportsQuestionAttachments: boolean; readonly composerDraftTarget: ScopedThreadRef | DraftId; readonly setComposerDraftPrompt: (target: ScopedThreadRef | DraftId, prompt: string) => void; - readonly promptRef: { current: string }; readonly composerRef: RefObject; readonly setThreadError: (threadId: ThreadId, error: string | null) => void; }) { const { environmentId, activeThreadId, - activeThread, serverProjection, supportsQuestionAttachments, composerDraftTarget, setComposerDraftPrompt, - promptRef, composerRef, setThreadError, } = input; @@ -134,13 +291,14 @@ export function usePendingThreadRequests(input: { reportFailure: false, }); const userInputResponsesInFlight = useRef(new Set()); - const [respondingRequestIds, setRespondingRequestIds] = useState([]); - const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< - RuntimeRequestId[] - >([]); + const respondingRequestIds = useRespondingApprovalIds( + (state) => state[threadRequestsKey(environmentId, activeThreadId)] ?? NO_RESPONDING_REQUEST_IDS, + ); + const respondingUserInputRequestIds = useRespondingUserInputIds( + (state) => state[threadRequestsKey(environmentId, activeThreadId)] ?? NO_RESPONDING_REQUEST_IDS, + ); const setPendingUserInputAnswersByRequestId = usePendingUserInputAnswers.setState; const setPendingUserInputQuestionIndexByRequestId = usePendingUserInputQuestionIndex.setState; - const projectedThreadId = serverProjection?.thread.id; const pendingRequests = serverProjection !== null && serverProjection.thread.id === activeThreadId ? derivePendingThreadRequests(serverProjection) @@ -156,25 +314,22 @@ export function usePendingThreadRequests(input: { ? NO_PENDING_USER_INPUTS : derivePendingUserInputs(pendingRequests.userInputs); const activePendingUserInput = pendingUserInputs[0] ?? null; - const activePendingRequestKey = pendingUserInputRequestKey( + const activePendingRequestKey = userInputDraftKey( environmentId, activeThreadId, activePendingUserInput?.requestId, ); - const activePendingAnswerDrafts = usePendingUserInputAnswers( + const storedPendingAnswerDrafts = usePendingUserInputAnswers( (state) => state[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS, ); + // Suggested answers are shown until the user edits them; only edits are stored. + const activePendingAnswerDrafts: Record = + activePendingUserInput + ? seedUserInputDraftAnswers(activePendingUserInput.questions, storedPendingAnswerDrafts) + : storedPendingAnswerDrafts; const activePendingQuestionIndex = usePendingUserInputQuestionIndex( (state) => state[activePendingRequestKey] ?? 0, ); - useLayoutEffect(() => { - if (!activePendingUserInput) return; - usePendingUserInputAnswers.setState((existing) => { - const drafts = existing[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; - const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts); - return seeded === drafts ? existing : { ...existing, [activePendingRequestKey]: seeded }; - }); - }, [activePendingRequestKey, activePendingUserInput]); const pendingQuestionDraftKeys = activeThreadId ? pendingUserInputs.flatMap((request) => request.questions.map((question) => @@ -211,44 +366,6 @@ export function usePendingThreadRequests(input: { Object.fromEntries(pendingQuestionDraftKeys.map((key) => [key, state.counts[key] ?? 0])), ), ); - useEffect(() => { - // A thread shell can arrive before its requests; missing detail is not resolution. - if (!activeThread || projectedThreadId !== activeThread.id) return; - const questionThread = activeThread; - const currentRequests = pendingUserInputs; - const pendingRequestIds = currentRequests.map((request) => request.requestId); - usePendingUserInputAnswers.setState( - (existing) => - pruneResolvedUserInputDrafts(existing, environmentId, questionThread.id, pendingRequestIds), - true, - ); - usePendingUserInputQuestionIndex.setState( - (existing) => - pruneResolvedUserInputDrafts(existing, environmentId, questionThread.id, pendingRequestIds), - true, - ); - const prefix = questionAttachmentDraftPrefix(environmentId, questionThread.id); - const retained = new Set( - currentRequests.flatMap((request) => - request.questions.map((question) => - questionAttachmentDraftId( - environmentId, - questionThread.id, - request.requestId, - question.id, - ), - ), - ), - ); - const keys = new Set([ - ...Object.keys(useComposerDraftStore.getState().draftsByThreadKey), - ...Object.keys(useQuestionAttachmentPreparation.getState().counts), - ]); - for (const key of keys) { - if (key.startsWith(prefix) && !retained.has(DraftId.make(key))) - clearQuestionAttachmentDraft(DraftId.make(key)); - } - }, [environmentId, activeThread, projectedThreadId, pendingUserInputs]); const activePendingDraftAnswers = !activePendingUserInput || !activeThreadId ? EMPTY_PENDING_USER_INPUT_ANSWERS @@ -304,9 +421,7 @@ export function usePendingThreadRequests(input: { ) return; - setRespondingRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], - ); + setResponding(useRespondingApprovalIds, environmentId, activeThreadId, requestId, true); const result = await respondToThreadApproval({ environmentId, input: { @@ -323,7 +438,7 @@ export function usePendingThreadRequests(input: { error instanceof Error ? error.message : "Failed to submit approval decision.", ); } - setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); + setResponding(useRespondingApprovalIds, environmentId, activeThreadId, requestId, false); return result; }; const onRespondToApprovalStable = useStableCallback(onRespondToApproval); @@ -367,9 +482,7 @@ export function usePendingThreadRequests(input: { } userInputResponsesInFlight.current.add(responseKey); - setRespondingUserInputRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], - ); + setResponding(useRespondingUserInputIds, environmentId, activeThreadId, requestId, true); const result = await respondToThreadUserInput({ environmentId, input: { @@ -388,17 +501,17 @@ export function usePendingThreadRequests(input: { error instanceof Error ? error.message : "Failed to submit user input.", ); } + if (result._tag === "Success") + collectAnsweredRequestDrafts(environmentId, activeThreadId, requestId); userInputResponsesInFlight.current.delete(responseKey); - setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); + setResponding(useRespondingUserInputIds, environmentId, activeThreadId, requestId, false); return result; }; const onDismissUserInput = async (requestId: RuntimeRequestId) => { if (!activeThreadId) return; - setRespondingUserInputRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], - ); + setResponding(useRespondingUserInputIds, environmentId, activeThreadId, requestId, true); const result = await dismissThreadUserInput({ environmentId, input: { threadId: activeThreadId, requestId }, @@ -410,7 +523,9 @@ export function usePendingThreadRequests(input: { error instanceof Error ? error.message : "Failed to dismiss the question.", ); } - setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); + if (result._tag === "Success") + collectAnsweredRequestDrafts(environmentId, activeThreadId, requestId); + setResponding(useRespondingUserInputIds, environmentId, activeThreadId, requestId, false); return result; }; const onDismissUserInputStable = useStableCallback(onDismissUserInput); @@ -476,20 +591,15 @@ export function usePendingThreadRequests(input: { }, }; }); - promptRef.current = ""; composerRef.current?.resetCursorState({ cursor: 0 }); }; const onSelectActivePendingUserInputOptionStable = useStableCallback( onSelectActivePendingUserInputOption, ); - const writePrompt = useStableCallback((prompt: string) => { - promptRef.current = prompt; - }); const onChangeActivePendingUserInputCustomAnswer = makePendingCustomAnswerSetter({ activePendingUserInput, activePendingRequestKey, - writePrompt, setPendingUserInputAnswersByRequestId, }); diff --git a/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts index bda0641e90917..8d56ed11c64a2 100644 --- a/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts +++ b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts @@ -1,137 +1,32 @@ -import { - type EnvironmentId, - PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, - type ServerProvider, -} from "@t3tools/contracts"; -import { type ProviderInstanceEntry } from "../../providerInstances"; -import { - hasCompleteProviderWorkspaceSnapshot, - hasCurrentProviderWorkspaceSnapshot, -} from "@t3tools/client-runtime/providerSkills"; +import { useAtomRefresh } from "@effect/atom-react"; +import { type EnvironmentId, type ProviderInstanceId } from "@t3tools/contracts"; +import { createProviderWorkspaceSnapshotRefreshAtomFamily } from "@t3tools/client-runtime/providerSkills"; +import { Atom } from "effect/reactivity"; + import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; -import { useStableCallback } from "../../hooks/useStableCallback"; -const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; +const providerWorkspaceSnapshotRefresh = createProviderWorkspaceSnapshotRefreshAtomFamily({ + providers: serverEnvironment.providersValueAtom, + refreshProviders: serverEnvironment.refreshProviders, +}); + +const idleRefreshAtom: Atom.Atom = Atom.make(undefined).pipe( + Atom.withLabel("provider-workspace-snapshot-refresh:idle"), +); /** * Keeps the selected provider's workspace snapshot fresh for the composer's - * cwd. Returns a check to run when the user types. + * cwd while mounted. Returns a check to run when the user types. */ export function useProviderWorkspaceSnapshotRefresh(input: { readonly environmentId: EnvironmentId; readonly gitCwd: string | null; - readonly selectedProviderEntry: ProviderInstanceEntry | undefined; - readonly selectedProviderStatus: ServerProvider | null; -}) { - const { environmentId, gitCwd, selectedProviderEntry, selectedProviderStatus } = input; - const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); - // The last scan this composer asked for. A request inside the TTL is not - // repeated, so a client clock ahead of the server's cannot loop rescans. - const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); - const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ - key: string; - notBefore: number; - } | null>(null); - const workspaceRefreshScopeKey = - gitCwd && selectedProviderEntry - ? `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}` - : null; - const workspaceSlashCommandsPending = - selectedProviderStatus?.workspaceSnapshots?.some( - (snapshot) => snapshot.cwd === gitCwd && snapshot.slashCommandsPending === true, - ) ?? false; - useEffect(() => { - if ( - !workspaceSlashCommandsPending || - !workspaceRefreshRetry || - workspaceRefreshRetry.key !== workspaceRefreshScopeKey - ) - return; - const timeout = setTimeout( - () => { - setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); - }, - Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), - ); - return () => clearTimeout(timeout); - }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]); - // Losing a snapshot (the server dropped it) lifts both the TTL and the retry - // cooldown so the next check rescans at once. - const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd); - const [hadWorkspaceSnapshot, setHadWorkspaceSnapshot] = useState(hasWorkspaceSnapshot); - const [workspaceSnapshotLossCount, setWorkspaceSnapshotLossCount] = useState(0); - if (hadWorkspaceSnapshot !== hasWorkspaceSnapshot) { - setHadWorkspaceSnapshot(hasWorkspaceSnapshot); - if (!hasWorkspaceSnapshot) { - setWorkspaceSnapshotLossCount((count) => count + 1); - setWorkspaceRefreshRetry(null); - } - } - const lastWorkspaceSnapshotLossRef = useRef(workspaceSnapshotLossCount); - const refreshWorkspaceSnapshotIfStale = ( - now: number, - key: string | null, - retry: { key: string; notBefore: number } | null, - snapshotLossCount: number, - ) => { - if (lastWorkspaceSnapshotLossRef.current !== snapshotLossCount) { - lastWorkspaceSnapshotLossRef.current = snapshotLossCount; - workspaceRefreshKeyRef.current = null; - } - if (key === null || !gitCwd || !selectedProviderEntry) return; - const lastRequest = workspaceRefreshKeyRef.current; - if ( - lastRequest?.key === key && - now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - ) - return; - // A pending retry needs no clearing here: its cooldown lapses long before the TTL. - if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd, now)) return; - if (retry?.key === key && now < retry.notBefore) return; - const request = { key, requestedAt: now }; - workspaceRefreshKeyRef.current = request; - const retryLater = () => { - if (workspaceRefreshKeyRef.current !== request) return; - workspaceRefreshKeyRef.current = null; - setWorkspaceRefreshRetry({ - key, - notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, - }); - }; - void refreshProviders({ - environmentId, - input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd }, - }).then((result) => { - const refreshedSnapshot = - result._tag === "Success" && - hasCompleteProviderWorkspaceSnapshot( - result.value.providers.find( - (provider) => provider.instanceId === selectedProviderEntry.instanceId, - ), - gitCwd, - ); - if (!refreshedSnapshot) retryLater(); - }, retryLater); - }; - const refreshWorkspaceSnapshotOnChange = useEffectEvent(refreshWorkspaceSnapshotIfStale); - useEffect(() => { - refreshWorkspaceSnapshotOnChange( - Date.now(), - workspaceRefreshScopeKey, - workspaceRefreshRetry, - workspaceSnapshotLossCount, - ); - }, [workspaceRefreshScopeKey, workspaceRefreshRetry, workspaceSnapshotLossCount]); - return useStableCallback(() => - refreshWorkspaceSnapshotIfStale( - Date.now(), - workspaceRefreshScopeKey, - workspaceRefreshRetry, - workspaceSnapshotLossCount, - ), - ); + readonly instanceId: ProviderInstanceId | undefined; +}): () => void { + const { environmentId, gitCwd, instanceId } = input; + const atom = + gitCwd && instanceId + ? providerWorkspaceSnapshotRefresh({ environmentId, instanceId, cwd: gitCwd }) + : idleRefreshAtom; + return useAtomRefresh(atom); } diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index 44a828a7b8fd4..cca7413216f96 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -1,3 +1,8 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + makeQueuedRunEditStore, + makeQueuedRunsAtom, +} from "@t3tools/client-runtime/queued-run-edit"; import type { ThreadStructure } from "@t3tools/client-runtime/state/shell"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -13,10 +18,12 @@ import { } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; -import { useEffect, useRef, useState, type RefObject } from "react"; +import { Atom } from "effect/reactivity"; +import { useRef, useState, type RefObject } from "react"; import { useAssetUrls } from "../../assets/assetUrls"; import { DraftId, + QUEUED_EDIT_DRAFT_PREFIX, useComposerDraftStore, type ComposerFileAttachment, type ComposerImageAttachment, @@ -35,7 +42,11 @@ import type { TerminalContextDraft } from "../../lib/terminalContext"; import type { ReviewCommentContext } from "../../reviewCommentContext"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../../state/server"; -import { threadEnvironment } from "../../state/threads"; +import { + environmentThreadDetails, + environmentThreadShells, + threadEnvironment, +} from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { stackedThreadToast, toastManager } from "../ui/toast"; import type { ChatComposerHandle } from "./ChatComposer"; @@ -52,6 +63,12 @@ interface EditingQueuedRun { readonly context?: OrchestrationMessageContext | undefined; } +// Keyed by the route thread, so an edit survives navigating away and back. +const queuedRunEdits = makeQueuedRunEditStore({ + registry: appAtomRegistry, + label: "web-queued-run-edits", +}); + export interface QueuedRunEditContent { readonly prompt: string; readonly images: ReadonlyArray; @@ -63,7 +80,7 @@ export interface QueuedRunEditContent { } function queuedEditDraftTarget(routeThreadRef: ScopedThreadRef, runId: RunId) { - return DraftId.make(`queued-edit:${scopedThreadKey(routeThreadRef)}:${runId}`); + return DraftId.make(`${QUEUED_EDIT_DRAFT_PREFIX}${scopedThreadKey(routeThreadRef)}:${runId}`); } async function submitQueuedRunEdit(input: { @@ -161,7 +178,6 @@ export function useQueuedRunEdit(input: { readonly threadDraftTarget: ComposerThreadTarget; readonly activeThreadId: ThreadId | null; readonly serverProjection: ThreadStructure | null; - readonly promptRef: { current: string }; readonly composerRef: RefObject; }) { const { @@ -170,16 +186,14 @@ export function useQueuedRunEdit(input: { threadDraftTarget, activeThreadId, serverProjection, - promptRef, composerRef, } = input; const editQueuedRunCommand = useEditQueuedRunCommand(); - const [editing, setEditing] = useState(null); + const editKey = scopedThreadKey(routeThreadRef); + const storedEdit = useAtomValue(queuedRunEdits.atom)[editKey] ?? null; + const editing = activeThreadId === null ? null : storedEdit; const saveInFlightRef = useRef(false); const [isSaving, setIsSaving] = useState(false); - if (editing !== null && activeThreadId !== editing.threadId) { - setEditing(null); - } const draftTarget = editing === null ? threadDraftTarget : queuedEditDraftTarget(routeThreadRef, editing.runId); @@ -213,62 +227,73 @@ export function useQueuedRunEdit(input: { const store = useComposerDraftStore.getState(); store.clearComposerContent(target); store.setPrompt(target, request.text); - setEditing({ - threadId: activeThreadId, - runId: request.runId, - messageId: request.messageId, - originalText: request.text, - existingAttachments: request.attachments, - context: serverProjection?.messages.find((message) => message.id === request.messageId) - ?.context, - }); + const threadRef = { environmentId, threadId: activeThreadId }; + queuedRunEdits.begin( + editKey, + { + threadId: activeThreadId, + runId: request.runId, + messageId: request.messageId, + originalText: request.text, + existingAttachments: request.attachments, + context: serverProjection?.messages.find((message) => message.id === request.messageId) + ?.context, + }, + { + runs: makeQueuedRunsAtom({ + threadId: activeThreadId, + snapshot: threadEnvironment.snapshotAtom(environmentId), + detailStatus: environmentThreadDetails.statusAtom(threadRef), + runs: Atom.make( + (get) => + get(environmentThreadDetails.quietThreadAtom(threadRef))?.projection.runs ?? null, + ), + }), + // The run started or was cancelled from another client. A changed + // edit moves into the thread's own draft when that draft is empty; + // otherwise it is dropped. Edits outlive navigation, so the toast is + // unscoped and names the thread: the user may be anywhere by now. + onRunLeftQueue: (_, edit) => { + const recovery = recoverQueuedMessageEdit({ + editTarget: queuedEditDraftTarget(routeThreadRef, edit.runId), + threadTarget: threadDraftTarget, + originalText: edit.originalText, + }); + if (recovery === "clean") return; + const title = appAtomRegistry.get( + environmentThreadShells.threadShellAtom(threadRef), + )?.title; + const where = title === undefined ? "" : ` in “${title}”`; + toastManager.add( + stackedThreadToast({ + type: recovery === "kept" ? "info" : "warning", + title: "Queued message is no longer queued", + description: + recovery === "kept" + ? `Your unsaved edit${where} was kept in the composer.` + : `Your unsaved edit${where} was discarded.`, + }), + ); + }, + }, + ); focusComposerSoon(); }); const cancelEdit = useStableCallback(() => { if (editing === null) return; clearEditDraft(editing.runId); - setEditing(null); + queuedRunEdits.end(editKey); focusComposerSoon(); }); const removeEditingAttachment = useStableCallback((attachmentId: string) => { - setEditing((current) => - current === null - ? current - : { - ...current, - existingAttachments: current.existingAttachments.filter( - (attachment) => attachment.id !== attachmentId, - ), - }, - ); + queuedRunEdits.update(editKey, (current) => ({ + ...current, + existingAttachments: current.existingAttachments.filter( + (attachment) => attachment.id !== attachmentId, + ), + })); }); - useEffect(() => { - if (editing === null || activeThreadId !== editing.threadId) return; - if (serverProjection === null) return; - const run = serverProjection.runs.find((candidate) => candidate.id === editing.runId); - if (run !== undefined && run.status === "queued") return; - const recovery = recoverQueuedMessageEdit({ - editTarget: queuedEditDraftTarget(routeThreadRef, editing.runId), - threadTarget: threadDraftTarget, - originalText: editing.originalText, - }); - if (recovery !== "clean") { - toastManager.add( - stackedThreadToast({ - type: recovery === "kept" ? "info" : "warning", - title: "Queued message is no longer queued", - description: - recovery === "kept" - ? "Your unsaved edit was kept in the composer." - : "Your unsaved edit was discarded.", - }), - ); - } - // oxlint-disable-next-line react/set-state-in-effect -- Leaving edit mode follows the draft recovery and toast above, which must run once after commit. - setEditing(null); - }, [activeThreadId, editing, routeThreadRef, serverProjection, threadDraftTarget]); - const saveEdit = async ( content: QueuedRunEditContent, setThreadError: (threadId: ThreadId, error: string | null) => void, @@ -294,6 +319,8 @@ export function useQueuedRunEdit(input: { } saveInFlightRef.current = true; setIsSaving(true); + // The save's result decides the edit's fate, even if the run starts meanwhile. + queuedRunEdits.suspendExitWatch(editKey); const error = await submitQueuedRunEdit({ environmentId, draftTarget, @@ -304,12 +331,14 @@ export function useQueuedRunEdit(input: { saveInFlightRef.current = false; setIsSaving(false); setThreadError(editing.threadId, error); - if (error !== null) return; + if (error !== null) { + queuedRunEdits.resumeExitWatch(editKey); + return; + } releaseDraftAttachments(newAttachments); - promptRef.current = ""; clearEditDraft(editing.runId); composerRef.current?.resetCursorState(); - setEditing(null); + queuedRunEdits.end(editKey); focusComposerSoon(); }; diff --git a/apps/web/src/components/chat/useRightPanelActions.ts b/apps/web/src/components/chat/useRightPanelActions.ts index 0ee09f128c6de..61eeca457e791 100644 --- a/apps/web/src/components/chat/useRightPanelActions.ts +++ b/apps/web/src/components/chat/useRightPanelActions.ts @@ -1,27 +1,23 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import type { Dispatch } from "react"; import type { ThreadPreviewState } from "../../previewStateStore"; import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; -import { resolveExplicitDiffOpenOnActivate } from "../ChatView.logic"; import { useStableCallback } from "../../hooks/useStableCallback"; import { type ScopedThreadRef, type RunId } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { type SetStateAction, useEffect } from "react"; +import { useEffect } from "react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { useDiffPanelStore } from "../../diffPanelStore"; import { type ThreadPanelPresentation } from "../../rightPanelLayout"; -import { - selectActiveRightPanel, - type RightPanelSurface, - useRightPanelStore, -} from "../../rightPanelStore"; +import { type RightPanelSurface, useRightPanelStore } from "../../rightPanelStore"; import { setActivePreviewTab } from "../../previewStateStore"; import { BrowserSettingsReadError } from "../../browser/openFileInPreview"; import { addBrowserSurface } from "../preview/addBrowserSurface"; +import { floatDeviceSurface } from "../preview/agentOpenedPreviews"; +import { collapseRightPanel } from "../preview/collapseRightPanel"; +import { focusActivePanelTerminal, focusTerminal } from "../../lib/terminalFocus"; import { subscribePreviewAction } from "../preview/previewActionBus"; import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore"; import type { threadPullRequestPanelTarget } from "../pullRequest/pullRequestDetail.logic"; @@ -52,8 +48,6 @@ interface RightPanelActionsInput { readonly pullRequestsSurfaceAvailable: boolean; readonly gitCwd: string | null; readonly activeProjectCwd: string | null; - readonly explicitDiffOpenRef: { current: ScopedThreadRef | null }; - readonly setTerminalFocusRequestId: Dispatch>; readonly onDiffPanelOpen: (() => void) | undefined; readonly closeRightPanelSurface: (surface: RightPanelSurface) => void; } @@ -85,8 +79,6 @@ export function useRightPanelActions(input: RightPanelActionsInput) { pullRequestsSurfaceAvailable, gitCwd, activeProjectCwd, - explicitDiffOpenRef, - setTerminalFocusRequestId, onDiffPanelOpen, } = input; @@ -98,9 +90,12 @@ export function useRightPanelActions(input: RightPanelActionsInput) { if (!diffOpen) { onDiffPanelOpen?.(); } - if (activeThreadRef) { - useRightPanelStore.getState().toggle(activeThreadRef, "diff"); + if (!activeThreadRef) return; + if (rightPanelOpen && activeRightPanelSurface?.kind === "diff") { + collapseRightPanel(activeThreadRef); + return; } + useRightPanelStore.getState().openDiff(activeThreadRef); }; const onToggleDiffStable = useStableCallback(onToggleDiff); const createBrowserSurface = (profileId?: string) => { @@ -126,8 +121,7 @@ export function useRightPanelActions(input: RightPanelActionsInput) { const createBrowserSurfaceStable = useStableCallback(createBrowserSurface); const addDiffSurface = () => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); - useRightPanelStore.getState().open(activeThreadRef, "diff"); + useRightPanelStore.getState().openDiff(activeThreadRef); onDiffPanelOpen?.(); }; const addDiffSurfaceStable = useStableCallback(addDiffSurface); @@ -166,12 +160,10 @@ export function useRightPanelActions(input: RightPanelActionsInput) { .getState() .open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId)); } else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) { - usePreviewMiniPlayerStore - .getState() - .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target }); + floatDeviceSurface(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target }); } useRightPanelStore.getState().setMaximized(activeThreadRef, false); - useRightPanelStore.getState().close(activeThreadRef); + collapseRightPanel(activeThreadRef); } }; const closePreviewPanelStable = useStableCallback(closePreviewPanel); @@ -192,17 +184,12 @@ export function useRightPanelActions(input: RightPanelActionsInput) { const togglePreviewPanelStable = useStableCallback(togglePreviewPanel); const activateRightPanelSurface = (surface: RightPanelSurface) => { if (!activeThreadRef) return; - explicitDiffOpenRef.current = resolveExplicitDiffOpenOnActivate({ - surfaceKind: surface.kind, - diffOpen, - activeThreadRef, - }); useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { setActivePreviewTab(activeThreadRef, surface.resourceId); } if (surface.kind === "terminal") { - setTerminalFocusRequestId((value) => value + 1); + focusTerminal(activeThreadRef, surface.activeTerminalId); } if (surface.kind === "diff" && !diffOpen) { onDiffPanelOpen?.(); @@ -216,6 +203,7 @@ export function useRightPanelActions(input: RightPanelActionsInput) { return; } useRightPanelStore.getState().toggleVisibility(activeThreadRef); + focusActivePanelTerminal(activeThreadRef); }; const toggleRightPanelStable = useStableCallback(toggleRightPanel); const toggleThreadPanel = () => { @@ -277,11 +265,7 @@ export function useRightPanelActions(input: RightPanelActionsInput) { const addPullRequestSurfaceStable = useStableCallback(addPullRequestSurface); const onOpenTurnDiff = (runId: RunId, filePath?: string) => { if (!isServerThread || !activeThreadRef) return; - const diffOpen = - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === "diff"; - explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; - useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath); - useRightPanelStore.getState().open(activeThreadRef, "diff"); + useRightPanelStore.getState().openDiff(activeThreadRef, { turnId: runId, filePath }); onDiffPanelOpen?.(); }; const onOpenTurnDiffStable = useStableCallback(onOpenTurnDiff); diff --git a/apps/web/src/components/chat/useSystemComposerBanners.tsx b/apps/web/src/components/chat/useSystemComposerBanners.tsx index e6fae8fd4d014..3e043bd9553bc 100644 --- a/apps/web/src/components/chat/useSystemComposerBanners.tsx +++ b/apps/web/src/components/chat/useSystemComposerBanners.tsx @@ -2,7 +2,7 @@ import { useStableCallback } from "../../hooks/useStableCallback"; import { type EnvironmentId } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useNavigate } from "@tanstack/react-router"; import { isAtomCommandInterrupted, @@ -13,14 +13,14 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { environmentCatalog } from "../../connection/catalog"; import { ConnectionDiagnosisHeadline } from "../settings/ConnectionDiagnosisDetails"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; -import { useEnvironmentDisconnectDelay } from "../../hooks/useEnvironmentDisconnectDelay"; +import { + useEnvironmentDisconnectDelay, + useEnvironmentStateHeldFor, +} from "../../hooks/useEnvironmentDisconnectDelay"; import { serverEnvironment } from "../../state/server"; import { type EnvironmentPresentation } from "../../state/environments"; import type { ComposerBannerStackItem } from "./ComposerBannerStack"; -import { - hasEnvironmentReconnectWarningGraceElapsed, - scheduleEnvironmentReconnectWarning, -} from "../ChatView.logic"; +import { ENVIRONMENT_RECONNECT_WARNING_GRACE_MS } from "../ChatView.logic"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -83,27 +83,12 @@ export function useSystemComposerBanners(input: SystemComposerBannersInput) { activeEnvironmentConnectionPhase === "reconnecting" ? (activeEnvironment?.environmentId ?? null) : null; - const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] = - useState(null); // Each reconnect attempt gets its own grace period, including a later one to // the same environment. - const [graceReconnectingEnvironmentId, setGraceReconnectingEnvironmentId] = useState( - activeReconnectingEnvironmentId, - ); - if (graceReconnectingEnvironmentId !== activeReconnectingEnvironmentId) { - setGraceReconnectingEnvironmentId(activeReconnectingEnvironmentId); - setReconnectWarningGraceElapsedEnvironmentId(null); - } - const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed( + const reconnectWarningGraceElapsed = useEnvironmentStateHeldFor( activeReconnectingEnvironmentId, - reconnectWarningGraceElapsedEnvironmentId, + ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, ); - useEffect(() => { - if (activeReconnectingEnvironmentId === null) return; - return scheduleEnvironmentReconnectWarning(() => - setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), - ); - }, [activeReconnectingEnvironmentId]); const handleReconnectActiveEnvironment = async (environmentId: EnvironmentId) => { const result = await retryEnvironment(environmentId); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { diff --git a/apps/web/src/components/chat/useThreadFind.ts b/apps/web/src/components/chat/useThreadFind.ts index ed0d011c8fc3b..a5b1423b41487 100644 --- a/apps/web/src/components/chat/useThreadFind.ts +++ b/apps/web/src/components/chat/useThreadFind.ts @@ -1,43 +1,24 @@ import type { InlineSkill } from "@t3tools/shared/inlineSkills"; import type { OrchestrationV2SearchThreadResult, ScopedThreadRef } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react"; -import { useStableCallback } from "~/hooks/useStableCallback"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { orchestrationEnvironment } from "~/state/orchestration"; import { useThreadProjection } from "~/state/entities"; import { useEnvironmentQuery } from "~/state/query"; import { useDebouncedValue } from "~/state/queries"; +import { type ThreadFindMatch, type ThreadFindStart, stepThreadFindIndex } from "./threadFind"; import { - type ThreadFindPositionReader, - type ThreadFindStart, - stepThreadFindIndex, -} from "./threadFind"; -import { - subscribeThreadFindOpen, - subscribeThreadFindQuery, - takeThreadFindQuery, -} from "./threadFindActionBus"; -import { toastManager } from "../ui/toast"; + CLOSED_THREAD_FIND, + closeThreadFind, + setThreadFindQuery, + useThreadFindOpen, + useThreadFindStore, + type ThreadFindLocalSelection, + type ThreadFindState, +} from "./threadFindStore"; const EMPTY_SKILLS: readonly InlineSkill[] = []; -const CLOSED_FIND = { - threadKey: null as string | null, - query: "", - offset: 0, - start: undefined as ThreadFindStart | undefined, - focusRequestId: 0, - navigationId: 0, - requestId: 0, - localSelection: null as { - base: OrchestrationV2SearchThreadResult; - baseNavigationId: number; - activeIndex: number; - match: NonNullable; - navigationId: number; - } | null, -}; - export function useThreadFind({ thread, enabled, @@ -52,77 +33,17 @@ export function useThreadFind({ // Read here rather than passed in: a streamed delta re-renders only the find // provider, which refreshes open results from it. const content = useThreadProjection(enabled ? thread : null)?.projection; - // Held in state so the timeline props that carry it can be stored in state too. - const [positionReader] = useState>(() => ({ - current: null, - })); const lastContentRef = useRef(content); const refreshTimerRef = useRef | null>(null); const refreshAfterSearchRef = useRef(false); const threadKey = thread ? scopedThreadKey(thread) : null; - const [state, setState] = useState(CLOSED_FIND); - if (state.threadKey !== null && (!enabled || state.threadKey !== threadKey)) - setState(CLOSED_FIND); - const isOpen = enabled && threadKey !== null && state.threadKey === threadKey; - const open = useStableCallback(() => { - if (threadKey === null) return; - if (!enabled) { - toastManager.add({ - id: "thread-find-unavailable", - title: "Thread search is unavailable on this server.", - description: "Update the server to enable it.", - }); - return; - } - const start = - state.threadKey === threadKey && state.query.trim() - ? positionReader.current?.(state.query.trim()) - : undefined; - setState((previous) => { - const current = previous.threadKey === threadKey ? previous : CLOSED_FIND; - return { - ...current, - threadKey, - focusRequestId: previous.focusRequestId + 1, - ...(current.query.trim() - ? { - start, - offset: 0, - localSelection: null, - navigationId: current.navigationId + 1, - requestId: current.navigationId + 1, - } - : {}), - }; - }); - }); - const close = useStableCallback(() => setState(CLOSED_FIND)); - useEffect(() => subscribeThreadFindOpen(open), [open]); - const openOnQuery = useStableCallback((query: string, start?: ThreadFindStart) => { - if (threadKey === null) return; - setState((previous) => ({ - ...(previous.threadKey === threadKey ? previous : CLOSED_FIND), - threadKey, - query, - offset: 0, - start, - localSelection: null, - focusRequestId: previous.focusRequestId + 1, - navigationId: previous.navigationId + 1, - requestId: previous.navigationId + 1, - })); - }); - // A palette message result opens find on its query, at its message, once - // this thread shows. - useEffect(() => { - if (threadKey === null || !enabled) return; - const take = () => { - const find = takeThreadFindQuery(threadKey); - if (find !== null) openOnQuery(find.query, find.start); - }; - take(); - return subscribeThreadFindQuery(take); - }, [enabled, openOnQuery, threadKey]); + const isOpen = useThreadFindOpen(thread, enabled); + const stored = useThreadFindStore(); + const state = isOpen ? stored : CLOSED_THREAD_FIND; + const setState = (update: (previous: ThreadFindState) => Partial) => + useThreadFindStore.setState((previous) => + previous.threadKey === threadKey ? update(previous) : previous, + ); const remote = useServerResults( isOpen ? thread : null, @@ -153,7 +74,7 @@ export function useThreadFind({ const navigationId = localSelection?.navigationId ?? remote.navigationId; const findExpanded = isOpen && state.query.trim().length > 0; const findQuery = isOpen && remote.data?.match ? state.query : ""; - const nextTimeline = { + const nextTimeline: TimelineFields = { findOpen: isOpen, findExpanded, findQuery, @@ -164,19 +85,13 @@ export function useThreadFind({ findNavigationId: navigationId, }; // Keep timeline identity stable during count and response updates to avoid extra jumps. - const [storedTimeline, setTimeline] = useState(() => ({ - fields: nextTimeline, - props: { ...nextTimeline, findPositionReaderRef: positionReader }, - })); + const [storedTimeline, setTimeline] = useState(nextTimeline); let timeline = storedTimeline; - if (!sameTimeline(timeline.fields, nextTimeline)) { - timeline = { - fields: nextTimeline, - props: { ...nextTimeline, findPositionReaderRef: positionReader }, - }; + if (!sameTimeline(timeline, nextTimeline)) { + timeline = nextTimeline; setTimeline(timeline); } - const match = timeline.fields.activeFindMatch; + const match = timeline.activeFindMatch; const step = (delta: number) => { if (count === 0 || counting) return; setState((previous) => { @@ -235,41 +150,40 @@ export function useThreadFind({ }); }; - const refreshLiveResultsRef = useRef(() => {}); - useLayoutEffect(() => { - refreshLiveResultsRef.current = () => { - if (!isOpen || !state.query.trim()) return; - // Coalesce paging updates while the server counts to avoid restarting scans. - if (progressive && remote.isPending) { - refreshAfterSearchRef.current = true; - return; - } - if (!match || state.navigationId !== navigationId) { - remote.refresh(); - return; - } - const start = { entryId: match.entryId, occurrence: match.occurrence }; - if ( - state.offset === 0 && - state.start?.entryId === start.entryId && - state.start.occurrence === start.occurrence && - state.requestId === state.navigationId - ) { - remote.refresh(); - } else { - setState((previous) => ({ - ...previous, - start, - offset: 0, - requestId: previous.navigationId, - })); - } - }; + // Runs from the refresh timer and after a coalesced search settles, against + // the results and selection showing by then. + const refreshLiveResults = useEffectEvent(() => { + if (!isOpen || !state.query.trim()) return; + // Coalesce paging updates while the server counts to avoid restarting scans. + if (progressive && remote.isPending) { + refreshAfterSearchRef.current = true; + return; + } + if (!match || state.navigationId !== navigationId) { + remote.refresh(); + return; + } + const start = { entryId: match.entryId, occurrence: match.occurrence }; + if ( + state.offset === 0 && + state.start?.entryId === start.entryId && + state.start.occurrence === start.occurrence && + state.requestId === state.navigationId + ) { + remote.refresh(); + } else { + setState((previous) => ({ + ...previous, + start, + offset: 0, + requestId: previous.navigationId, + })); + } }); useEffect(() => { if (!refreshAfterSearchRef.current || remote.isPending) return; refreshAfterSearchRef.current = false; - if (!remote.error) refreshLiveResultsRef.current(); + if (!remote.error) refreshLiveResults(); }, [remote.isPending, remote.error]); useEffect(() => { if (lastContentRef.current === content) return; @@ -277,7 +191,7 @@ export function useThreadFind({ if (!isOpen || !state.query.trim() || refreshTimerRef.current !== null) return; refreshTimerRef.current = setTimeout(() => { refreshTimerRef.current = null; - refreshLiveResultsRef.current(); + refreshLiveResults(); }, 300); }, [content, isOpen, state.query]); useEffect(() => { @@ -290,9 +204,6 @@ export function useThreadFind({ }, [threadKey, isOpen, state.query]); return { - isOpen, - open, - close, barProps: { open: isOpen, query: state.query, @@ -303,29 +214,23 @@ export function useThreadFind({ focusRequestId: state.focusRequestId, onRetry: remote.refresh, onQueryChange: (query: string) => { - const start = positionReader.current?.(query.trim()); - setState((previous) => ({ - ...previous, - query, - offset: 0, - start, - localSelection: null, - navigationId: previous.navigationId + 1, - requestId: previous.navigationId + 1, - })); + if (threadKey !== null) setThreadFindQuery(threadKey, query); }, onNext: () => step(1), onPrevious: () => step(-1), - onClose: close, + onClose: closeThreadFind, }, - timelineProps: timeline.props, + timelineProps: timeline, }; } -type TimelineFields = Omit< - ReturnType["timelineProps"], - "findPositionReaderRef" ->; +interface TimelineFields { + readonly findOpen: boolean; + readonly findExpanded: boolean; + readonly findQuery: string; + readonly activeFindMatch: ThreadFindMatch | null; + readonly findNavigationId: number; +} function sameTimeline(a: TimelineFields, b: TimelineFields) { const aMatch = a.activeFindMatch; @@ -344,7 +249,7 @@ function sameTimeline(a: TimelineFields, b: TimelineFields) { } function rebaseLocalSelection( - selection: typeof CLOSED_FIND.localSelection, + selection: ThreadFindLocalSelection | null, data: OrchestrationV2SearchThreadResult | null, navigationId: number, ) { diff --git a/apps/web/src/components/chat/useThreadFindPositionReader.ts b/apps/web/src/components/chat/useThreadFindPositionReader.ts index 5afc9ca25488d..7db6ce84ccc22 100644 --- a/apps/web/src/components/chat/useThreadFindPositionReader.ts +++ b/apps/web/src/components/chat/useThreadFindPositionReader.ts @@ -1,4 +1,4 @@ -import { useLayoutEffect } from "react"; +import { useImperativeHandle } from "react"; import type { RunId } from "@t3tools/contracts"; import type { TimelineEntry } from "../../session-logic"; import { @@ -7,11 +7,13 @@ import { type TimelineLatestRun, } from "./MessagesTimeline.logic"; import type { ThreadFindPositionReader } from "./threadFind"; +import { attachThreadFindTimeline, type ThreadFindTimeline } from "./threadFindStore"; import { readThreadFindPosition } from "./threadFindPosition"; -/** Publishes how to read the find position of the timeline's current viewport. */ +/** Hands thread find this timeline: how to read its viewport's find position, and how to navigate it. */ export function useThreadFindPositionReader({ - findPositionReaderRef, + threadKey, + navigate, timelineViewportElement, findFoldRunIds, timelineEntries, @@ -22,7 +24,8 @@ export function useThreadFindPositionReader({ rows, contentInsetEndAdjustment, }: { - readonly findPositionReaderRef: React.RefObject | undefined; + readonly threadKey: string; + readonly navigate: () => void; readonly timelineViewportElement: HTMLDivElement | null; readonly findFoldRunIds: ReadonlyMap | null; readonly timelineEntries: ReadonlyArray; @@ -33,8 +36,11 @@ export function useThreadFindPositionReader({ readonly rows: MessagesTimelineRow[]; readonly contentInsetEndAdjustment: number; }) { - useLayoutEffect(() => { - if (!findPositionReaderRef || !timelineViewportElement) return; + useImperativeHandle< + ThreadFindTimeline | null, + ThreadFindTimeline | null + >(attachThreadFindTimeline, () => { + if (!timelineViewportElement) return null; const read: ThreadFindPositionReader = (query) => { const foldRunIds = findFoldRunIds ?? @@ -81,15 +87,13 @@ export function useThreadFindPositionReader({ contentInsetEndAdjustment, ); }; - findPositionReaderRef.current = read; - return () => { - if (findPositionReaderRef.current === read) findPositionReaderRef.current = null; - }; + return { threadKey, readPosition: read, navigate }; }, [ contentInsetEndAdjustment, findFoldRunIds, - findPositionReaderRef, isWorking, + navigate, + threadKey, latestRun, rows, runlessWorkActive, diff --git a/apps/web/src/components/chat/useTimelineFollow.ts b/apps/web/src/components/chat/useTimelineFollow.ts index 0d94898466d8c..5a710ff816f12 100644 --- a/apps/web/src/components/chat/useTimelineFollow.ts +++ b/apps/web/src/components/chat/useTimelineFollow.ts @@ -1,19 +1,13 @@ import { useStableCallback } from "../../hooks/useStableCallback"; -import { - eventPathContainsSelector, - TYPE_TO_FOCUS_EDITABLE_SELECTOR, - TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR, -} from "./composerFocusRedirect"; -import { type MessageId, type ThreadId, type RunId } from "@t3tools/contracts"; +import { type MessageId, type RunId } from "@t3tools/contracts"; import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { Debouncer } from "@tanstack/react-pacer"; import { type SetStateAction, - useEffect, - useEffectEvent, useLayoutEffect, useRef, useState, + useSyncExternalStore, } from "react"; import { type LegendListRef } from "@legendapp/list/react"; import { @@ -22,15 +16,25 @@ import { readTimelinePosition, observeTimelineRun, type TimelineRunObservation, - type TimelineScrollMode, } from "./timelineScrollAnchoring"; import { type ChatComposerHandle } from "./ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./pageScrollController"; -import { createUpwardScrollDetector, isTimelineScrollTarget } from "./timelineScrollTarget"; -import { resolveTimelineIsAtEnd } from "./MessagesTimeline.logic"; +import { + FOLLOWING, + initialTimelineFollow, + navigationReleasesFollow, + transitionTimelineFollow, + type TimelineFollow, + type TimelineFollowEvent, + type TimelineNavigation, + type TimelineViewport, +} from "./timelineViewport"; +import { resolveTimelineIsAtEnd, type MessagesTimelineRow } from "./MessagesTimeline.logic"; import { measuredEndOffset, settleListScroll } from "./useTimelinePositionRestore"; -import { shouldReleaseTimelineAnchorForToolActivity } from "../ChatView.logic"; -import { releaseChatTimelineAnchor } from "../ChatView.logic"; +import { + releaseChatTimelineAnchor, + shouldReleaseTimelineAnchorForToolActivity, +} from "../ChatView.logic"; import type { Dispatch, RefObject } from "react"; import type { ThreadStructure } from "@t3tools/client-runtime/state/shell"; import type { deriveThreadActivityRun } from "@t3tools/client-runtime/state/thread-execution"; @@ -44,14 +48,110 @@ interface TimelineAnchor { readonly messageId: MessageId | null; } +/** One visit to a thread; a switch starts a new one from the remembered position. */ +interface TimelineFollowVisit { + readonly threadKey: string | null; + readonly followEnd: boolean; +} + +/** + * Scroll bookkeeping that belongs to one visit. Handlers fetch it for the + * current visit, so a thread switch leaves the old one behind instead of + * resetting it. + */ +function createVisitScrollState( + visit: TimelineFollowVisit, + showScrollToBottom: (visit: TimelineFollowVisit) => void, +) { + return { + visit, + intent: null as "toward-end" | "away-from-end" | null, + // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during + // thread switches. LegendList fires scroll events with isAtEnd=false while + // initialScrollAtEnd is settling; hiding is always immediate. + showScrollDebouncer: new Debouncer(() => showScrollToBottom(visit), { wait: 150 }), + positionedAnchor: null as MessageId | null, + settledAnchor: null as MessageId | null, + releaseAnchorScrollHold: null as (() => void) | null, + pendingAnchorScrollRestore: null as { + readonly messageId: MessageId; + readonly offset: number; + } | null, + anchorScrollRestoreFrame: null as number | null, + }; +} +type VisitScrollState = ReturnType; + +function stopAnchorPositioning(scroll: VisitScrollState) { + scroll.positionedAnchor = null; + scroll.settledAnchor = null; + scroll.releaseAnchorScrollHold?.(); + scroll.releaseAnchorScrollHold = null; +} + +/** Whether the list still shows the anchored message; a thread switch replaces its data. */ +function listShowsMessage(list: LegendListRef | null, messageId: MessageId): boolean { + const rows = list?.getState().data as ReadonlyArray | undefined; + return rows?.some((row) => row.kind === "message" && row.message.id === messageId) === true; +} + +/** + * The follow mode, read by scroll listeners at event time and by render + * through useSyncExternalStore. State recorded for an earlier visit is + * discarded, so a thread switch needs no reset step. + */ +function createTimelineFollowStore() { + let snapshot: { readonly visit: TimelineFollowVisit | null; readonly follow: TimelineFollow } = { + visit: null, + follow: FOLLOWING, + }; + const listeners = new Set<() => void>(); + // Page keys read the overlay at keypress time; it changes no rendered state. + let composerOverlay: HTMLElement | null = null; + const read = (visit: TimelineFollowVisit) => + snapshot.visit === visit ? snapshot.follow : initialTimelineFollow(visit.followEnd); + return { + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + snapshot: () => snapshot, + read, + /** Ref callback for the composer overlay. */ + attachComposerOverlay: (element: HTMLElement | null) => { + composerOverlay = element; + }, + readComposerOverlayHeight: () => composerOverlay?.getBoundingClientRect().height ?? 0, + dispatch: (visit: TimelineFollowVisit, event: TimelineFollowEvent) => { + const before = read(visit); + const after = transitionTimelineFollow(before, event); + if (snapshot.visit !== visit || after !== snapshot.follow) { + snapshot = { visit, follow: after }; + for (const listener of [...listeners]) listener(); + } + return { before, after }; + }, + }; +} + +function sameRunObservation( + next: TimelineRunObservation, + previous: TimelineRunObservation | null, +): boolean { + return ( + previous !== null && + next.threadKey === previous.threadKey && + next.hydrated === previous.hydrated && + next.runId === previous.runId + ); +} + interface TimelineFollowInput { readonly legendListRef: RefObject; readonly composerRef: RefObject; - readonly composerOverlayElement: HTMLDivElement | null; readonly composerTimelineInset: number; readonly routeThreadKey: string; readonly activeThreadKey: string | null; - readonly activeThreadId: ThreadId | undefined; readonly isServerThread: boolean; readonly serverProjection: ThreadStructure | null; readonly threadStatus: EnvironmentThreadStatus; @@ -64,18 +164,17 @@ interface TimelineFollowInput { /** * Keeps the timeline following the live end, anchoring a newly sent turn near - * the top, and letting the user scroll away. The scroll handlers read refs, so - * a scroll gesture does not re-render ChatView unless the follow mode flips. + * the top, and letting the user scroll away. Follow lives in a store the + * viewport's navigation signal writes, so a scroll gesture does not re-render + * ChatView unless the follow mode flips. */ export function useTimelineFollow(input: TimelineFollowInput) { const { legendListRef, composerRef, - composerOverlayElement, composerTimelineInset, routeThreadKey, activeThreadKey, - activeThreadId, isServerThread, serverProjection, threadStatus, @@ -85,54 +184,75 @@ export function useTimelineFollow(input: TimelineFollowInput) { timelineAnchorMessageId, setTimelineAnchor, } = input; - const [showScrollToBottom, setShowScrollToBottom] = useState(false); - const isAtEndRef = useRef(true); - const isTimelineAtLogicalEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current; - const isTimelineAtLogicalEndStable = useStableCallback(isTimelineAtLogicalEnd); - // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during - // thread switches. LegendList fires scroll events with isAtEnd=false while - // initialScrollAtEnd is settling; hiding is always immediate. - const showScrollDebouncer = useRef( - new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), + const [visitState, setVisit] = useState({ + threadKey: activeThreadKey, + followEnd: true, + }); + let visit = visitState; + if (visit.threadKey !== activeThreadKey) { + visit = { + threadKey: activeThreadKey, + followEnd: readTimelinePosition(routeThreadKey)?.atEnd !== false, + }; + setVisit(visit); + } + // Keyed by visit, so a pill decision from the previous thread (a pending + // debounce) never shows here. + const [pill, setPill] = useState<{ readonly visit: TimelineFollowVisit; readonly show: boolean }>( + { visit, show: false }, ); - const timelineScrollIntentRef = useRef<"toward-end" | "away-from-end" | null>(null); - const timelineScrollModeRef = useRef("following-end"); - // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd - // re-pins on its own (independent of the refs), so the timeline needs a - // render-visible flag to switch it off once the user scrolls away. - const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); - const pendingTimelineAnchorRef = useRef(null); - const positionedTimelineAnchorRef = useRef(null); - const settledTimelineAnchorRef = useRef(null); - const activeTimelineAnchorIndexRef = useRef(null); - const observedTimelineActivityRef = useRef(null); - const anchorUserScrollGenerationRef = useRef(0); - const cancelPositionRestoreRef = useRef<(() => void) | null>(null); - const liveFollowUserScrollGenerationRef = useRef(0); - const pendingAnchorScrollRestoreRef = useRef<{ - readonly messageId: MessageId; - readonly offset: number; - readonly userScrollGeneration: number; - } | null>(null); - const anchorScrollRestoreFrameRef = useRef(null); - const cancelTimelineLiveFollowForUserNavigation = () => { - cancelPositionRestoreRef.current?.(); - anchorUserScrollGenerationRef.current += 1; - const wasProgrammaticScrollMode = timelineScrollModeRef.current !== "free-scrolling"; - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - setTimelineLiveFollowEnabled(false); - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - pendingAnchorScrollRestoreRef.current = null; - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; + const showScrollToBottom = pill.visit === visit ? pill.show : !visit.followEnd; + // Called on every streamed chunk while following; an unchanged pill must + // bail out instead of re-rendering ChatView. + const setShowScrollToBottom = (show: boolean) => + setPill((current) => + current.visit === visit && current.show === show ? current : { visit, show }, + ); + const [followStore] = useState(createTimelineFollowStore); + const followSnapshot = useSyncExternalStore(followStore.subscribe, followStore.snapshot); + const follow = + followSnapshot.visit === visit ? followSnapshot.follow : initialTimelineFollow(visit.followEnd); + const timelineLiveFollowEnabled = follow.mode === "following"; + const readFollow = () => followStore.read(visit); + // Async settles outlive their render; this reads the visit shown now. + const readCurrentFollow = useStableCallback(readFollow); + const isTimelineAtLogicalEnd = () => { + const current = readFollow(); + return ( + resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? + (current.mode === "following" || current.atEnd) + ); + }; + const isTimelineAtLogicalEndStable = useStableCallback(isTimelineAtLogicalEnd); + const visitScrollRef = useRef(null); + /** Only from handlers: the bookkeeping of the visit they run in. */ + const visitScroll = () => { + if (visitScrollRef.current?.visit !== visit) { + visitScrollRef.current = createVisitScrollState(visit, (shownVisit) => { + // A debounce that outlived its visit must not overwrite the current one's pill. + if (visitScrollRef.current?.visit === shownVisit) + setPill({ visit: shownVisit, show: true }); + }); } - if (wasProgrammaticScrollMode) { + return visitScrollRef.current; + }; + // The anchor whose list position LegendList reported ready; until then a + // newly started run must not re-anchor. + const [laidOutAnchor, setLaidOutAnchor] = useState(null); + const anchorPending = + timelineAnchorMessageId !== null && laidOutAnchor !== timelineAnchorMessageId; + const viewportRef = useRef(null); + const releaseFollowForNavigation = () => { + const { before } = followStore.dispatch(visit, { type: "navigated" }); + if (before.mode === "following") viewportRef.current?.followReleased(); + const scroll = visitScroll(); + stopAnchorPositioning(scroll); + scroll.pendingAnchorScrollRestore = null; + if (scroll.anchorScrollRestoreFrame !== null) { + cancelAnimationFrame(scroll.anchorScrollRestoreFrame); + scroll.anchorScrollRestoreFrame = null; + } + if (before.mode === "following") { // LegendList may run queued scrolls after user navigation. A new imperative // scroll cancels that queue; a missing item cancels without moving the view. void legendListRef.current?.scrollToItem({ @@ -152,96 +272,51 @@ export function useTimelineFollow(input: TimelineFollowInput) { } } }; - const cancelTimelineLiveFollowForUserNavigationStable = useStableCallback( - cancelTimelineLiveFollowForUserNavigation, - ); - const cancelTimelineLiveFollowForUserNavigationRef = useRef( - cancelTimelineLiveFollowForUserNavigationStable, - ); - useEffect(() => { - cancelTimelineLiveFollowForUserNavigationRef.current = - cancelTimelineLiveFollowForUserNavigationStable; - }, [cancelTimelineLiveFollowForUserNavigationStable]); - useEffect(() => { - const previous = observedTimelineActivityRef.current; - const dispatchedUserItem = - previous?.hydrated && - previous.threadKey === activeThreadKey && - activeActivityRun !== null && - previous.runId !== activeActivityRun.runId - ? serverProjection?.visibleTurnItems.find( - (row) => row.item.type === "user_message" && row.item.runId === activeActivityRun.runId, - ) - : undefined; - const { observation, anchorMessageId: messageId } = observeTimelineRun(previous, { - threadKey: activeThreadKey, - hydrated: !isServerThread || (serverProjection !== null && threadStatus === "live"), - runId: activeActivityRun?.runId ?? null, - queued: activeActivityRun?.status === "queued", - messageId: - dispatchedUserItem?.item.type === "user_message" ? dispatchedUserItem.item.messageId : null, - }); - observedTimelineActivityRef.current = observation; - if (messageId === null) return; - if ( - pendingTimelineAnchorRef.current !== null || - timelineScrollModeRef.current === "free-scrolling" - ) { - return; - } - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = messageId; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ threadKey: activeThreadKey, messageId }); - }, [ - activeActivityRun, - activeThreadKey, - isServerThread, - serverProjection, - setTimelineAnchor, - threadStatus, - ]); - const timelineRealContentOverflowsViewport = (list?: LegendListRef | null) => - timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), { + // A run that starts while the live end is followed anchors its user message + // near the top, unless the previous anchor has not been laid out yet. + const [observedRun, setObservedRun] = useState(null); + const dispatchedUserItem = + observedRun?.hydrated && + observedRun.threadKey === activeThreadKey && + activeActivityRun !== null && + observedRun.runId !== activeActivityRun.runId + ? serverProjection?.visibleTurnItems.find( + (row) => row.item.type === "user_message" && row.item.runId === activeActivityRun.runId, + ) + : undefined; + const runObservation = observeTimelineRun(observedRun, { + threadKey: activeThreadKey, + hydrated: !isServerThread || (serverProjection !== null && threadStatus === "live"), + runId: activeActivityRun?.runId ?? null, + queued: activeActivityRun?.status === "queued", + messageId: + dispatchedUserItem?.item.type === "user_message" ? dispatchedUserItem.item.messageId : null, + }); + if (!sameRunObservation(runObservation.observation, observedRun)) { + setObservedRun(runObservation.observation); + const startedRunAnchor = runObservation.anchorMessageId; + if (startedRunAnchor !== null && !anchorPending && timelineLiveFollowEnabled) { + setShowScrollToBottom(false); + setTimelineAnchor({ threadKey: activeThreadKey, messageId: startedRunAnchor }); + } + } + const timelineRealContentOverflowsViewport = () => + timelineContentOverflowsViewport(legendListRef.current?.getState(), { composerInset: composerTimelineInset, anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, }); - const timelineRealContentOverflowsViewportStable = useStableCallback( - timelineRealContentOverflowsViewport, - ); const pageScrollControllerRef = useRef | null>( null, ); - const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => { - timelineScrollIntentRef.current = key === "PageUp" ? "away-from-end" : "toward-end"; + const handlePageScrollStart = useStableCallback((key: PageScrollKey) => { + visitScroll().intent = key === "PageUp" ? "away-from-end" : "toward-end"; composerRef.current?.collapseForTimelineScrollKey(key); - if ( - (key === "PageUp" && timelineRealContentOverflowsViewportStable()) || - !isTimelineAtLogicalEndStable() - ) { - cancelTimelineLiveFollowForUserNavigationStable(); + if ((key === "PageUp" && timelineRealContentOverflowsViewport()) || !isTimelineAtLogicalEnd()) { + // Through the viewport, so a running position restore stops too. + viewportRef.current?.navigate({ releasesFollow: true }); } }); - useEffect(() => { - const controller = createPageScrollController({ - getContainer: () => legendListRef.current?.getScrollableNode() ?? null, - getScrollPaddingBottomPx: () => composerOverlayElement?.getBoundingClientRect().height ?? 0, - onScrollStart: handlePageScrollStart, - }); - pageScrollControllerRef.current = controller; - - return () => { - controller.dispose(); - if (pageScrollControllerRef.current === controller) { - pageScrollControllerRef.current = null; - } - }; - }, [composerOverlayElement, legendListRef]); const onComposerPageScrollKeyDown = (key: PageScrollKey) => { pageScrollControllerRef.current?.handleKeyDown(key); }; @@ -255,14 +330,10 @@ export function useTimelineFollow(input: TimelineFollowInput) { }; const onComposerPageScrollReleaseStable = useStableCallback(onComposerPageScrollRelease); const scrollToEnd = (animated: boolean = false) => { - cancelPositionRestoreRef.current?.(); - isAtEndRef.current = true; - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); + // Stops a running position restore; follow itself is re-armed below. + viewportRef.current?.navigate({ releasesFollow: false }); + followStore.dispatch(visit, { type: "followEnd" }); + visitScroll().showScrollDebouncer.cancel(); setShowScrollToBottom(false); setTimelineAnchor(releaseChatTimelineAnchor); // The anchored end space must be gone before the scroll measures, or the @@ -278,217 +349,96 @@ export function useTimelineFollow(input: TimelineFollowInput) { settleListScroll( list, measuredEndOffset, - () => timelineScrollModeRef.current !== "following-end" || legendListRef.current !== list, + () => readCurrentFollow().mode !== "following" || legendListRef.current !== list, ), ); }); }; const scrollToEndStable = useStableCallback(scrollToEnd); - useEffect(() => { - const composer = composerRef; - let removeListeners: (() => void) | null = null; - let frame: number | null = null; - const attach = (remainingAttempts: number) => { - frame = requestAnimationFrame(() => { - frame = null; - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - // The list may not have mounted on the first frame after a thread - // switch — without a retry the opt-out listeners never attach and - // live-follow becomes impossible to escape for the whole thread. - if (remainingAttempts > 0) { - attach(remainingAttempts - 1); - } - return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - // Only break follow for gestures that can move away from the live edge. - // A spurious break emits no scroll event to re-arm follow, so streaming stops - // following. Underflowing content cannot scroll and must keep following. - const contentScrollsUp = () => timelineRealContentOverflowsViewportStable(); - // The follow re-arm band, not the strict flag: streaming growth makes - // isAtEnd flicker false for a frame before the follow scroll catches - // up, and a gesture landing in that window while still pinned would - // otherwise break follow with no scroll event left to re-arm it. - const viewportIsAwayFromEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; - const handleWheel = (event: WheelEvent) => { - if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) - return; - if (event.deltaY > 0) { - timelineScrollIntentRef.current = "toward-end"; - if (isAtEndRef.current) { - composer.current?.restoreAfterTimelineReachedEnd(); - } - } else if (event.deltaY < 0) { - timelineScrollIntentRef.current = "away-from-end"; - } - if (event.deltaY < 0 && contentScrollsUp()) { - handleManualNavigation(); - } - }; - // Touch direction isn't observable here (touchmove fires on any - // finger motion, scrolling or not), so break only once the drag has - // actually carried the viewport out of the end band — an upward flick - // gets there within its first few events and later touchmoves break. - const handleTouchMove = () => { - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - }; - // Scrollbar drags produce no wheel/touch events; they are the only - // pointerdowns whose target is the scroll node itself rather than a - // message row. - const handlePointerDown = (event: PointerEvent) => { - if (event.target === scrollNode) { - if (contentScrollsUp()) { - handleManualNavigation(); - } - return; - } - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - }; - // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and - // pointer events entirely; without this the timeline yanks back to - // the end on the next stream chunk. Clicking message text can leave - // DOM focus on body, so these keys must also be heard at document. - const handleKeyDown = (event: KeyboardEvent) => { - if ( - !(event.target instanceof Node) || - (!scrollNode.contains(event.target) && - event.target !== document.body && - event.target !== document.documentElement) || - event.defaultPrevented || - event.isComposing || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) || - document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) - ) { - return; - } - if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) - return; - const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; - if ( - scrollNode.contains(event.target) && - !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) - ) - return; - switch (event.key) { - case "PageUp": - case "Home": - case "ArrowUp": - timelineScrollIntentRef.current = "away-from-end"; - if (contentScrollsUp()) { - handleManualNavigation(); - composer.current?.collapseForTimelineScrollKey(event.key); - } - break; - case "PageDown": - case "End": - case "ArrowDown": - timelineScrollIntentRef.current = "toward-end"; - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - composer.current?.collapseForTimelineScrollKey(event.key); - if (isTimelineAtLogicalEndStable()) { - composer.current?.restoreAfterTimelineReachedEnd(); - } - break; - default: - break; - } - }; - // Input inside an embedded frame reaches none of the listeners above, - // but its scroll still chains out to the timeline. - const scrolledUp = createUpwardScrollDetector({ - top: scrollNode.scrollTop, - height: scrollNode.scrollHeight, - }); - const handleScroll = () => { - if ( - scrolledUp({ - top: scrollNode.scrollTop, - height: scrollNode.scrollHeight, - programmatic: - positionedTimelineAnchorRef.current !== null && - positionedTimelineAnchorRef.current !== settledTimelineAnchorRef.current, - }) && - timelineScrollModeRef.current !== "free-scrolling" && - // LegendList updates its scroll state on the next animation frame. - resolveTimelineIsAtEnd({ - contentLength: scrollNode.scrollHeight, - scroll: scrollNode.scrollTop, - scrollLength: scrollNode.clientHeight, - }) === false - ) { - handleManualNavigation(); - } - }; - scrollNode.addEventListener("scroll", handleScroll, { passive: true }); - scrollNode.addEventListener("wheel", handleWheel, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleTouchMove, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handlePointerDown, { - passive: true, - }); - document.addEventListener("keydown", handleKeyDown); - removeListeners = () => { - scrollNode.removeEventListener("scroll", handleScroll); - scrollNode.removeEventListener("wheel", handleWheel); - scrollNode.removeEventListener("touchmove", handleTouchMove); - scrollNode.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }); - }; - attach(12); - return () => { - if (frame !== null) { - cancelAnimationFrame(frame); + const handleNavigation = (navigation: TimelineNavigation) => { + const composer = composerRef.current; + const awayFromEnd = () => resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; + const releases = navigationReleasesFollow(navigation, { + contentScrollsUp: timelineRealContentOverflowsViewport, + awayFromEnd, + }); + if (navigation.source === "wheel") { + visitScroll().intent = navigation.direction === "down" ? "toward-end" : "away-from-end"; + const current = readFollow(); + if (navigation.direction === "down" && (current.mode === "following" || current.atEnd)) { + composer?.restoreAfterTimelineReachedEnd(); + } + } + if (navigation.source === "key") { + visitScroll().intent = navigation.direction === "down" ? "toward-end" : "away-from-end"; + } + if (releases) releaseFollowForNavigation(); + if (navigation.source === "key" && navigation.key !== " ") { + if (navigation.direction === "up") { + if (releases) composer?.collapseForTimelineScrollKey(navigation.key); + } else { + composer?.collapseForTimelineScrollKey(navigation.key); + if (isTimelineAtLogicalEnd()) composer?.restoreAfterTimelineReachedEnd(); } - removeListeners?.(); + } + }; + const handleNavigationStable = useStableCallback(handleNavigation); + /** + * Attached by the timeline's list ref callback, so follow hears the + * viewport's navigation exactly as long as the scroll node exists. + */ + const attachTimelineViewport = (viewport: TimelineViewport) => { + viewportRef.current = viewport; + const unsubscribe = viewport.subscribe(handleNavigationStable); + const controller = createPageScrollController({ + getContainer: () => viewport.scrollNode, + getScrollPaddingBottomPx: followStore.readComposerOverlayHeight, + onScrollStart: handlePageScrollStart, + }); + pageScrollControllerRef.current = controller; + return () => { + unsubscribe(); + controller.dispose(); + if (pageScrollControllerRef.current === controller) pageScrollControllerRef.current = null; + if (viewportRef.current === viewport) viewportRef.current = null; + const scroll = visitScrollRef.current; + scroll?.releaseAnchorScrollHold?.(); + if (scroll) scroll.releaseAnchorScrollHold = null; }; - }, [ - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A thread switch remounts the list's scroll node, so the listeners reattach. - activeThreadId, - composerRef, - isTimelineAtLogicalEndStable, - legendListRef, - timelineRealContentOverflowsViewportStable, - ]); + }; const onTimelineAnchorReady = (messageId: MessageId, anchorIndex: number) => { - if (pendingTimelineAnchorRef.current === messageId) { - pendingTimelineAnchorRef.current = null; - } - activeTimelineAnchorIndexRef.current = anchorIndex; - if (positionedTimelineAnchorRef.current === messageId) { + setLaidOutAnchor(messageId); + const scroll = visitScroll(); + if (scroll.positionedAnchor === messageId) { return; } - positionedTimelineAnchorRef.current = messageId; - settledTimelineAnchorRef.current = null; + stopAnchorPositioning(scroll); + scroll.positionedAnchor = messageId; + // The positioning scroll moves up when the anchor sits above the view. + const releaseHold = viewportRef.current?.holdProgrammaticScroll() ?? (() => {}); + scroll.releaseAnchorScrollHold = releaseHold; + const isPositioning = () => + scroll.positionedAnchor === messageId && listShowsMessage(legendListRef.current, messageId); + // Releasing is idempotent, so an attempt that is no longer current (the + // thread switched, the user navigated) always lets go of its own hold. + const abandon = () => { + releaseHold(); + if (scroll.positionedAnchor === messageId) stopAnchorPositioning(scroll); + }; const positionAnchor = (remainingAttempts: number) => { requestAnimationFrame(() => { - if (positionedTimelineAnchorRef.current !== messageId) { + if (!isPositioning()) { + abandon(); return; } const list = legendListRef.current; if (!list) { if (remainingAttempts > 0) { positionAnchor(remainingAttempts - 1); + } else { + abandon(); } return; } @@ -501,11 +451,12 @@ export function useTimelineFollow(input: TimelineFollowInput) { viewOffset: CHAT_LIST_ANCHOR_OFFSET, }) .then(() => { - if ( - legendListRef.current === list && - positionedTimelineAnchorRef.current === messageId - ) { - settledTimelineAnchorRef.current = messageId; + if (legendListRef.current === list && isPositioning()) { + scroll.settledAnchor = messageId; + releaseHold(); + scroll.releaseAnchorScrollHold = null; + } else { + abandon(); } }); }); @@ -514,35 +465,25 @@ export function useTimelineFollow(input: TimelineFollowInput) { }; const onTimelineAnchorReadyStable = useStableCallback(onTimelineAnchorReady); const onTimelineAnchorSizeChanged = (messageId: MessageId) => { - if (settledTimelineAnchorRef.current !== messageId) { - return; - } - if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { + const scroll = visitScroll(); + if (scroll.settledAnchor !== messageId || readFollow().mode === "following") { return; } const scrollOffset = legendListRef.current?.getState().scroll; if (scrollOffset === undefined) { return; } - if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { - messageId, - offset: scrollOffset, - userScrollGeneration: anchorUserScrollGenerationRef.current, - }; + if (scroll.pendingAnchorScrollRestore === null) { + scroll.pendingAnchorScrollRestore = { messageId, offset: scrollOffset }; } - if (anchorScrollRestoreFrameRef.current !== null) { + if (scroll.anchorScrollRestoreFrame !== null) { return; } - anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { - anchorScrollRestoreFrameRef.current = null; - const pending = pendingAnchorScrollRestoreRef.current; - pendingAnchorScrollRestoreRef.current = null; - if ( - pending && - settledTimelineAnchorRef.current === pending.messageId && - pending.userScrollGeneration === anchorUserScrollGenerationRef.current - ) { + scroll.anchorScrollRestoreFrame = requestAnimationFrame(() => { + scroll.anchorScrollRestoreFrame = null; + const pending = scroll.pendingAnchorScrollRestore; + scroll.pendingAnchorScrollRestore = null; + if (pending && scroll.settledAnchor === pending.messageId) { const list = legendListRef.current; const currentScrollOffset = list?.getState().scroll; if ( @@ -562,30 +503,25 @@ export function useTimelineFollow(input: TimelineFollowInput) { const onToolOutputCollapsedAtEndStable = useStableCallback(onToolOutputCollapsedAtEnd); const onIsAtEndChange = (isAtEnd: boolean) => { - if ( - !isAtEnd && - liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current - ) { - showScrollDebouncer.current.cancel(); + const scroll = visitScroll(); + if (!isAtEnd && readFollow().mode === "following") { + scroll.showScrollDebouncer.cancel(); setShowScrollToBottom(false); return; } - if (isAtEndRef.current === isAtEnd) return; - isAtEndRef.current = isAtEnd; - if (isAtEnd) { - if (timelineScrollIntentRef.current === "toward-end") { + const { before, after } = followStore.dispatch(visit, { + type: isAtEnd ? "reachedEnd" : "leftEnd", + }); + if (after === before) return; + if (after.mode === "following") { + if (scroll.intent === "toward-end") { composerRef.current?.restoreAfterTimelineReachedEnd(); } - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); setTimelineAnchor(releaseChatTimelineAnchor); - showScrollDebouncer.current.cancel(); + scroll.showScrollDebouncer.cancel(); setShowScrollToBottom(false); } else { - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - showScrollDebouncer.current.maybeExecute(); + scroll.showScrollDebouncer.maybeExecute(); } }; const onIsAtEndChangeStable = useStableCallback(onIsAtEndChange); @@ -597,10 +533,14 @@ export function useTimelineFollow(input: TimelineFollowInput) { serverProjection?.visibleTurnItems.some( (row) => row.item.type === "reasoning" && row.item.runId === activeRunningTurnId, ) === true; + // Z: the release scrolls, and that scroll must follow the commit that drops + // the anchored end space; the trigger is streamed thread data. A + // totalSize listener cannot replace it: the anchored end space can already + // be 0, so dropping it changes no size and no list event fires. useLayoutEffect(() => { - if (timelineScrollModeRef.current !== "anchoring-new-turn") return; + if (timelineAnchorMessageId === null || !timelineLiveFollowEnabled) return; if ( - (runningTurnHasReasoning && timelineAnchorMessageId !== null && timelineLiveFollowEnabled) || + runningTurnHasReasoning || shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId: timelineAnchorMessageId, liveFollowEnabled: timelineLiveFollowEnabled, @@ -619,42 +559,10 @@ export function useTimelineFollow(input: TimelineFollowInput) { timelineLiveFollowEnabled, ]); - // A thread switch restores that thread's remembered follow state; the refs - // the scroll handlers read catch up after commit. - const [timelineThreadReset, setTimelineThreadReset] = useState({ - threadKey: activeThreadKey, - followEnd: true, - }); - if (timelineThreadReset.threadKey !== activeThreadKey) { - const followEnd = readTimelinePosition(routeThreadKey)?.atEnd !== false; - setTimelineThreadReset({ threadKey: activeThreadKey, followEnd }); - setTimelineLiveFollowEnabled(followEnd); - setShowScrollToBottom(!followEnd); - } - useEffect(() => { - const { followEnd } = timelineThreadReset; - isAtEndRef.current = followEnd; - timelineScrollIntentRef.current = null; - timelineScrollModeRef.current = followEnd ? "following-end" : "free-scrolling"; - liveFollowUserScrollGenerationRef.current = followEnd - ? anchorUserScrollGenerationRef.current - : null; - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - }, [timelineThreadReset]); - /** Arms the anchor for a message the user just sent, before its run exists. */ const anchorNewTurn = (threadKey: string, messageId: MessageId) => { - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = messageId; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); + followStore.dispatch(visit, { type: "followEnd" }); + visitScroll().showScrollDebouncer.cancel(); setShowScrollToBottom(false); setTimelineAnchor({ threadKey, messageId }); }; @@ -662,9 +570,7 @@ export function useTimelineFollow(input: TimelineFollowInput) { return { showScrollToBottom, timelineLiveFollowEnabled, - cancelPositionRestoreRef, isTimelineAtLogicalEndStable, - cancelTimelineLiveFollowForUserNavigationStable, onComposerPageScrollKeyDownStable, onComposerPageScrollKeyUpStable, onComposerPageScrollReleaseStable, @@ -674,5 +580,7 @@ export function useTimelineFollow(input: TimelineFollowInput) { onToolOutputCollapsedAtEndStable, onIsAtEndChangeStable, anchorNewTurn, + attachTimelineViewport, + attachComposerOverlay: followStore.attachComposerOverlay, }; } diff --git a/apps/web/src/components/chat/useTimelinePositionRestore.ts b/apps/web/src/components/chat/useTimelinePositionRestore.ts index 52cd99c96c4a8..97374d7c19f69 100644 --- a/apps/web/src/components/chat/useTimelinePositionRestore.ts +++ b/apps/web/src/components/chat/useTimelinePositionRestore.ts @@ -1,8 +1,7 @@ -import { useLayoutEffect, useRef } from "react"; import type { LegendListRef } from "@legendapp/list/react"; -import type { AssistantCitationRequest } from "./AssistantCitationSource"; import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; import type { RememberedTimelinePosition } from "./timelineScrollAnchoring"; +import type { TimelineViewport } from "./timelineViewport"; // A streaming reply moves the end every frame, so the target may never hold // still. Past this many frames the list's own end maintenance takes over. @@ -48,146 +47,104 @@ export const measuredEndOffset = (element: HTMLElement) => /** * Scrolls a newly shown thread back to where the user left it, or to its end, - * and reports the thread positioned once the saved row settles. + * and reports the thread positioned once the saved row settles. Any navigation + * on the viewport cancels it where it is. Returns the cancel for the + * operation's owner. */ -export function useTimelinePositionRestore({ - listRef, +export function startTimelinePositionRestore({ + list, + viewport, listIdentityKey, - restoringThreadPosition, - rememberedPosition, - rows, - citationRequest, - cancelPositionRestoreRef, - onManualNavigation, + listOpenedOn, + rememberedPosition: position, + citationPending, + releaseForFind, onPositioned, }: { - readonly listRef: React.RefObject; + readonly list: LegendListRef; + readonly viewport: TimelineViewport; readonly listIdentityKey: string; - readonly restoringThreadPosition: boolean; + /** The identity the mounted list opened on, until it shows another; see nextListOpening. */ + readonly listOpenedOn: string | null; readonly rememberedPosition: RememberedTimelinePosition | undefined; - readonly rows: MessagesTimelineRow[]; - readonly citationRequest: AssistantCitationRequest | null; - readonly cancelPositionRestoreRef: React.RefObject<(() => void) | null> | undefined; - readonly onManualNavigation: () => void; + readonly citationPending: boolean; + /** + * Find was open when the list started showing the thread. Opening find is + * navigation, so follow lets go; the thread still opens where it was left, + * and MessagesTimeline holds find's reveal until this reports it positioned. + */ + readonly releaseForFind: boolean; readonly onPositioned: (identity: string) => void; -}) { - // Preserve initialScrollAtEnd's measured target until the list is reused - // for another thread; scrollToEnd would replace it with an estimated offset. - const listOpenedOnRef = useRef<{ - readonly list: LegendListRef; - identity: string | null; - } | null>(null); - useLayoutEffect(() => { - const list = listRef.current; - const opened = listOpenedOnRef.current; - if (!list) return; - if (opened?.list !== list) { - listOpenedOnRef.current = { list, identity: listIdentityKey }; - } else if (opened.identity !== listIdentityKey) { - opened.identity = null; - } +}): () => void { + if (citationPending) { + // The citation scroll owns positioning once the list has mounted. + onPositioned(listIdentityKey); + return () => {}; + } + let cancelled = false; + const rows = list.getState().data as ReadonlyArray; + const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1; + // Reading history or finding: follow lets go before the restore claims the viewport. + if (position?.atEnd === false || releaseForFind) viewport.navigate({ releasesFollow: true }); + const releaseScrollHold = viewport.holdProgrammaticScroll(); + const stop = () => { + cancelled = true; + releaseScrollHold(); + unsubscribe(); + }; + const unsubscribe = viewport.subscribe(() => { + if (cancelled) return; + stop(); + void list.scrollToOffset({ offset: viewport.scrollNode.scrollTop, animated: false }); + onPositioned(listIdentityKey); }); - useLayoutEffect(() => { - if (!restoringThreadPosition || rows.length === 0) return; - const list = listRef.current; - if (!list) return; - if (citationRequest !== null) { - // The citation scroll owns positioning once the list has mounted. - onPositioned(listIdentityKey); - return; + const finish = () => { + stop(); + onPositioned(listIdentityKey); + }; + const scrolling = + position?.atEnd === false + ? index >= 0 + ? list.scrollToIndex({ + index, + animated: false, + viewPosition: 0, + viewOffset: -position.offsetWithinRow, + }) + : list.scrollToOffset({ offset: position.scrollOffset, animated: false }) + : // initialScrollAtEnd measured this target; scrollToEnd would replace it with an estimate. + listOpenedOn === listIdentityKey + ? undefined + : list.scrollToEnd({ animated: false }); + const settlesAtEnd = position?.atEnd !== false && scrolling !== undefined; + void Promise.resolve(scrolling).then(async () => { + if (cancelled) return; + if (settlesAtEnd) { + await settleListScroll(list, measuredEndOffset, () => cancelled); + } else if (position?.atEnd === false && index >= 0) { + // Keep the saved row mounted until its measured position and the DOM agree. + await settleListScroll( + list, + (element) => { + const state = list.getState(); + const rowIndex = state.indexByKey(position.rowId); + const row = rowIndex === undefined ? undefined : state.elementAtIndex(rowIndex); + if (!row) return undefined; + return Math.max( + 0, + Math.min( + element.scrollTop + + row.getBoundingClientRect().top - + element.getBoundingClientRect().top + + position.offsetWithinRow, + measuredEndOffset(element), + ), + ); + }, + () => cancelled, + ); } - let cancelled = false; - const viewport: HTMLElement | null = list.getScrollableNode(); - const cancelRestoration = () => { - if (cancelled) return; - cancelled = true; - if (viewport) void list.scrollToOffset({ offset: viewport.scrollTop, animated: false }); - onPositioned(listIdentityKey); - }; - const cancelForNavigation = () => { - cancelRestoration(); - onManualNavigation(); - }; - const onScrollKey = (event: globalThis.KeyboardEvent) => { - if ( - ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key) && - !( - event.target instanceof Element && - event.target.closest("input, textarea, [contenteditable=true]") - ) - ) - cancelForNavigation(); - }; - viewport?.addEventListener("wheel", cancelForNavigation, { passive: true }); - viewport?.addEventListener("touchmove", cancelForNavigation, { passive: true }); - viewport?.addEventListener("pointerdown", cancelForNavigation, { passive: true }); - viewport?.ownerDocument.addEventListener("keydown", onScrollKey); - const position = rememberedPosition; - const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1; - if (position?.atEnd === false) onManualNavigation(); - if (cancelPositionRestoreRef) cancelPositionRestoreRef.current = cancelRestoration; - const scrolling = - position?.atEnd === false - ? index >= 0 - ? list.scrollToIndex({ - index, - animated: false, - viewPosition: 0, - viewOffset: -position.offsetWithinRow, - }) - : list.scrollToOffset({ offset: position.scrollOffset, animated: false }) - : listOpenedOnRef.current?.identity === listIdentityKey - ? undefined - : list.scrollToEnd({ animated: false }); - const settlesAtEnd = position?.atEnd !== false && scrolling !== undefined; - void Promise.resolve(scrolling).then(async () => { - if (cancelled) return; - if (settlesAtEnd) { - await settleListScroll(list, measuredEndOffset, () => cancelled); - } else if (position?.atEnd === false && index >= 0) { - // Keep the saved row mounted until its measured position and the DOM agree. - await settleListScroll( - list, - (element) => { - const state = list.getState(); - const rowIndex = state.indexByKey(position.rowId); - const row = rowIndex === undefined ? undefined : state.elementAtIndex(rowIndex); - if (!row) return undefined; - return Math.max( - 0, - Math.min( - element.scrollTop + - row.getBoundingClientRect().top - - element.getBoundingClientRect().top + - position.offsetWithinRow, - measuredEndOffset(element), - ), - ); - }, - () => cancelled, - ); - } - if (!cancelled) onPositioned(listIdentityKey); - }); - return () => { - cancelled = true; - if (cancelPositionRestoreRef?.current === cancelRestoration) { - cancelPositionRestoreRef.current = null; - } - viewport?.removeEventListener("wheel", cancelForNavigation); - viewport?.removeEventListener("touchmove", cancelForNavigation); - viewport?.removeEventListener("pointerdown", cancelForNavigation); - viewport?.ownerDocument.removeEventListener("keydown", onScrollKey); - }; - }, [ - citationRequest, - cancelPositionRestoreRef, - listIdentityKey, - listRef, - onManualNavigation, - onPositioned, - rememberedPosition, - restoringThreadPosition, - rows, - ]); + if (!cancelled) finish(); + }); + return stop; } diff --git a/apps/web/src/components/chat/useTimelineRewind.ts b/apps/web/src/components/chat/useTimelineRewind.ts index 6c7aef0a75693..efe7180ed97be 100644 --- a/apps/web/src/components/chat/useTimelineRewind.ts +++ b/apps/web/src/components/chat/useTimelineRewind.ts @@ -49,7 +49,6 @@ interface TimelineRewindInput { readonly supportsConversationRollback: boolean; readonly activeEnvironmentUnavailable: boolean; readonly activeEnvironmentUnavailableLabel: string | null; - readonly promptRef: { current: string }; readonly composerRef: ComposerHandleRef; readonly composerDraftTarget: ComposerThreadTarget; readonly setThreadError: ReturnType; @@ -76,7 +75,6 @@ export function useTimelineRewind(input: TimelineRewindInput) { supportsConversationRollback, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel, - promptRef, composerRef, composerDraftTarget, setThreadError, @@ -162,7 +160,6 @@ export function useTimelineRewind(input: TimelineRewindInput) { } if (currentRouteThreadKeyRef.current === routeThreadKey) { const nextPrompt = rewind.prompt; - promptRef.current = nextPrompt; composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length }); requestAnimationFrame(() => { if (currentRouteThreadKeyRef.current === routeThreadKey) composerRef.current?.focusAtEnd(); diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index ead9de100264f..157f0c0475bd5 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -1,29 +1,15 @@ -import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { - type EnvironmentConnectionPresentation, - RelayConnectionRegistration, - RelayConnectionTarget, - orchestrationProtocolCompatibilityError, -} from "@t3tools/client-runtime/connection"; +import { RegistryContext, useAtomMount, useAtomValue } from "@effect/atom-react"; +import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { relayOfflineReasonMessage } from "@t3tools/client-runtime/relay"; -import { - isAtomCommandInterrupted, - settlePromise, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; import { type EnvironmentId, resolveEnvironmentMachineKind, type ServerConfig, } from "@t3tools/contracts"; -import type { - RelayClientEnvironmentRecord, - RelayEnvironmentStatusResponse, -} from "@t3tools/contracts/relay"; import * as Option from "effect/Option"; -import { type ReactNode, useEffect, useEffectEvent, useState } from "react"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import { type ReactNode, useContext, useState } from "react"; -import { environmentCatalog } from "~/connection/catalog"; import { cn } from "~/lib/utils"; import { relayEnvironmentDiscovery } from "~/state/relay"; import { useRelayEnvironmentDiscovery } from "~/state/environments"; @@ -34,18 +20,15 @@ import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRo import { Checkbox } from "../ui/checkbox"; import { Button } from "../ui/button"; import { Skeleton } from "../ui/skeleton"; -import { toastManager } from "../ui/toast"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation"; - -const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000; - -function discoveredCompatibilityError( - status: Option.Option | undefined, -) { - const descriptor = status === undefined ? undefined : Option.getOrNull(status)?.descriptor; - return descriptor === undefined ? null : orchestrationProtocolCompatibilityError(descriptor); -} +import { + type ComputerSelection, + connectRelayEnvironment, + discoveredCompatibilityError, + relayConnectingIdsAtom, + makeRelayDiscoveryRefreshAtom, +} from "./relayDiscoveryStore"; export interface SavedCloudEnvironmentConnection { readonly environmentId: EnvironmentId; @@ -72,46 +55,29 @@ export function CloudEnvironmentConnectRows({ primaryEnvironmentId, savedEnvironments, showSavedEnvironments = false, - refreshWhileEmpty = false, empty = null, selection, - onDiscoveryReady, }: { readonly primaryEnvironmentId: EnvironmentId | null; readonly savedEnvironments: ReadonlyArray; readonly showSavedEnvironments?: boolean; - readonly refreshWhileEmpty?: boolean; readonly empty?: ReactNode; - readonly onDiscoveryReady?: () => void; + /** Onboarding pick list: auto-selects new computers and polls discovery while none is listed. */ readonly selection?: { - readonly autoSelectedComputers?: Set; + readonly store: ComputerSelection; readonly selectedIds: ReadonlySet; - readonly onChange: (environmentId: EnvironmentId, selected: boolean) => void; }; }) { + const registry = useContext(RegistryContext); const environmentsState = useRelayEnvironmentDiscovery(); - const registerEnvironment = useAtomCommand(environmentCatalog.register, { - reportFailure: false, - }); + const [refreshOnViewAtom] = useState(makeRelayDiscoveryRefreshAtom); + useAtomMount(selection?.store.discoveryAtom ?? refreshOnViewAtom); const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { reportFailure: false, }); - const refreshDiscoveryWhenIdle = useEffectEvent(async () => { - if (environmentsState.refreshing || environmentsState.offline) return; - await refreshRelayEnvironments(); - }); - const connectRelayEnvironment = (environment: RelayClientEnvironmentRecord) => - registerEnvironment( - new RelayConnectionRegistration({ - target: new RelayConnectionTarget({ - environmentId: environment.environmentId, - label: environment.label, - }), - }), - ); - const [connectingEnvironmentIds, setConnectingEnvironmentIds] = useState< - ReadonlySet - >(new Set()); + const connectingEnvironmentIds = useAtomValue(relayConnectingIdsAtom); + const connectEnvironment = (environment: RelayClientEnvironmentRecord) => + connectRelayEnvironment(registry, environment); const savedById = new Map( savedEnvironments .filter((environment) => environment.relayManaged) @@ -123,68 +89,6 @@ export function CloudEnvironmentConnectRows({ .map((environment) => environment.environmentId), ); - useEffect(() => { - let active = true; - if (onDiscoveryReady || !refreshWhileEmpty || document.visibilityState === "visible") { - void refreshRelayEnvironments().then(() => { - if (active) onDiscoveryReady?.(); - }); - } - return () => { - active = false; - }; - }, [refreshRelayEnvironments, refreshWhileEmpty, onDiscoveryReady]); - - const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { - if ( - discoveredCompatibilityError( - environmentsState.environments.get(environment.environmentId)?.status, - ) !== null - ) - return false; - setConnectingEnvironmentIds((current) => new Set([...current, environment.environmentId])); - const result = await connectRelayEnvironment(environment); - setConnectingEnvironmentIds((current) => { - const next = new Set(current); - next.delete(environment.environmentId); - return next; - }); - if (result._tag === "Success") { - toastManager.add({ - type: "success", - title: savedWithoutRelay.has(environment.environmentId) - ? "T3 Connect route added" - : "Environment added", - description: savedWithoutRelay.has(environment.environmentId) - ? `${environment.label} falls back to T3 Connect when its other routes are unreachable.` - : `Connecting to ${environment.label} through T3 Connect.`, - }); - return true; - } - if (isAtomCommandInterrupted(result)) { - return false; - } - const cause = squashAtomCommandFailure(result); - const message = - cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment."; - const traceId = findErrorTraceId(cause); - console.error("[t3-connect] Could not connect environment", { message, traceId, cause }); - toastManager.add({ - type: "error", - title: "Could not connect environment", - description: message, - data: traceId - ? { - secondaryActionProps: { - children: "Copy trace ID", - onClick: () => void navigator.clipboard?.writeText(traceId), - }, - } - : undefined, - }); - return false; - }; - // Omit machines saved over other routes; onboarding selection must not add a T3 Connect route. const isVisibleEnvironment = (environmentId: EnvironmentId) => environmentId !== primaryEnvironmentId && @@ -193,83 +97,10 @@ export function CloudEnvironmentConnectRows({ const visibleEnvironments = [...environmentsState.environments.values()].filter( ({ environment }) => isVisibleEnvironment(environment.environmentId), ); - const selectNewComputers = useEffectEvent( - (environments: typeof environmentsState.environments) => { - const seen = selection?.autoSelectedComputers; - if (!selection || !seen) return; - for (const { environment, status, availability } of environments.values()) { - if (!isVisibleEnvironment(environment.environmentId)) continue; - const id = environment.environmentId; - if (availability === "checking") continue; - if ( - discoveredCompatibilityError(status) !== null || - savedById.get(id)?.connection.phase === "unsupported" - ) { - seen.add(id); - if (selection.selectedIds.has(id)) selection.onChange(id, false); - continue; - } - if (seen.has(id)) continue; - seen.add(id); - selection.onChange(id, true); - if (!savedById.has(id)) { - void connectEnvironment(environment).then((connected) => { - if (!connected) selection.onChange(id, false); - }); - } - } - }, - ); - useEffect(() => { - // oxlint-disable-next-line react/set-state-in-effect -- Discovery results arrive from an external store and auto-selection starts relay connections, which mark themselves connecting. - selectNewComputers(environmentsState.environments); - }, [environmentsState.environments]); - - // Discovery clears its list on refresh, so poll only until a machine appears. - const shouldRefreshWhileEmpty = - refreshWhileEmpty && visibleEnvironments.length === 0 && !environmentsState.offline; - - useEffect(() => { - if (!shouldRefreshWhileEmpty) return; - let timer: ReturnType | undefined; - let disposed = false; - let pending = false; - const visible = () => document.visibilityState === "visible"; - const schedule = () => { - clearTimeout(timer); - if (!disposed && visible()) { - timer = setTimeout(() => void refresh(), EMPTY_DISCOVERY_REFRESH_INTERVAL_MS); - } - }; - const refresh = async () => { - if (disposed || pending || !visible()) return; - clearTimeout(timer); - pending = true; - await settlePromise(() => refreshDiscoveryWhenIdle()); - pending = false; - schedule(); - }; - const onFocus = () => void refresh(); - const onVisibilityChange = () => { - clearTimeout(timer); - if (visible()) void refresh(); - }; - - schedule(); - window.addEventListener("focus", onFocus); - document.addEventListener("visibilitychange", onVisibilityChange); - return () => { - disposed = true; - clearTimeout(timer); - window.removeEventListener("focus", onFocus); - document.removeEventListener("visibilitychange", onVisibilityChange); - }; - }, [shouldRefreshWhileEmpty]); - const standalone = showSavedEnvironments || savedEnvironments.length === 0; if ( - !refreshWhileEmpty && + !selection && standalone && visibleEnvironments.length === 0 && environmentsState.refreshing && @@ -373,10 +204,10 @@ export function CloudEnvironmentConnectRows({ disabled={unsupported || connectingEnvironmentIds.has(environment.environmentId)} onCheckedChange={async (checked) => { if (unsupported) return; - selection.onChange(environment.environmentId, checked); + selection.store.choose(environment.environmentId, checked); if (checked && !savedEnvironment) { const connected = await connectEnvironment(environment); - if (!connected) selection.onChange(environment.environmentId, false); + if (!connected) selection.store.choose(environment.environmentId, false); } }} /> diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 35092effaa882..61661373a33d0 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -71,13 +71,6 @@ export function resolveComposerTimelineInset(input: { : overlayHeight; } -export function overlayComposerIsResting(input: { - composerMounted: boolean; - composerReportedResting: boolean; -}): boolean { - return input.composerMounted && input.composerReportedResting; -} - export function shouldAnimateComposerRestingTransition(input: { hasCompletedInitialLayout: boolean; stateChanged: boolean; diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts index 4e29d1ebc363e..7c10fba19924c 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts +++ b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts @@ -5,10 +5,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import type { DesktopSnapShotBridge } from "../../lib/desktopSnapShot"; import * as imageCompression from "../../lib/imageCompression"; +import { countQuestionAttachments, questionAttachmentDraftId } from "../../questionAttachments"; import { - questionAttachmentDraftId, - trackOpenQuestionAttachmentDraft, -} from "../../questionAttachments"; + countQuestionRequestAttachments, + openQuestionAttachmentDraft, +} from "../chat/usePendingThreadRequests"; import { beginSnapShotAnimationWhenReady, deliverSnapShot, @@ -28,6 +29,7 @@ vi.mock(import("../../hooks/useSettings"), { spy: true }); vi.mock(import("../../hooks/useHandleNewThread"), { spy: true }); vi.mock(import("../../lib/snapShotSound"), { spy: true }); vi.mock(import("../../lib/imageCompression"), { spy: true }); +vi.mock(import("../chat/usePendingThreadRequests"), { spy: true }); const storage = vi.hoisted(() => { const values = new Map(); @@ -51,6 +53,8 @@ const projectRef = scopeProjectRef(environmentId, ProjectId.make("snap-shot-proj // Tests stub globals and spy on module exports freely; disposing restores all of them. function setup(): Disposable { storage.clear(); + vi.mocked(openQuestionAttachmentDraft).mockReset(); + vi.mocked(countQuestionRequestAttachments).mockReset(); vi.stubGlobal("localStorage", storage); useComposerDraftStore.setState({ draftsByThreadKey: {}, @@ -124,6 +128,15 @@ describe("open question delivery", () => { windowTitle: "main.ts", }, }; + // Stands in for the composer showing a question: captures read the open draft at use time. + const showQuestion = (draft: typeof request | null) => { + vi.mocked(openQuestionAttachmentDraft).mockImplementation((ref) => + ref.threadId === threadRef.threadId ? draft : null, + ); + vi.mocked(countQuestionRequestAttachments).mockImplementation((id) => + draft?.draftId === id ? countQuestionAttachments(draft.requestKeys) : null, + ); + }; const makeBridge = (readSnapShot: () => Promise = async () => capture) => { const acknowledgeSnapShot = vi.fn(async () => undefined); const bridge = { readSnapShot, acknowledgeSnapShot } as unknown as DesktopSnapShotBridge; @@ -133,39 +146,43 @@ describe("open question delivery", () => { it("attaches a capture to the open question draft and leaves the thread draft alone", async () => { using _env = setup(); const pins = new Map(); - const untrack = trackOpenQuestionAttachmentDraft(threadRef, request); - try { - const { bridge, acknowledgeSnapShot } = makeBridge(); - await deliverSnapShot(bridge, capture, () => - resolveSnapShotAttachmentTarget(pins, capture.id, threadRef), - ); - const store = useComposerDraftStore.getState(); - expect(store.getComposerDraft(first)?.images.map(({ id }) => id)).toEqual([capture.id]); - expect(store.getComposerDraft(threadRef)?.images ?? []).toHaveLength(0); - expect(acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); - } finally { - untrack(); - } + showQuestion(request); + const { bridge, acknowledgeSnapShot } = makeBridge(); + await deliverSnapShot(bridge, capture, () => + resolveSnapShotAttachmentTarget(pins, capture.id, threadRef, threadRef), + ); + const store = useComposerDraftStore.getState(); + expect(store.getComposerDraft(first)?.images.map(({ id }) => id)).toEqual([capture.id]); + expect(store.getComposerDraft(threadRef)?.images ?? []).toHaveLength(0); + expect(acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); + }); + + it("leaves a thread that is not on screen to its own draft", () => { + using _env = setup(); + showQuestion(request); + const otherRoute = scopeThreadRef(environmentId, ThreadId.make("other-thread")); + expect(resolveSnapShotAttachmentTarget(new Map(), capture.id, threadRef, otherRoute)).toEqual( + threadRef, + ); + expect(resolveSnapShotAttachmentTarget(new Map(), capture.id, threadRef, null)).toEqual( + threadRef, + ); }); it("falls back to the thread when a newer question replaces the pinned one during compression", async () => { using _env = setup(); const pins = new Map(); - const untrackFirst = trackOpenQuestionAttachmentDraft(threadRef, request); - let untrackSecond = () => {}; + showQuestion(request); const compression = vi .spyOn(imageCompression, "compressImageToByteLimit") .mockImplementationOnce(async (file) => { - untrackSecond = trackOpenQuestionAttachmentDraft(threadRef, { - ...request, - draftId: second, - }); - untrackFirst(); + showQuestion({ ...request, draftId: second }); return { ok: true, file, recompressed: false }; }); try { const { bridge, acknowledgeSnapShot } = makeBridge(); - const resolveTarget = () => resolveSnapShotAttachmentTarget(pins, capture.id, threadRef); + const resolveTarget = () => + resolveSnapShotAttachmentTarget(pins, capture.id, threadRef, threadRef); expect(resolveTarget()).toBe(first); await deliverSnapShot(bridge, capture, resolveTarget); const store = useComposerDraftStore.getState(); @@ -175,31 +192,25 @@ describe("open question delivery", () => { expect(acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); } finally { compression.mockRestore(); - untrackFirst(); - untrackSecond(); } }); it("keeps a capture pinned before any question off a question that opens during the read", async () => { using _env = setup(); const pins = new Map(); - const resolveTarget = () => resolveSnapShotAttachmentTarget(pins, capture.id, threadRef); + const resolveTarget = () => + resolveSnapShotAttachmentTarget(pins, capture.id, threadRef, threadRef); // The drain pins a capture it never saw requested before reading it. expect(resolveTarget()).toEqual(threadRef); - let untrack = () => {}; const { bridge, acknowledgeSnapShot } = makeBridge(async () => { - untrack = trackOpenQuestionAttachmentDraft(threadRef, request); + showQuestion(request); return capture; }); - try { - await deliverSnapShot(bridge, capture, resolveTarget); - const store = useComposerDraftStore.getState(); - expect(store.getComposerDraft(first)?.images ?? []).toHaveLength(0); - expect(store.getComposerDraft(threadRef)?.images.map(({ id }) => id)).toEqual([capture.id]); - expect(acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); - } finally { - untrack(); - } + await deliverSnapShot(bridge, capture, resolveTarget); + const store = useComposerDraftStore.getState(); + expect(store.getComposerDraft(first)?.images ?? []).toHaveLength(0); + expect(store.getComposerDraft(threadRef)?.images.map(({ id }) => id)).toEqual([capture.id]); + expect(acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); }); }); diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.tsx b/apps/web/src/components/desktop/SnapShotCoordinator.tsx index 132dcdccf9ff6..8a3dde784dbfc 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.tsx +++ b/apps/web/src/components/desktop/SnapShotCoordinator.tsx @@ -5,17 +5,22 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ScopedThreadRef, } from "@t3tools/contracts"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import type { AnyRouter } from "@tanstack/react-router"; import { type DraftId, type PersistedComposerImageAttachment, useComposerDraftStore, } from "../../composerDraftStore"; -import { useHandleNewThread } from "../../hooks/useHandleNewThread"; -import { useStableCallback } from "../../hooks/useStableCallback"; -import { getClientSettings, useClientSettings } from "../../hooks/useSettings"; -import { readThreadShell } from "../../state/entities"; +import { + createNewThreadHandler, + type NewThreadHandler, + resolveDefaultProjectRef, +} from "../../hooks/useHandleNewThread"; +import { getClientSettings, subscribeClientSettings } from "../../hooks/useSettings"; +import { readProjects, readThreadShell } from "../../state/entities"; +import { resolveThreadRouteTarget } from "../../threadRoutes"; +import { useUiStateStore } from "../../uiStateStore"; import { compressImageToByteLimit, dataUrlToFile } from "../../lib/imageCompression"; import { resolveThreadActionProjectRef } from "../../lib/chatThreadActions"; import { @@ -37,7 +42,7 @@ import { import { countQuestionRequestAttachments, openQuestionAttachmentDraft, -} from "../../questionAttachments"; +} from "../chat/usePendingThreadRequests"; import { readFileAsDataUrl } from "../ChatView.logic"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -49,6 +54,14 @@ function isSameCaptureTarget(a: CaptureTarget, b: CaptureTarget): boolean { return a.environmentId === b.environmentId && a.threadId === b.threadId; } +function isSameRouteThread(target: ScopedThreadRef, routeThreadRef: ScopedThreadRef | null) { + return ( + routeThreadRef !== null && + routeThreadRef.environmentId === target.environmentId && + routeThreadRef.threadId === target.threadId + ); +} + export function resolveExistingSnapShotTarget( target: CaptureTarget, routeThreadRef: ScopedThreadRef | null, @@ -59,24 +72,25 @@ export function resolveExistingSnapShotTarget( if (draftSession?.promotedTo) return draftSession.promotedTo; return draftSession ? target : null; } - const targetIsCurrentRoute = - routeThreadRef !== null && - routeThreadRef.environmentId === target.environmentId && - routeThreadRef.threadId === target.threadId; - return targetIsCurrentRoute || + return isSameRouteThread(target, routeThreadRef) || store.getDraftSessionByRef(target) !== null || readThreadShell(target) !== null ? target : null; } -// Pin absence too: a later question must never claim an earlier capture. +// Pin absence too: a later question must never claim an earlier capture. Only the thread on +// screen shows its question, so a capture for any other thread goes to that thread's draft. export function resolveSnapShotAttachmentTarget( pins: Map, id: string, target: CaptureTarget, + routeThreadRef: ScopedThreadRef | null, ): CaptureTarget { - const open = typeof target === "string" ? null : openQuestionAttachmentDraft(target); + const open = + typeof target !== "string" && isSameRouteThread(target, routeThreadRef) + ? openQuestionAttachmentDraft(target) + : null; if (!pins.has(id)) pins.set(id, open?.draftId ?? null); const pinned = pins.get(id); return pinned && pinned === open?.draftId ? pinned : target; @@ -224,70 +238,90 @@ export async function deliverSnapShot( dispatchSnapShotComposerFocus(); } -export function SnapShotCoordinator() { - const { - activeDraftThread, - activeThread, - defaultProjectRef, +function readRouteTarget(router: AnyRouter) { + const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + return { + routeThreadRef: target?.kind === "server" ? target.threadRef : null, + routeDraftId: target?.kind === "draft" ? target.draftId : null, + }; +} + +/** The project a capture opens a new draft in when no thread or draft can take it. */ +function readCaptureProjectRef(router: AnyRouter, handleNewThread: NewThreadHandler) { + const { routeThreadRef, routeDraftId } = readRouteTarget(router); + const drafts = useComposerDraftStore.getState(); + return resolveThreadActionProjectRef({ + activeDraftThread: routeThreadRef + ? drafts.getDraftThread(routeThreadRef) + : routeDraftId + ? drafts.getDraftSession(routeDraftId) + : null, + activeThread: (routeThreadRef ? readThreadShell(routeThreadRef) : null) ?? undefined, + defaultProjectRef: resolveDefaultProjectRef( + readProjects(), + useUiStateStore.getState().projectOrder, + ), handleNewThread, - routeDraftId, - routeThreadRef, - } = useHandleNewThread(); - const captureSound = useClientSettings((settings) => - settings.snapShotPlaySound ? settings.snapShotSound : null, - ); - const animateCaptures = useClientSettings((settings) => settings.snapShotAnimations); - const enabled = useClientSettings((settings) => settings.snapShotEnabled); - const captureTargetsRef = useRef(new Map>()); - const questionPinsRef = useRef(new Map()); - const targetResolutionRef = useRef | null>(null); - const drainingRef = useRef | null>(null); - const rerunRequestedRef = useRef(false); - const soundedCaptureIdsRef = useRef(new Set()); - const reportedCaptureFailuresRef = useRef(new Map>()); - const pendingAnimationStartsRef = useRef(new Set()); + }); +} + +/** + * Delivers desktop window captures into the thread or draft on screen. Captures + * arrive from the desktop shell at any time, so this runs for the life of the app + * and follows the snapShotEnabled setting. + */ +export function startSnapShotCoordinator(router: AnyRouter): () => void { + const bridge = getDesktopSnapShotBridge(); + if (!bridge) return () => {}; + const handleNewThread = createNewThreadHandler(router); + const captureTargets = new Map>(); + const questionPins = new Map(); + const targetResolution: { current: Promise | null } = { current: null }; + let draining: Promise | null = null; + let rerunRequested = false; + const soundedCaptureIds = new Set(); + const reportedCaptureFailures = new Map>(); + const pendingAnimationStarts = new Set(); // Captures go to the last thread or draft on screen, even from routes that show neither. - const currentTarget = routeThreadRef ?? routeDraftId; - const [lastRouteTarget, setLastRouteTarget] = useState(currentTarget); - if ( - currentTarget && - (lastRouteTarget === null || !isSameCaptureTarget(currentTarget, lastRouteTarget)) - ) { - setLastRouteTarget(currentTarget); - } + const currentRouteTarget = (): CaptureTarget | null => { + const { routeThreadRef, routeDraftId } = readRouteTarget(router); + return routeThreadRef ?? routeDraftId; + }; + let lastRouteTarget = currentRouteTarget(); + const recordRouteTarget = () => { + const current = currentRouteTarget(); + if (current && (lastRouteTarget === null || !isSameCaptureTarget(current, lastRouteTarget))) { + lastRouteTarget = current; + } + }; + const routeThreadRef = () => readRouteTarget(router).routeThreadRef; + // Reuse the resolved target until navigation so captures in one drain share a draft. - const settledTargetRef = useRef<{ + // A resolution records it against the route it started from, so a route change while it is + // pending cannot hand that draft to the new route. + let settledTarget: { readonly routeTarget: CaptureTarget | null; readonly target: CaptureTarget | null; - } | null>(null); - const readLastRouteTarget = useStableCallback(() => lastRouteTarget); - // Scope pending resolutions to their starting route so navigation cannot inherit another route's draft. - const readSettledTarget = (routeTarget: CaptureTarget | null) => { - const settled = settledTargetRef.current; - return settled && settled.routeTarget === routeTarget ? settled.target : routeTarget; - }; + } | null = null; + const readSettledTarget = (routeTarget: CaptureTarget | null) => + settledTarget && settledTarget.routeTarget === routeTarget ? settledTarget.target : routeTarget; const settleTarget = (routeTarget: CaptureTarget | null, target: CaptureTarget | null) => { - settledTargetRef.current = { routeTarget, target }; + settledTarget = { routeTarget, target }; }; const resolveTarget = async (): Promise => { - const routeTarget = readLastRouteTarget(); + const routeTarget = lastRouteTarget; const lastTarget = readSettledTarget(routeTarget); if (lastTarget) { - const existingTarget = resolveExistingSnapShotTarget(lastTarget, routeThreadRef); + const existingTarget = resolveExistingSnapShotTarget(lastTarget, routeThreadRef()); if (existingTarget) { settleTarget(routeTarget, existingTarget); return existingTarget; } settleTarget(routeTarget, null); } - const projectRef = resolveThreadActionProjectRef({ - activeDraftThread, - activeThread: activeThread ?? undefined, - defaultProjectRef, - handleNewThread, - }); + const projectRef = readCaptureProjectRef(router, handleNewThread); if (!projectRef) return null; const created = await handleNewThread(projectRef); if (!created) return null; @@ -295,13 +329,14 @@ export function SnapShotCoordinator() { return created.draftId; }; - const resolveCaptureTarget = () => resolveSnapShotTargetOnce(targetResolutionRef, resolveTarget); + const resolveCaptureTarget = () => resolveSnapShotTargetOnce(targetResolution, resolveTarget); const playCaptureSound = (id: string) => { - if (!captureSound || soundedCaptureIdsRef.current.has(id)) return; - soundedCaptureIdsRef.current.add(id); + const settings = getClientSettings(); + if (!settings.snapShotPlaySound || soundedCaptureIds.has(id)) return; + soundedCaptureIds.add(id); try { - playSnapShotSound(captureSound); + playSnapShotSound(settings.snapShotSound); } catch {} }; @@ -311,37 +346,35 @@ export function SnapShotCoordinator() { toast: Parameters[0], ) => { if (!getClientSettings().snapShotEnabled) return; - const failures = reportedCaptureFailuresRef.current.get(id) ?? new Set(); + const failures = reportedCaptureFailures.get(id) ?? new Set(); if (failures.has(failure)) return; failures.add(failure); - reportedCaptureFailuresRef.current.set(id, failures); + reportedCaptureFailures.set(id, failures); toastManager.add(toast); }; const drain = async () => { if (!getClientSettings().snapShotEnabled) return; - const bridge = getDesktopSnapShotBridge(); - if (!bridge) return; - if (drainingRef.current) { - rerunRequestedRef.current = true; - return drainingRef.current; + if (draining) { + rerunRequested = true; + return draining; } const operation = (async () => { do { - rerunRequestedRef.current = false; + rerunRequested = false; const pending = await bridge.listPendingSnapShots(); for (const item of pending) { if (!getClientSettings().snapShotEnabled) return; playCaptureSound(item.id); const capturedTarget = await resolveSnapShotDeliveryTarget( - captureTargetsRef.current, + captureTargets, item.id, resolveCaptureTarget, ); if (!getClientSettings().snapShotEnabled) return; const target = capturedTarget - ? resolveExistingSnapShotTarget(capturedTarget, routeThreadRef) + ? resolveExistingSnapShotTarget(capturedTarget, routeThreadRef()) : null; if (!target) { await dismissSnapShotAnimation(item.id); @@ -359,14 +392,14 @@ export function SnapShotCoordinator() { try { // Pending captures may have arrived without a requested event; pin before reading. - resolveSnapShotAttachmentTarget(questionPinsRef.current, item.id, target); + resolveSnapShotAttachmentTarget(questionPins, item.id, target, routeThreadRef()); await deliverSnapShot(bridge, item, () => - resolveSnapShotAttachmentTarget(questionPinsRef.current, item.id, target), + resolveSnapShotAttachmentTarget(questionPins, item.id, target, routeThreadRef()), ); - captureTargetsRef.current.delete(item.id); - questionPinsRef.current.delete(item.id); - soundedCaptureIdsRef.current.delete(item.id); - reportedCaptureFailuresRef.current.delete(item.id); + captureTargets.delete(item.id); + questionPins.delete(item.id); + soundedCaptureIds.delete(item.id); + reportedCaptureFailures.delete(item.id); } catch (error) { await dismissSnapShotAnimation(item.id); reportOnce( @@ -382,7 +415,7 @@ export function SnapShotCoordinator() { ); } } - } while (rerunRequestedRef.current && getClientSettings().snapShotEnabled); + } while (rerunRequested && getClientSettings().snapShotEnabled); })() .catch((error: unknown) => { dismissAllSnapShotAnimations(); @@ -396,106 +429,110 @@ export function SnapShotCoordinator() { ); }) .finally(() => { - drainingRef.current = null; + draining = null; }); - drainingRef.current = operation; + draining = operation; return operation; }; - const drainPending = useEffectEvent(() => drain()); - const handleSnapShotEvent = useEffectEvent( - (bridge: DesktopSnapShotBridge, event: DesktopSnapShotEvent) => { - if (!getClientSettings().snapShotEnabled) return; - switch (event.type) { - case "requested": { - const current = readSettledTarget(readLastRouteTarget()); - const target = current ? resolveExistingSnapShotTarget(current, routeThreadRef) : null; - // Creating a new draft would navigate the renderer before a self-capture finishes. - if (target) { - resolveSnapShotAttachmentTarget(questionPinsRef.current, event.id, target); - void resolveSnapShotDeliveryTarget(captureTargetsRef.current, event.id, () => - Promise.resolve(target), - ); - } else if (!questionPinsRef.current.has(event.id)) { - questionPinsRef.current.set(event.id, null); - } - return; - } - case "started": { - playCaptureSound(event.id); - const target = resolveSnapShotDeliveryTarget( - captureTargetsRef.current, - event.id, - resolveCaptureTarget, - ).then( - (target) => - target && resolveSnapShotAttachmentTarget(questionPinsRef.current, event.id, target), + const handleSnapShotEvent = (event: DesktopSnapShotEvent) => { + if (!getClientSettings().snapShotEnabled) return; + switch (event.type) { + case "requested": { + const current = readSettledTarget(lastRouteTarget); + const target = current ? resolveExistingSnapShotTarget(current, routeThreadRef()) : null; + // Creating a new draft would navigate the renderer before a self-capture finishes. + if (target) { + resolveSnapShotAttachmentTarget(questionPins, event.id, target, routeThreadRef()); + void resolveSnapShotDeliveryTarget(captureTargets, event.id, () => + Promise.resolve(target), ); - if (animateCaptures && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - void beginSnapShotAnimationWhenReady( - event.id, - target, - pendingAnimationStartsRef.current, - ); - } - return; + } else if (!questionPins.has(event.id)) { + questionPins.set(event.id, null); } - case "ready": - void drain(); - return; - case "failed": { - if (event.id) { - captureTargetsRef.current.delete(event.id); - questionPinsRef.current.delete(event.id); - } - dismissFailedSnapShot( - event.id, - soundedCaptureIdsRef.current, - pendingAnimationStartsRef.current, - ); - void bridge.getSnapShotState().then((state) => { - if (!getClientSettings().snapShotEnabled) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Snapshot failed", - description: state.message ?? "Try the capture again.", - }), - ); - }); - return; + return; + } + case "started": { + playCaptureSound(event.id); + const target = resolveSnapShotDeliveryTarget( + captureTargets, + event.id, + resolveCaptureTarget, + ).then( + (target) => + target && + resolveSnapShotAttachmentTarget(questionPins, event.id, target, routeThreadRef()), + ); + if ( + getClientSettings().snapShotAnimations && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + void beginSnapShotAnimationWhenReady(event.id, target, pendingAnimationStarts); } - case "shortcut-changed": - return; + return; } - }, - ); - useEffect(() => { - const bridge = getDesktopSnapShotBridge(); - if (!bridge || !enabled) return; - void drainPending(); - return bridge.onSnapShotEvent((event) => handleSnapShotEvent(bridge, event)); - }, [enabled]); + case "ready": + void drain(); + return; + case "failed": { + if (event.id) { + captureTargets.delete(event.id); + questionPins.delete(event.id); + } + dismissFailedSnapShot(event.id, soundedCaptureIds, pendingAnimationStarts); + void bridge.getSnapShotState().then((state) => { + if (!getClientSettings().snapShotEnabled) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Snapshot failed", + description: state.message ?? "Try the capture again.", + }), + ); + }); + return; + } + case "shortcut-changed": + return; + } + }; - useEffect(() => { - const dismissOnBlur = () => { - pendingAnimationStartsRef.current.clear(); - dismissAllSnapShotAnimations(); - }; - const drainOnFocus = () => void drainPending(); - const handleVisibilityChange = () => { - if (document.visibilityState === "hidden") dismissOnBlur(); - else void drainPending(); - }; - window.addEventListener("blur", dismissOnBlur); - window.addEventListener("focus", drainOnFocus); - document.addEventListener("visibilitychange", handleVisibilityChange); - return () => { - window.removeEventListener("blur", dismissOnBlur); - window.removeEventListener("focus", drainOnFocus); - document.removeEventListener("visibilitychange", handleVisibilityChange); - }; - }, []); + // Listens to the desktop only while captures are enabled; enabling drains what queued meanwhile. + let stopEvents: (() => void) | null = null; + const followEnabled = () => { + const enabled = getClientSettings().snapShotEnabled; + if (enabled && stopEvents === null) { + stopEvents = bridge.onSnapShotEvent(handleSnapShotEvent); + void drain(); + } else if (!enabled && stopEvents !== null) { + stopEvents(); + stopEvents = null; + } + }; - return null; + const dismissOnBlur = () => { + pendingAnimationStarts.clear(); + dismissAllSnapShotAnimations(); + }; + const drainOnFocus = () => void drain(); + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") dismissOnBlur(); + else void drain(); + }; + + const unsubscribeRoute = router.subscribe("onResolved", recordRouteTarget); + const unsubscribeSettings = subscribeClientSettings(followEnabled); + followEnabled(); + window.addEventListener("blur", dismissOnBlur); + window.addEventListener("focus", drainOnFocus); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + unsubscribeRoute(); + unsubscribeSettings(); + stopEvents?.(); + stopEvents = null; + window.removeEventListener("blur", dismissOnBlur); + window.removeEventListener("focus", drainOnFocus); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; } diff --git a/apps/web/src/components/guest/GuestPage.tsx b/apps/web/src/components/guest/GuestPage.tsx index 28b281618661f..f8268d9fe492e 100644 --- a/apps/web/src/components/guest/GuestPage.tsx +++ b/apps/web/src/components/guest/GuestPage.tsx @@ -392,11 +392,9 @@ function GuestThread({ const follow = useTimelineFollow({ legendListRef: listRef, composerRef: NO_COMPOSER, - composerOverlayElement: null, composerTimelineInset: 0, routeThreadKey: threadKey, activeThreadKey: threadKey, - activeThreadId: undefined, // Not a server thread: new turns follow the end instead of anchoring near the top. isServerThread: false, serverProjection: null, @@ -476,8 +474,7 @@ function GuestThread({ emptyPlaceholderInset={0} liveFollowEnabled={follow.timelineLiveFollowEnabled} onIsAtEndChange={follow.onIsAtEndChangeStable} - onManualNavigation={follow.cancelTimelineLiveFollowForUserNavigationStable} - cancelPositionRestoreRef={follow.cancelPositionRestoreRef} + onViewportAttach={follow.attachTimelineViewport} footer={ unplaced.length === 0 ? null : (
      diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx index 9428a64a338bd..5f4bc3fbb69e7 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx @@ -35,6 +35,7 @@ const state = vi.hoisted(() => ({ onDone: vi.fn(), scan: vi.fn(), refreshScan: vi.fn(), + waitForProject: vi.fn(), })); vi.mock(import("@t3tools/client-runtime/state/session"), async (original) => { @@ -87,6 +88,7 @@ vi.mock(import("../../state/entities"), async (original) => { ...(await original()), useProjects: (() => useAtomValue(state.projects as Atom.Atom)) as never, readProjects: (() => appAtomRegistry.get(state.projects as Atom.Atom)) as never, + waitForProject: ((ref: unknown) => state.waitForProject(ref)) as never, }; }); vi.mock(import("../../state/projects"), async (original) => ({ @@ -183,6 +185,27 @@ function recordProject({ environmentId, input }: CreateProjectInput) { return AsyncResult.success(undefined); } +// Resolves once the project reaches the mocked projects atom; the real helper's timeout is +// replaced per test with a rejection. +function waitForRecordedProject(ref: { environmentId: EnvironmentId; projectId: string }) { + const find = () => + appAtomRegistry + .get(projectsAtom()) + .find( + (project) => project.id === ref.projectId && project.environmentId === ref.environmentId, + ); + return new Promise((resolve) => { + const current = find(); + if (current) return resolve(current); + const unsubscribe = appAtomRegistry.subscribe(projectsAtom(), () => { + const project = find(); + if (!project) return; + unsubscribe(); + resolve(project); + }); + }); +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((complete) => { @@ -255,6 +278,7 @@ function setup() { state.onDone.mockReset(); state.scan.mockReset(); state.refreshScan.mockReset(); + state.waitForProject.mockReset().mockImplementation(waitForRecordedProject); return now; } @@ -304,3 +328,23 @@ it("completes accepted imports when permission is revoked after the final server expect(state.completeOnboarding).toHaveBeenCalledOnce(); expect(state.onDone).toHaveBeenCalledOnce(); }); + +it("finishes setup without landing when the imported project never reaches the client", async () => { + using _now = setup(); + state.candidates = state.candidates.slice(0, 1); + // The server accepts the project, but it never shows up in the client's projects. + state.createProject.mockResolvedValue(AsyncResult.success(undefined)); + state.waitForProject.mockRejectedValue(new Error("The project did not appear.")); + state.completeOnboarding.mockRejectedValueOnce(new Error("offline")); + await using _screen = await mountImport(); + + await click("Import 1 project"); + await vi.waitFor(() => expect(state.completeOnboarding).toHaveBeenCalledOnce()); + // A failed completion hands the step back instead of leaving it importing. + await vi.waitFor(() => expect(button("Import 1 project").disabled).toBe(false)); + expect(state.onDone).not.toHaveBeenCalled(); + + await click("Import 1 project"); + await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce()); + expect(state.onDone).toHaveBeenCalledWith(undefined); +}); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 5545ea2e4cf14..1815215598862 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -1,5 +1,5 @@ import { useAuth } from "@clerk/react"; -import { useAtomValue } from "@effect/atom-react"; +import { RegistryContext, useAtomValue } from "@effect/atom-react"; import type { AgentSessionProjectCandidate, EnvironmentId, @@ -32,7 +32,7 @@ import { MonitorIcon, TerminalIcon, } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; import { useLocalStorage } from "../../hooks/useLocalStorage"; @@ -57,8 +57,8 @@ import { } from "../../onboarding/providerReadiness.logic"; import { newProjectId, randomUUID } from "../../lib/utils"; import { agentSessionImport } from "../../state/agentSessions"; -import { readProjects, useProjects } from "../../state/entities"; -import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; +import { readProjects, waitForProject } from "../../state/entities"; +import { useEnvironments } from "../../state/environments"; import { isOnboardingRelayEnvironment, resolveOnboardingSetup, @@ -82,6 +82,11 @@ import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic"; import { TerminalViewport } from "../ThreadTerminalDrawer"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; +import { + type ComputerSelection, + createComputerSelection, + resolveSelectedComputers, +} from "../cloud/relayDiscoveryStore"; import { presentSavedCloudEnvironmentConnection } from "../cloud/cloudEnvironmentConnectionPresentation"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { T3Wordmark } from "../T3Wordmark"; @@ -118,37 +123,29 @@ export function WelcomeWizard({ }) { const [step, setStep] = useState(resumeEnvironmentId ? "agents" : "connection"); const { environments } = useEnvironments(); - const [selection, setSelection] = useState | null>(null); - // Share the registry for the wizard's lifetime so unchecked computers are not auto-selected again. - const [autoSelectedComputers] = useState(() => new Set()); + const registry = useContext(RegistryContext); + // One selection for the wizard's lifetime, shared with the connect list, so a computer is + // auto-selected once even after the user unchecks it. + const [computerSelection] = useState(() => + createComputerSelection(registry, { waitForHealthChecks: false, refreshWhileHidden: true }), + ); + const selectedIds = resolveSelectedComputers( + environments, + useAtomValue(computerSelection.choicesAtom), + ); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const enterAgentsStep = (ids: readonly EnvironmentId[]) => { + for (const environmentId of ids) void refreshProviders({ environmentId, input: {} }); + setStep("agents"); + }; const [setupIds, setSetupIds] = useState( resumeEnvironmentId ? [resumeEnvironmentId] : [], ); const [isImporting, setIsImporting] = useState(false); const finishingPromiseRef = useRef | null>(null); const completionErrorToastIdRef = useRef | null>(null); - const primaryEnvironment = usePrimaryEnvironment(); - useEffect(() => { - const newComputers = environments.filter( - (environment) => !autoSelectedComputers.has(environment.environmentId), - ); - if (newComputers.length === 0) return; - for (const environment of newComputers) { - autoSelectedComputers.add(environment.environmentId); - } - const enabledComputers = newComputers.filter((environment) => environment.entry.enabled); - if (enabledComputers.length === 0) return; - // oxlint-disable-next-line react/set-state-in-effect -- The registry marks each computer once, and only after commit; render must not mutate it. - setSelection( - (current) => - new Set([ - ...(current ?? []), - ...enabledComputers.map((environment) => environment.environmentId), - ]), - ); - }, [autoSelectedComputers, environments]); - const selectedIds = - selection ?? new Set(primaryEnvironment ? [primaryEnvironment.environmentId] : []); const scans = useProjectScans(step === "import" ? setupIds : NO_ENVIRONMENTS); const isLoadingProjects = step === "import" && @@ -157,7 +154,7 @@ export function WelcomeWizard({ const startSetup = (ids: readonly EnvironmentId[]) => { if (ids.length === 0) return; setSetupIds(ids); - setStep("agents"); + enterAgentsStep(ids); }; const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0; const finish = ( @@ -246,7 +243,8 @@ export function WelcomeWizard({ isStepDisabled={(index) => isImporting || index >= stageIndex} onStepChange={(index) => { if (isImporting || index > stageIndex) return; - setStep(index === 0 ? "connection" : "agents"); + if (index === 0) setStep("connection"); + else enterAgentsStep(setupIds); }} /> @@ -256,22 +254,11 @@ export function WelcomeWizard({ - setSelection((current) => { - const next = new Set(current ?? selectedIds); - if (checked) next.add(environmentId); - else next.delete(environmentId); - return next; - }) - } + computerSelection={computerSelection} onContinue={() => startSetup(resolveOnboardingSetup(environments, selectedIds).environmentIds) } - onPaired={(environmentId) => { - setSelection(new Set([...selectedIds, environmentId])); - }} + onPaired={(environmentId) => computerSelection.choose(environmentId, true)} /> ) : step === "agents" ? ( setStep("import")} /> @@ -291,19 +278,15 @@ export function WelcomeWizard({ } function ConnectionStep({ - autoSelectedComputers, + computerSelection, expandPairingInitially, selectedIds, - onSelectionChange, - onToggleEnvironment, onContinue, onPaired, }: { - readonly autoSelectedComputers: Set; + readonly computerSelection: ComputerSelection; readonly expandPairingInitially: boolean; readonly selectedIds: ReadonlySet; - readonly onSelectionChange: (ids: ReadonlySet) => void; - readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void; readonly onContinue: () => void; readonly onPaired: (environmentId: EnvironmentId) => void; }) { @@ -319,15 +302,18 @@ function ConnectionStep({ .filter((environment) => skippedIds.includes(environment.environmentId)) .map((environment) => environment.label); const continueRef = useRef(null); - useEffect(() => { + // Re-attached when `ready` flips, so the button takes idle focus the moment it can act. + const attachContinue = (button: HTMLButtonElement | null) => { + continueRef.current = button; if ( + button && ready && (document.activeElement === document.body || document.activeElement?.getAttribute("role") === "dialog") ) { - continueRef.current?.focus(); + button.focus(); } - }, [ready]); + }; return ( <>

      @@ -346,12 +332,9 @@ function ConnectionStep({ > { - const next = new Set(selectedIds); - if (checked) next.add(environment.environmentId); - else next.delete(environment.environmentId); - onSelectionChange(next); - }} + onCheckedChange={(checked) => + computerSelection.choose(environment.environmentId, checked) + } /> @@ -383,10 +366,9 @@ function ConnectionStep({
      {cloudEnabled ? ( ) : null}
      @@ -445,7 +427,7 @@ function ConnectionStep({

      -
      -
      - ); - } - return ( - - {candidates.length > 0 ? ( -
      - - {selected.length} of {candidates.length} selected - -
      - +
      +
      + ) : ( + + {candidates.length > 0 ? ( +
      + + {selected.length} of {candidates.length} selected + +
      + + +
      +
      + ) : null} + +
      + {scans.map((scan) => { + const scanCandidates = candidates.filter( + (candidate) => candidate.environmentId === scan.environmentId, + ); + const label = + environments.find( + (environment) => environment.environmentId === scan.environmentId, + )?.label ?? "Computer"; + return ( +
      + {scans.length > 1 ? ( + {label} + ) : null} + {scan.isPending && scan.data === null ? ( +
      + + Looking for projects… +
      + ) : scan.error !== null ? ( +
      + Could not check projects. {scan.error} + +
      + ) : scanCandidates.length === 0 ? ( +

      + No existing Claude Code or Codex projects found. +

      + ) : null} + {scan.data?.truncated ? ( +

      + {SCAN_LIMIT_MESSAGE} +

      + ) : null} + +
      + ); + })} +
      +
      + {visibleImportError ? ( +

      {visibleImportError}

      + ) : null} +
      +
      -

- ) : null} - -
- {scans.map((scan) => { - const scanCandidates = candidates.filter( - (candidate) => candidate.environmentId === scan.environmentId, - ); - const label = - environments.find((environment) => environment.environmentId === scan.environmentId) - ?.label ?? "Computer"; - return ( -
- {scans.length > 1 ? ( - {label} - ) : null} - {scan.isPending && scan.data === null ? ( -
- - Looking for projects… -
- ) : scan.error !== null ? ( -
- Could not check projects. {scan.error} - -
- ) : scanCandidates.length === 0 ? ( -

- No existing Claude Code or Codex projects found. -

- ) : null} - {scan.data?.truncated ? ( -

- {SCAN_LIMIT_MESSAGE} -

- ) : null} - -
- ); - })} -
-
- {visibleImportError ? ( -

{visibleImportError}

- ) : null} -
- - -
- + + )} +
); } diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index bf880bf253ac8..a8260441c2c54 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -2,16 +2,13 @@ import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts" import { previewPdfAssetUrl } from "@t3tools/shared/preview"; import { useEnvironmentHttpBaseUrl } from "~/state/environments"; import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; -import { - type PointerEvent as ReactPointerEvent, - type ReactNode, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { type PointerEvent as ReactPointerEvent, type ReactNode, useRef, useState } from "react"; +import { create } from "zustand"; +import { useAtomValue } from "@effect/atom-react"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { useChatCanvas } from "../chat/ChatCanvasContext"; +import type { ChatCanvasPreview } from "../chat/chatCanvasLayout"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { findActiveBrowserRecordingRuntimeTabId, @@ -20,7 +17,8 @@ import { import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; -import { useRendersServerTabNatively } from "~/browser/previewRuntime"; +import { rendersServerTabNatively } from "~/browser/previewRuntime"; +import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface"; import { closeServerPictureInPicture, @@ -39,6 +37,7 @@ import { type PreviewMiniPlayerSize, type PreviewMiniPlayerSource, type PreviewMiniPlayerState, + browserMiniPlayerSource, previewMiniPlayerSourceKey, usePreviewMiniPlayerStore, } from "~/previewMiniPlayerStore"; @@ -47,6 +46,8 @@ import { useDeviceState } from "~/state/device"; import { DeviceStreamView } from "../device/DeviceStreamView"; import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream"; +import { Atom } from "effect/reactivity"; +import { deviceScreenKey, deviceStreamScreenAtom } from "../device/deviceStreamSession"; import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream"; import { previewBridge } from "./previewBridge"; import { showPreviewPopup } from "./showPreviewPopup"; @@ -95,6 +96,86 @@ const RESIZE_HANDLES: ReadonlyArray<{ { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" }, ]; +// What the streams report about their source. It lives outside the player so +// the chat canvas lays out the frame from the same source size the player uses. +const useStreamedSourceSizes = create<{ + readonly viewports: Record; +}>(() => ({ viewports: {} })); + +const NO_DEVICE_SCREEN_ATOM = Atom.make(null).pipe( + Atom.withLabel("device-stream-screen:none"), +); + +const streamedSourceKey = (threadRef: ScopedThreadRef, sourceKey: string) => + `${scopedThreadKey(threadRef)}\u0000${sourceKey}`; + +function useBrowserMiniPlayerSource(threadRef: ScopedThreadRef | null, tabId: string | null) { + const previewState = useThreadPreviewState(threadRef); + const snapshot = tabId === null ? null : (previewState.sessions[tabId] ?? null); + const runtimeTabId = + threadRef && tabId !== null + ? previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId) + : null; + const desktopOverlay = tabId === null ? null : (previewState.desktopByTabId[tabId] ?? null); + const layoutZoomFactor = useBrowserLayoutZoomFactor(desktopOverlay?.zoomFactor ?? 1); + const fittedSourceContent = useBrowserSurfaceStore((state) => + runtimeTabId === null ? null : (state.byTabId[runtimeTabId]?.fittedSourceContent ?? null), + ); + const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); + const serverTab = + threadRef !== null && + snapshot?.runtime === "server" && + !rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot); + const streamViewport = useStreamedSourceSizes((state) => + threadRef && tabId !== null + ? (state.viewports[ + streamedSourceKey(threadRef, previewMiniPlayerSourceKey(browserMiniPlayerSource(tabId))) + ] ?? null) + : null, + ); + const sourceSize: PreviewMiniPlayerSize = + serverTab && streamViewport + ? streamViewport + : resolvePreviewMiniPlayerSourceSize( + snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, + fittedSourceContent, + layoutZoomFactor, + ); + return { serverEpoch: previewState.serverEpoch, snapshot, desktopOverlay, serverTab, sourceSize }; +} + +/** The mini player as the chat canvas lays it out; null when no player renders. */ +export function useThreadPreviewCanvasSource( + threadRef: ScopedThreadRef | null, + miniPlayer: PreviewMiniPlayerState | null, +): ChatCanvasPreview | null { + const source = miniPlayer?.source ?? null; + const browser = useBrowserMiniPlayerSource( + threadRef, + source?.kind === "browser" ? source.tabId : null, + ); + const sourceKey = source ? previewMiniPlayerSourceKey(source) : null; + const screen = useAtomValue( + threadRef && source?.kind === "device" + ? deviceStreamScreenAtom( + deviceScreenKey(threadRef.environmentId, source.hostId, source.deviceId), + ) + : NO_DEVICE_SCREEN_ATOM, + ); + if (!miniPlayer || !source || sourceKey === null) return null; + if (source.kind === "browser" && !browser.snapshot) return null; + return { + key: sourceKey, + width: miniPlayer.width, + position: miniPlayer.position, + lastInteraction: miniPlayer.lastInteraction, + source: + source.kind === "browser" + ? browser.sourceSize + : resolveDeviceMiniPlayerSourceSize(source.platform, screen), + }; +} + export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer }: Props) { const { source } = miniPlayer; return source.kind === "browser" ? ( @@ -116,32 +197,20 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer }: Props) { function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly tabId: string }) { const httpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); - const previewState = useThreadPreviewState(threadRef); - const snapshot = previewState.sessions[tabId] ?? null; - const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId); + const { serverEpoch, snapshot, desktopOverlay, serverTab, sourceSize } = + useBrowserMiniPlayerSource(threadRef, tabId); + const runtimeTabId = previewRuntimeTabId(threadRef, serverEpoch, tabId); const recordingTabIds = useActiveBrowserRecordingTabIds(); const recording = recordingTabIds.has(runtimeTabId) || findActiveBrowserRecordingRuntimeTabId(threadRef, tabId) !== null; - const desktopOverlay = previewState.desktopByTabId[tabId] ?? null; - const layoutZoomFactor = useBrowserLayoutZoomFactor(desktopOverlay?.zoomFactor ?? 1); - const fittedSourceContent = useBrowserSurfaceStore( - (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null, - ); - const nativeServerTab = useRendersServerTabNatively(threadRef.environmentId, snapshot); - const serverTab = snapshot?.runtime === "server" && !nativeServerTab; - const [streamViewport, setStreamViewport] = useState(null); const serverSurfaceRef = useRef(null); const serverPictureInPicture = useServerPictureInPictureKey() === serverPictureInPictureKey(threadRef.threadId, tabId); - const sourceSize = - serverTab && streamViewport - ? streamViewport - : resolvePreviewMiniPlayerSourceSize( - snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, - fittedSourceContent, - layoutZoomFactor, - ); + const viewportKey = streamedSourceKey( + threadRef, + previewMiniPlayerSourceKey(browserMiniPlayerSource(tabId)), + ); const openInPanel = () => { usePreviewMiniPlayerStore.getState().close(threadRef); @@ -232,7 +301,14 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly visible followSize={false} controlPosition="bottom" - onViewport={setStreamViewport} + onViewport={(viewport) => + useStreamedSourceSizes.setState((state) => { + const current = state.viewports[viewportKey]; + return current?.width === viewport.width && current.height === viewport.height + ? state + : { viewports: { ...state.viewports, [viewportKey]: viewport } }; + }) + } onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "floating")} className="size-full" /> @@ -266,7 +342,11 @@ function DeviceMiniPlayer({ miniPlayer, }: Props & { readonly source: Extract }) { const { state: deviceState } = useDeviceState(threadRef.environmentId); - const [screen, setScreen] = useState(null); + const screen = useAtomValue( + deviceStreamScreenAtom( + deviceScreenKey(threadRef.environmentId, source.hostId, source.deviceId), + ), + ); const sourceSize = resolveDeviceMiniPlayerSourceSize(source.platform, screen); const device = deviceState.devices.find( (entry) => entry.hostId === source.hostId && entry.id === source.deviceId, @@ -308,7 +388,6 @@ function DeviceMiniPlayer({ deviceName={device?.name ?? source.name} deviceDescription={`${hostLabel} · ${device?.version ?? source.platform}`} visible - onScreen={setScreen} />
)} @@ -344,40 +423,34 @@ function MiniPlayerShell({ const [pillOpen, setPillOpen] = useState(false); const handleTappedRef = useRef(false); const handleRef = useRef(null); - useEffect(() => { - if (!pillOpen) return; + const dismissPillRef = useRef<(() => void) | null>(null); + const closePill = () => { + dismissPillRef.current?.(); + dismissPillRef.current = null; + setPillOpen(false); + }; + // A press anywhere outside the handle closes the pill; the listener lives + // exactly as long as the pill is open. + const openPill = () => { const dismiss = (event: PointerEvent) => { if (event.target instanceof Node && handleRef.current?.contains(event.target)) return; - setPillOpen(false); + closePill(); }; document.addEventListener("pointerdown", dismiss, true); - return () => document.removeEventListener("pointerdown", dismiss, true); - }, [pillOpen]); + dismissPillRef.current = () => document.removeEventListener("pointerdown", dismiss, true); + setPillOpen(true); + }; + const attachHandle = (node: HTMLDivElement | null) => { + handleRef.current = node; + return () => { + handleRef.current = null; + closePill(); + }; + }; const container = canvas?.container ?? null; const obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES; const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); const frame = canvas?.previewKey === sourceKey ? canvas.layout.frame : null; - const { width: sourceWidth, height: sourceHeight } = sourceSize; - const reportPreview = canvas?.reportPreview; - const clearPreview = canvas?.clearPreview; - useLayoutEffect(() => { - reportPreview?.({ - key: sourceKey, - width: miniPlayer.width, - position: miniPlayer.position, - lastInteraction: miniPlayer.lastInteraction, - source: { width: sourceWidth, height: sourceHeight }, - }); - }, [ - reportPreview, - sourceKey, - miniPlayer.width, - miniPlayer.position, - miniPlayer.lastInteraction, - sourceWidth, - sourceHeight, - ]); - useLayoutEffect(() => () => clearPreview?.(sourceKey), [clearPreview, sourceKey]); const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS; const pillInset = Math.max(8, Math.round(radius * 0.55)); @@ -466,7 +539,7 @@ function MiniPlayerShell({ }} >
{ if (!handleTappedRef.current) return; handleTappedRef.current = false; - setPillOpen((open) => !open); + if (pillOpen) closePill(); + else openPill(); }} >
- settings.browserAutoShowFloatingPreview; - -export function useAgentOpenedPreviews(input: { - readonly threadRef: ScopedThreadRef | null; - readonly deviceState: DeviceServiceState; - readonly deviceStateLoaded: boolean; - readonly previewState: ThreadPreviewState; - readonly serverBrowserAvailable: boolean; - readonly panelSurfaces: ReadonlyArray; - readonly miniPlayerSource: PreviewMiniPlayerState["source"] | null; - // Sheet layouts show neither floating previews nor auto-opened device tabs. - readonly sheetLayout: boolean; -}) { - const { - threadRef, - deviceState, - deviceStateLoaded, - previewState, - serverBrowserAvailable, - panelSurfaces, - miniPlayerSource, - sheetLayout, - } = input; - const autoShowFloatingPreview = useClientSettings(selectAutoShowFloatingPreview); - // Baseline sessions to preserve dismissals on reload; missing device summaries must remain eligible to open - // later. - const previousDeviceSessions = useRef(new Map>()); - useEffect(() => { - if (!threadRef || !deviceStateLoaded) return; - const threadKey = `${threadRef.environmentId}:${threadRef.threadId}`; - const sessions = deviceState.sessions.filter( - (session) => session.threadId === threadRef.threadId, - ); - const key = (session: (typeof sessions)[number]) => `${session.hostId}:${session.deviceId}`; - const deviceFor = (session: (typeof sessions)[number]) => - deviceState.devices.find( - (entry) => entry.hostId === session.hostId && entry.id === session.deviceId, - ); - const previous = previousDeviceSessions.current.get(threadKey); - previousDeviceSessions.current.set( - threadKey, - new Set(sessions.filter((session) => deviceFor(session) !== undefined).map(key)), - ); - if (!previous || sheetLayout) return; - for (const session of sessions) { - if (previous.has(key(session))) continue; - const device = deviceFor(session); - if (!device) continue; - const target = { - hostId: session.hostId, - deviceId: session.deviceId, - platform: device.platform, - name: device.name, - }; - if (autoShowFloatingPreview) { - usePreviewMiniPlayerStore.getState().open(threadRef, { kind: "device", ...target }); - continue; - } - const existing = useRightPanelStore - .getState() - .byThreadKey[scopedThreadKey(threadRef)]?.surfaces.some( - (surface) => - surface.kind === "device" && - surface.target?.hostId === session.hostId && - surface.target.deviceId === session.deviceId, - ); - if (existing) continue; - useRightPanelStore.getState().openDevice(threadRef, target, true); - } - }, [ - threadRef, - autoShowFloatingPreview, - deviceStateLoaded, - sheetLayout, - deviceState.sessions, - deviceState.devices, - ]); - // Baseline loaded tabs so reloads never reopen previews the user dismissed. - const previousServerPreviewTabs = useRef(new Map>()); - useEffect(() => { - if (!threadRef || !serverBrowserAvailable || !previewState.listLoaded) return; - const threadKey = scopedThreadKey(threadRef); - const serverSessions = Object.values(previewState.sessions).filter( - (session) => session.runtime === "server", - ); - const previous = previousServerPreviewTabs.current.get(threadKey); - previousServerPreviewTabs.current.set( - threadKey, - new Map(serverSessions.map((session) => [session.tabId, session.revealRequest?.id])), - ); - if (!previous) return; - for (const session of serverSessions) { - const requested = session.revealRequest; - const fresh = requested - ? previous.get(session.tabId) !== requested.id - : !previous.has(session.tabId); - if (!fresh || session.reveal !== true) continue; - if (!autoShowFloatingPreview && requested?.force !== true) continue; - const surface = panelSurfaces.find( - (surface) => surface.kind === "preview" && surface.resourceId === session.tabId, - ); - if (surface && requested?.force === true) { - useRightPanelStore.getState().activateSurface(threadRef, surface.id); - } else if (!surface) { - usePreviewMiniPlayerStore - .getState() - .open(threadRef, browserMiniPlayerSource(session.tabId)); - } - } - }, [ - serverBrowserAvailable, - previewState.listLoaded, - previewState.sessions, - threadRef, - autoShowFloatingPreview, - panelSurfaces, - ]); - useEffect(() => { - if (!threadRef || !deviceStateLoaded) return; - const source = miniPlayerSource; - if (source?.kind !== "device") return; - const sessionStillExists = deviceState.sessions.some( - (session) => - session.threadId === threadRef.threadId && - session.hostId === source.hostId && - session.deviceId === source.deviceId, - ); - if (!sessionStillExists) usePreviewMiniPlayerStore.getState().close(threadRef); - }, [miniPlayerSource, threadRef, deviceState.sessions, deviceStateLoaded]); -} diff --git a/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx b/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx deleted file mode 100644 index 956df9eed73ad..0000000000000 --- a/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; -import { parseThreadLinkPrompt, withoutPromptLinkParams } from "@t3tools/shared/promptLinks"; -import { useLocation, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; - -import { isThreadRoutePathname } from "../../threadRoutes"; -import { stackedThreadToast, toastManager } from "../ui/toast"; -import { placeLinkedPrompt } from "./linkedPrompt"; - -const handledLinkKeys = new Set(); - -export function ThreadLinkPrompt({ threadRef }: { readonly threadRef: ScopedThreadRef }) { - const navigate = useNavigate(); - const location = useLocation({ - select: (current) => ({ - key: current.state.__TSR_key ?? current.href, - pathname: current.pathname, - search: current.searchStr, - }), - structuralSharing: true, - }); - - useEffect(() => { - // A pending navigation shows the next location while the params still name this thread. - if (!isThreadRoutePathname(location.pathname, threadRef)) return; - if (!new URLSearchParams(location.search).has("prompt")) return; - if (!handledLinkKeys.has(location.key)) { - handledLinkKeys.add(location.key); - const parsed = parseThreadLinkPrompt(location.search); - if (!parsed.ok) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not open the linked prompt", - description: parsed.error, - }), - ); - } else if (parsed.value !== null) { - placeLinkedPrompt(threadRef, parsed.value); - } - } - void navigate({ - href: `${location.pathname}${withoutPromptLinkParams(location.search)}`, - replace: true, - }); - }, [location, navigate, threadRef]); - - return null; -} diff --git a/apps/web/src/components/promptLinks/threadLinkPrompts.ts b/apps/web/src/components/promptLinks/threadLinkPrompts.ts new file mode 100644 index 0000000000000..93f7d0027bc8e --- /dev/null +++ b/apps/web/src/components/promptLinks/threadLinkPrompts.ts @@ -0,0 +1,56 @@ +import { parseThreadLinkPrompt, withoutPromptLinkParams } from "@t3tools/shared/promptLinks"; + +import type { AppRouter } from "../../router"; +import { resolveThreadRouteTarget } from "../../threadRoutes"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { + isHostedPromptLinkLandingShowing, + subscribeHostedPromptLinkLanding, +} from "./HostedPromptLinkLanding"; +import { placeLinkedPrompt } from "./linkedPrompt"; + +/** + * A thread link's `?prompt` lands in that thread's composer once per history entry, then leaves + * the address. The hosted landing page reads the same address, so the prompt waits behind it. + */ +export function startThreadLinkPrompts(router: AppRouter): () => void { + const handledLocationKeys = new Set(); + + const sync = () => { + const location = router.state.location; + if (!new URLSearchParams(location.searchStr).has("prompt")) return; + const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + if (target?.kind !== "server") return; + const hosted = router.state.matches[0]?.context.authGateState?.status === "hosted-static"; + if (isHostedPromptLinkLandingShowing(hosted, location.href)) return; + + const locationKey = location.state.__TSR_key ?? location.href; + if (!handledLocationKeys.has(locationKey)) { + handledLocationKeys.add(locationKey); + const parsed = parseThreadLinkPrompt(location.searchStr); + if (!parsed.ok) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not open the linked prompt", + description: parsed.error, + }), + ); + } else if (parsed.value !== null) { + placeLinkedPrompt(target.threadRef, parsed.value); + } + } + void router.navigate({ + href: `${location.pathname}${withoutPromptLinkParams(location.searchStr)}`, + replace: true, + }); + }; + + const unsubscribers = [ + router.subscribe("onResolved", sync), + subscribeHostedPromptLinkLanding(sync), + ]; + return () => { + for (const unsubscribe of unsubscribers) unsubscribe(); + }; +} diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 0227096adef22..12206c1f5ea3c 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -3,6 +3,7 @@ import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; import { discardComposerDraft } from "../../lib/discardComposerDraft"; import { requestCustomSnooze } from "../CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; +import { useDeadlinePassed } from "~/hooks/useDeadline"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -49,7 +50,6 @@ import { } from "lucide-react"; import { memo, - useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, @@ -84,6 +84,7 @@ import { resolveWorkingStartedAt, useRetainedValue, useSidebarRowSubscriptionLease, + setFileDragActive, } from "../Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "../BranchToolbar.logic"; import { @@ -523,6 +524,8 @@ type SidebarThreadRowProps = { snoozeWakeLabelText: string | null; wokeAt: string | null; isActive: boolean; + // Set on the active row only; scrolls it into view when a reveal is owed. + revealRef?: ((row: HTMLElement) => void) | undefined; // Unlike isActive, never true for the ancestor standing in for an open // subagent: drafts and PR clicks care about which composer is on screen. isRouteThread: boolean; @@ -559,9 +562,50 @@ type SidebarThreadRowProps = { onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined; }; +// Mounted only while renaming, so each rename starts uncommitted. +function ThreadRenameInput(props: { + title: string; + onTitleChange: (title: string) => void; + onCommit: () => void; + onCancel: () => void; +}) { + // Enter and Escape blur the input as it unmounts; that blur must not commit again. + const settledRef = useRef(false); + const handleKeyDown = (event: ReactKeyboardEvent) => { + event.stopPropagation(); + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter") { + event.preventDefault(); + settledRef.current = true; + props.onCommit(); + } else if (event.key === "Escape") { + event.preventDefault(); + settledRef.current = true; + props.onCancel(); + } + }; + return ( + props.onTitleChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleKeyDown} + onBlur={() => { + if (!settledRef.current) props.onCommit(); + }} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" + /> + ); +} + const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { - isRenaming, + isRenaming: isRenamingRequested, onCancelRename, onCommitRename, onContextMenu, @@ -586,10 +630,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); - useEffect(() => { - if (!canOperateThread && isRenaming) onCancelRename(); - }, [canOperateThread, isRenaming, onCancelRename]); - const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive); + // Losing operate access mid-rename drops the editor without committing the title. + const isRenaming = isRenamingRequested && canOperateThread; + const { leaseLiveStatus, rowRef: leaseRowRef } = useSidebarRowSubscriptionLease(props.isActive); + const { revealRef } = props; + const rowRef = (row: HTMLElement | null) => { + if (row !== null) revealRef?.(row); + return leaseRowRef(row); + }; const isRegeneratingTitle = thread.titleRegeneration != null; const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt); @@ -785,41 +833,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const [isFileDragOver, setIsFileDragOver] = useState(false); const fileDropHandlers = onFileDropThreads ? makeWorkspaceFileDropHandlers({ - setDragActive: setIsFileDragOver, + setDragActive: (active) => setFileDragActive(setIsFileDragOver, active), addFiles: (files) => { onFileDropThreads(threadRef, files); }, addFolders: () => {}, }) : null; - useEffect(() => { - if (!isFileDragOver) return; - const clearFileDrag = () => setIsFileDragOver(false); - window.addEventListener("dragend", clearFileDrag); - return () => window.removeEventListener("dragend", clearFileDrag); - }, [isFileDragOver]); - const renameCommittedRef = useRef(false); - useEffect(() => { - if (isRenaming) renameCommittedRef.current = false; - }, [isRenaming]); - const handleRenameKeyDown = (event: ReactKeyboardEvent) => { - event.stopPropagation(); - if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (event.key === "Enter") { - event.preventDefault(); - renameCommittedRef.current = true; - onCommitRename(threadRef, renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - renameCommittedRef.current = true; - onCancelRename(); - } - }; - const handleRenameBlur = () => { - if (!renameCommittedRef.current) { - onCommitRename(threadRef, renamingTitle, thread.title); - } - }; const handleSettleClick = (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -863,18 +883,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const snoozeGateExpiryMs = hasQueuedTurnStart(thread, { now: snoozeGateNow }) ? Date.parse(thread.latestUserMessageAt ?? "") + QUEUED_TURN_START_GRACE_MS : Number.NaN; - useEffect(() => { - // A gate still closed past its expiry is held by runtime status, not time, - // so no clock tick can open it. - if (!Number.isFinite(snoozeGateExpiryMs) || snoozeGateExpiryMs <= Date.parse(snoozeGateNow)) { - return; - } - const id = window.setTimeout( - () => setSnoozeGateClock({ thread, now: new Date().toISOString() }), - Math.min(Math.max(0, snoozeGateExpiryMs - Date.now()) + 50, 2_147_483_647), - ); - return () => window.clearTimeout(id); - }, [snoozeGateExpiryMs, snoozeGateNow, thread]); + // A gate still closed past its expiry is held by runtime status, not time, + // so no clock tick can open it. + const snoozeGateOpensAt = + Number.isFinite(snoozeGateExpiryMs) && snoozeGateExpiryMs > Date.parse(snoozeGateNow) + ? snoozeGateExpiryMs + 50 + : null; + const snoozeGateOpened = useDeadlinePassed(snoozeGateOpensAt); + if (snoozeGateOpened && snoozeGateOpensAt !== null) { + setSnoozeGateClock({ thread, now: new Date(snoozeGateOpensAt).toISOString() }); + } const showSnoozeButton = canOperateThread && props.snoozeSupported && canSnooze(thread, { now: snoozeGateNow }); // Closing here, not just masking, keeps the menu from reopening when the button returns. @@ -949,55 +967,47 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isActive: props.isActive, }); - const title = - isRenaming && canOperateThread ? ( - onRenameTitleChange(event.target.value)} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={handleRenameKeyDown} - onBlur={handleRenameBlur} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" - /> - ) : ( - - {thread.title} - - ); + const title = isRenaming ? ( + onCommitRename(threadRef, renamingTitle, thread.title)} + onCancel={onCancelRename} + /> + ) : ( + + {thread.title} + + ); const accessibleTitle = isRenaming ? null : {thread.title}; const prBadgeShape = supportsMultiplePullRequests @@ -1575,27 +1585,24 @@ export const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props const [isFileDragOver, setIsFileDragOver] = useState(false); const onFileDropThreads = props.onFileDropThreads; const fileDropHandlers = makeWorkspaceFileDropHandlers({ - setDragActive: setIsFileDragOver, + setDragActive: (active) => setFileDragActive(setIsFileDragOver, active), addFiles: (files) => { onFileDropThreads(threadRef, files); }, addFolders: () => {}, }); - useEffect(() => { - if (!isFileDragOver) return; - const clearFileDrag = () => setIsFileDragOver(false); - window.addEventListener("dragend", clearFileDrag); - return () => window.removeEventListener("dragend", clearFileDrag); - }, [isFileDragOver]); return (
  • { + // Arrow keys move the highlight past the scrolled edge of the results. + if (node && props.isHighlighted) node.scrollIntoView({ block: "nearest" }); + return rowRef(node); + }} id={props.resultId} - data-search-result-key={scopedThreadKey(threadRef)} type="button" role="option" tabIndex={-1} diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index 2e9134101e0d1..d6bad2d5b6a29 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -1,5 +1,4 @@ -import { useLocation, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { useNavigate, type AnyRouter } from "@tanstack/react-router"; export function isSidebarUtilityPage(pathname: string) { return ( @@ -11,16 +10,20 @@ export function isSidebarUtilityPage(pathname: string) { ); } +// Pages the root route renders without the app shell, so they are never the app to return to. +const SHELL_LESS_PATHS = new Set(["/pair", "/connect", "/connect-agent"]); + let mainAppHref: string | null = null; -export function MainAppLocationTracker() { - const href = useLocation({ - select: (location) => (isSidebarUtilityPage(location.pathname) ? null : location.href), - }); - useEffect(() => { - if (href !== null) mainAppHref = href; - }, [href]); - return null; +/** Remembers the last app location outside utility pages, for their way back. */ +export function startMainAppLocationTracking(router: AnyRouter): () => void { + const record = () => { + const { pathname, href } = router.state.location; + if (isSidebarUtilityPage(pathname) || SHELL_LESS_PATHS.has(pathname)) return; + mainAppHref = href; + }; + record(); + return router.subscribe("onResolved", record); } export function useNavigateToMainApp() { diff --git a/apps/web/src/components/sidebar/threadNavigationCommands.ts b/apps/web/src/components/sidebar/threadNavigationCommands.ts new file mode 100644 index 0000000000000..e009ac319adf6 --- /dev/null +++ b/apps/web/src/components/sidebar/threadNavigationCommands.ts @@ -0,0 +1,93 @@ +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { type ScopedThreadRef, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; +import type { AnyRouter } from "@tanstack/react-router"; + +import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { useComposerDraftStore } from "../../composerDraftStore"; +import { isModelPickerOpen } from "../../modelPickerVisibility"; +import { type CommandHandlers, commandScopeRef } from "../../shortcuts/dispatcher"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../../terminalUiStateStore"; +import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "../../threadRoutes"; +import { resolveAdjacentThreadId } from "../Sidebar.logic"; + +const KEY_SEPARATOR = "\n"; + +/** + * Thread keys in a form the React Compiler compares by value, so the scope ref + * stays put while thread shells stream in and re-registers only when the order moves. + */ +export function threadKeyList(keys: readonly string[]): string { + return keys.join(KEY_SEPARATOR); +} + +function splitThreadKeys(list: string): string[] { + return list === "" ? [] : list.split(KEY_SEPARATOR); +} + +/** The open thread, read from the router when a handler runs rather than at render. */ +export function readRouteThreadRef(router: AnyRouter): ScopedThreadRef | null { + const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + const draft = + target?.kind === "draft" + ? useComposerDraftStore.getState().getDraftSession(target.draftId) + : null; + return resolveActiveThreadRouteRef(target, draft); +} + +/** + * Thread traversal and jump shortcuts, live while the sidebar element is mounted. + * The route and terminal state are read at key time, so navigating or toggling the + * terminal leaves the scope registered as it was. + */ +export function threadNavigationScopeRef(input: { + /** From `threadKeyList`. */ + readonly traversalKeys: string; + /** From `threadKeyList`; index N is `thread.jump.N+1`. */ + readonly jumpKeys: string; + readonly router: AnyRouter; + /** Maps the route thread to the row key it highlights; must be stable across renders. */ + readonly rowKeyForRoute?: (threadRef: ScopedThreadRef) => string; + readonly navigate: (threadRef: ScopedThreadRef) => unknown; +}) { + const { traversalKeys, jumpKeys, router, rowKeyForRoute = scopedThreadKey, navigate } = input; + const go = (event: KeyboardEvent, key: string | null | undefined) => { + const threadRef = key ? parseScopedThreadKey(key) : null; + if (!threadRef) return false; + event.preventDefault(); + event.stopPropagation(); + void navigate(threadRef); + return true; + }; + const traverse = (direction: "previous" | "next") => (event: KeyboardEvent) => { + const routeThreadRef = readRouteThreadRef(router); + return go( + event, + resolveAdjacentThreadId({ + threadIds: splitThreadKeys(traversalKeys), + currentThreadId: routeThreadRef ? rowKeyForRoute(routeThreadRef) : null, + direction, + }), + ); + }; + const commands: CommandHandlers = { + "thread.previous": traverse("previous"), + "thread.next": traverse("next"), + }; + for (const [index, command] of THREAD_JUMP_KEYBINDING_COMMANDS.entries()) { + commands[command] = (event) => go(event, splitThreadKeys(jumpKeys)[index]); + } + return commandScopeRef(commands, { + when: (event) => !event.repeat && !isCommandPaletteOpen() && !isModelPickerOpen(), + context: () => { + const routeThreadRef = readRouteThreadRef(router); + return { + terminalOpen: routeThreadRef + ? selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }; + }, + }); +} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 238cd8663510e..ce91b28550a10 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -26,7 +26,9 @@ import { ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + RunId, ThreadId, + type ScopedThreadRef, type OrchestrationV2ThreadShell, type OrchestrationV2ShellSnapshot, type ModelSelection, @@ -82,6 +84,11 @@ import { finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, restoreFailedDraftThread, + resolveDraftPromotionNavigationTarget, + subscribeDraftThreadPromotion, + selectEffectiveDraftBranch, + setComposerAttachmentUploader, + QUEUED_EDIT_DRAFT_PREFIX, type ComposerFileAttachment, type ComposerImageAttachment, composerFileNeedsReattach, @@ -98,6 +105,15 @@ import { import { formatTerminalContextReference, type TerminalContextDraft } from "./lib/terminalContext"; import { createDeferredStorage } from "./lib/storage"; import { applyPreviewServerSnapshot, resetPreviewStateForTests } from "./previewStateStore"; +import { waitForDraftHeroTransition } from "./components/chat/draftHeroTransition"; + +const imageReads = vi.hoisted(() => ({ + read: (file: Blob): Promise => Promise.resolve(`data:${file.type};${file.size}`), +})); +vi.mock("./lib/imageCompression", async (importOriginal) => ({ + ...(await importOriginal()), + readFileAsDataUrl: (file: Blob) => imageReads.read(file), +})); function makeImage(input: { id: string; @@ -164,6 +180,8 @@ function resetComposerDraftStore() { draftsByThreadKey: {}, draftThreadsByThreadKey: {}, logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + backgroundSubmissionThreadKeys: {}, + promotedDraftRedirects: {}, stickyModelSelectionByProvider: {}, stickyOptionsByModelByProvider: {}, stickyActiveProvider: null, @@ -207,6 +225,201 @@ function draftByKey(key: string) { return useComposerDraftStore.getState().draftsByThreadKey[key] ?? undefined; } +describe("composerDraftStore image persistence", () => { + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-persist")); + + beforeEach(() => { + resetComposerDraftStore(); + }); + + function nextPersistedAttachmentIds(): Promise { + return new Promise((resolve) => { + const stop = useComposerDraftStore.subscribe((state) => { + const persisted = state.getComposerDraft(threadRef)?.persistedAttachments ?? []; + if (persisted.length === 0) return; + stop(); + resolve(persisted.map((attachment) => attachment.id)); + }); + }); + } + + it("stores the data URL of every accepted image, in image order", async () => { + const persisted = nextPersistedAttachmentIds(); + useComposerDraftStore + .getState() + .addImages(threadRef, [ + makeImage({ id: "a", previewUrl: "blob:a", name: "a.png" }), + makeImage({ id: "b", previewUrl: "blob:b", name: "b.png" }), + ]); + expect(await persisted).toEqual(["a", "b"]); + expect( + useComposerDraftStore.getState().getComposerDraft(threadRef)?.persistedAttachments[0] + ?.dataUrl, + ).toBe("data:image/png;4"); + }); + + it("marks an image whose read failed as not persisted", async () => { + const originalRead = imageReads.read; + imageReads.read = () => Promise.reject(new Error("unreadable")); + try { + const marked = new Promise((resolve) => { + const stop = useComposerDraftStore.subscribe((state) => { + const ids = state.getComposerDraft(threadRef)?.nonPersistedImageIds ?? []; + if (ids.length === 0) return; + stop(); + resolve(ids); + }); + }); + useComposerDraftStore + .getState() + .addImages(threadRef, [makeImage({ id: "unreadable", previewUrl: "blob:unreadable" })]); + expect(await marked).toEqual(["unreadable"]); + } finally { + imageReads.read = originalRead; + } + }); + + it("drops a read that lands after its image was removed", async () => { + let finishRead: (dataUrl: string) => void = () => {}; + const read = new Promise((resolve) => { + finishRead = resolve; + }); + const originalRead = imageReads.read; + imageReads.read = () => read; + try { + const store = useComposerDraftStore.getState(); + store.addImages(threadRef, [makeImage({ id: "gone", previewUrl: "blob:gone" })]); + store.setPrompt(threadRef, "keep the draft"); + store.removeImage(threadRef, "gone"); + finishRead("data:image/png;gone"); + await read; + await Promise.resolve(); + expect(store.getComposerDraft(threadRef)?.persistedAttachments).toEqual([]); + } finally { + imageReads.read = originalRead; + } + }); +}); + +describe("composerDraftStore attachment hand-over", () => { + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-uploads")); + + beforeEach(() => { + resetComposerDraftStore(); + }); + + function captureHandOvers() { + const handOvers: Array<{ environmentId: string | undefined; target: unknown; ids: string[] }> = + []; + setComposerAttachmentUploader(({ environmentId, draftTarget, attachments }) => { + handOvers.push({ + environmentId, + target: draftTarget, + ids: attachments.map((attachment) => attachment.id), + }); + }); + return { + handOvers, + [Symbol.dispose]: () => setComposerAttachmentUploader(null), + }; + } + + it("hands over only accepted attachments, with the caller's environment", () => { + using capture = captureHandOvers(); + const editDraft = DraftId.make(`${QUEUED_EDIT_DRAFT_PREFIX}${scopedThreadKey(threadRef)}:run`); + const store = useComposerDraftStore.getState(); + store.addImages(editDraft, [makeImage({ id: "a", previewUrl: "blob:a" })], { + environmentId: TEST_ENVIRONMENT_ID, + }); + store.addImages(editDraft, [makeImage({ id: "a", previewUrl: "blob:a" })], { + environmentId: TEST_ENVIRONMENT_ID, + }); + expect(capture.handOvers).toEqual([ + { environmentId: TEST_ENVIRONMENT_ID, target: editDraft, ids: ["a"] }, + ]); + }); + + it("hands moved attachments to the destination draft", () => { + const store = useComposerDraftStore.getState(); + const draftId = DraftId.make("draft-move-source"); + store.addImages(draftId, [makeImage({ id: "m", previewUrl: "blob:m" })]); + using capture = captureHandOvers(); + store.moveComposerPromptAndImages(draftId, threadRef); + expect(capture.handOvers).toEqual([ + { environmentId: undefined, target: threadRef, ids: ["m"] }, + ]); + }); + + it("hands a draft's attachments over again when it moves to another environment", () => { + const store = useComposerDraftStore.getState(); + const draftId = DraftId.make("draft-retarget"); + const projectId = ProjectId.make("project-retarget"); + store.setProjectDraftThreadId(scopeProjectRef(TEST_ENVIRONMENT_ID, projectId), draftId); + store.addImages(draftId, [makeImage({ id: "r", previewUrl: "blob:r" })]); + using capture = captureHandOvers(); + + store.setDraftThreadContext(draftId, { branch: "feature" }); + expect(capture.handOvers).toEqual([]); + + store.setDraftThreadContext(draftId, { + projectRef: scopeProjectRef(OTHER_TEST_ENVIRONMENT_ID, projectId), + }); + store.setProjectDraftThreadId(scopeProjectRef(TEST_ENVIRONMENT_ID, projectId), draftId); + expect(capture.handOvers).toEqual([ + { environmentId: undefined, target: draftId, ids: ["r"] }, + { environmentId: undefined, target: draftId, ids: ["r"] }, + ]); + }); +}); + +describe("selectEffectiveDraftBranch", () => { + const draft = { branch: null, worktreePath: null, envMode: "worktree" as const }; + + it("prefers an explicit branch", () => { + expect( + selectEffectiveDraftBranch({ ...draft, branch: "feature" }, { refName: "main" }, "main"), + ).toBe("feature"); + }); + + it("starts a new worktree from the base candidate, else the checked-out branch", () => { + expect(selectEffectiveDraftBranch(draft, { refName: "current" }, "main")).toBe("main"); + expect(selectEffectiveDraftBranch(draft, { refName: "current" }, null)).toBe("current"); + }); + + it("runs a local or existing-worktree draft on its current checkout", () => { + expect( + selectEffectiveDraftBranch({ ...draft, envMode: "local" }, { refName: "current" }, "main"), + ).toBe("current"); + expect( + selectEffectiveDraftBranch( + { ...draft, worktreePath: "/wt" }, + { refName: "wt-branch" }, + "main", + ), + ).toBe("wt-branch"); + expect(selectEffectiveDraftBranch(null, { refName: "current" }, "main")).toBeNull(); + }); + + it("follows a branch switched outside T3 Code over an older pick", () => { + const local = { branch: "feature/old-ref", worktreePath: null, envMode: "local" as const }; + expect(selectEffectiveDraftBranch(local, { refName: "effect-atom" }, "main")).toBe( + "effect-atom", + ); + expect(selectEffectiveDraftBranch(local, { refName: null }, "main")).toBe("feature/old-ref"); + expect(selectEffectiveDraftBranch(local, null, "main")).toBe("feature/old-ref"); + }); + + it("keeps a semantic pick over a temporary worktree branch", () => { + expect( + selectEffectiveDraftBranch( + { branch: "t3code/github-query-rate-limit", worktreePath: "/wt", envMode: "worktree" }, + { refName: "t3code/bda76797" }, + "main", + ), + ).toBe("t3code/github-query-rate-limit"); + }); +}); + describe("composerDraftStore addImages", () => { const threadId = ThreadId.make("thread-dedupe"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); @@ -1306,6 +1519,171 @@ function modelReconciliationHarness( }; } +type PromotionShell = NonNullable< + Parameters[0]["serverThread"] +>; + +function draftPromotionHarness() { + const tasks: Array<() => void> = []; + const registry = AtomRegistry.make({ + scheduleTask: (task) => { + tasks.push(task); + return () => {}; + }, + }); + onTestFinished(() => registry.dispose()); + const flush = () => { + for (let task = tasks.shift(); task !== undefined; task = tasks.shift()) task(); + }; + const threadRefsAtom = Atom.make>([]); + const shellAtoms = new Map>(); + const shellAtom = (ref: ScopedThreadRef) => { + const key = scopedThreadKey(ref); + let atom = shellAtoms.get(key); + if (!atom) { + atom = Atom.make(null); + shellAtoms.set(key, atom); + } + return atom; + }; + onTestFinished( + subscribeDraftThreadPromotion({ registry, threadRefsAtom, threadShellAtom: shellAtom }), + ); + flush(); + return { + flush, + arrive(ref: ScopedThreadRef, shell: PromotionShell | null = null) { + registry.set(shellAtom(ref), shell); + registry.set(threadRefsAtom, [...registry.get(threadRefsAtom), ref]); + flush(); + }, + updateShell(ref: ScopedThreadRef, shell: PromotionShell) { + registry.set(shellAtom(ref), shell); + flush(); + }, + }; +} + +describe("composerDraftStore draft promotion", () => { + const projectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, ProjectId.make("project-promotion")); + const draftId = DraftId.make("draft-promotion"); + const threadId = ThreadId.make("thread-promotion"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const preparingRun = { + runId: RunId.make("run-promotion"), + status: "preparing" as const, + requestedAt: "2026-03-29T00:00:00.000Z", + startedAt: null, + completedAt: null, + assistantMessageId: null, + }; + + beforeEach(resetComposerDraftStore); + + it("stays on the draft until the server owns the send", () => { + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef: threadRef, + serverThread: { latestRun: preparingRun, latestUserMessageAt: null }, + backgroundSubmissionPending: false, + }), + ).toBeNull(); + const persisted = { latestRun: preparingRun, latestUserMessageAt: "2026-03-29T00:00:00.000Z" }; + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef: threadRef, + serverThread: persisted, + backgroundSubmissionPending: false, + }), + ).toBe(threadRef); + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef: threadRef, + serverThread: persisted, + backgroundSubmissionPending: true, + }), + ).toBeNull(); + }); + + it("marks a draft promoting when its reserved thread arrives, then finalizes into it", async () => { + const h = draftPromotionHarness(); + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "typed during setup"); + h.flush(); + expect(store.getDraftSession(draftId)?.promotedTo).toBeNull(); + + h.arrive(threadRef, { latestRun: preparingRun, latestUserMessageAt: null }); + expect(useComposerDraftStore.getState().getDraftSession(draftId)?.promotedTo).toEqual( + threadRef, + ); + + h.updateShell(threadRef, { + latestRun: preparingRun, + latestUserMessageAt: "2026-03-29T00:00:00.000Z", + }); + await waitForDraftHeroTransition(); + + const state = useComposerDraftStore.getState(); + expect(state.getDraftSession(draftId)).toBeNull(); + expect(state.getPromotedDraftRedirect(draftId)).toEqual(threadRef); + expect(state.getPromotedDraftIdByRef(threadRef)).toBe(draftId); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("typed during setup"); + }); + + it("keeps a promotion redirect only while the route shows the draft or its thread", () => { + const otherRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-other")); + useComposerDraftStore.setState({ promotedDraftRedirects: { [draftId]: threadRef } }); + const store = useComposerDraftStore.getState(); + + store.retainPromotedDraftRedirectsForRoute({ kind: "draft", draftId }); + store.retainPromotedDraftRedirectsForRoute({ kind: "server", threadRef }); + expect(useComposerDraftStore.getState().getPromotedDraftIdByRef(threadRef)).toBe(draftId); + + store.retainPromotedDraftRedirectsForRoute({ kind: "server", threadRef: otherRef }); + expect(useComposerDraftStore.getState().promotedDraftRedirects).toEqual({}); + // A later visit renders the shared unkeyed ChatView. + expect(useComposerDraftStore.getState().getPromotedDraftIdByRef(threadRef)).toBeNull(); + }); + + it("holds a background submission on its draft until the send settles", async () => { + const h = draftPromotionHarness(); + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + beginBackgroundDraftSubmissionByRef(threadRef); + h.arrive(threadRef, { + latestRun: preparingRun, + latestUserMessageAt: "2026-03-29T00:00:00.000Z", + }); + await waitForDraftHeroTransition(); + expect(useComposerDraftStore.getState().getDraftSession(draftId)?.promotedTo).toEqual( + threadRef, + ); + + clearBackgroundDraftSubmissionByRef(threadRef); + h.flush(); + await waitForDraftHeroTransition(); + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull(); + }); + + it("does not finalize a draft whose send failed and was restored", async () => { + const h = draftPromotionHarness(); + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + const sent = useComposerDraftStore.getState().getDraftSession(draftId)!; + h.arrive(threadRef, { + latestRun: { ...preparingRun, status: "failed" as const }, + latestUserMessageAt: null, + }); + restoreFailedDraftThread(draftId, sent, ThreadId.make("retry-thread")); + h.flush(); + await waitForDraftHeroTransition(); + expect(useComposerDraftStore.getState().getDraftSession(draftId)?.threadId).toBe( + "retry-thread", + ); + }); +}); + describe("composerDraftStore modelSelection", () => { const threadId = ThreadId.make("thread-model-options"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d9a3be7b26af0..3eb6d96bc2c29 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4371,14 +4371,6 @@ export function subscribeComposerDraftModelReconciliation(input: { }; } -// Compat shim: the base ThreadRouteView reads this until the web-chat-view layer. -export function useBackgroundDraftSubmissionPending(threadRef: ScopedThreadRef | null): boolean { - const threadKey = threadRef ? scopedThreadKey(threadRef) : null; - return useComposerDraftStore( - (state) => threadKey !== null && state.backgroundSubmissionThreadKeys[threadKey] === true, - ); -} - export function beginBackgroundDraftSubmissionByRef(threadRef: ScopedThreadRef): void { const threadKey = scopedThreadKey(threadRef); useComposerDraftStore.setState((state) => ({ diff --git a/apps/web/src/hooks/FileMetadataThreadProvider.test.ts b/apps/web/src/hooks/FileMetadataThreadProvider.test.ts new file mode 100644 index 0000000000000..eca0d22cdece2 --- /dev/null +++ b/apps/web/src/hooks/FileMetadataThreadProvider.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { changedCheckpointPaths } from "./FileMetadataThreadProvider"; + +const files = (...paths: string[]) => paths.map((path) => ({ path })); + +describe("changedCheckpointPaths", () => { + it("skips checkpoints whose files and status are unchanged", () => { + const shared = files("src/a.ts"); + expect( + changedCheckpointPaths( + [{ id: "c1", status: "ready", files: shared }], + [{ id: "c1", status: "ready", files: shared }], + "/repo", + ), + ).toEqual([]); + }); + + it("covers old and new files of a changed checkpoint, a new one and a removed one", () => { + expect( + changedCheckpointPaths( + [ + { id: "c1", status: "capturing", files: files("src/a.ts") }, + { id: "gone", status: "ready", files: files("src/old.ts") }, + ], + [ + { id: "c1", status: "ready", files: files("src/b.ts") }, + { id: "c2", status: "ready", files: files("src/c.ts") }, + ], + "/repo", + ).toSorted(), + ).toEqual(["/repo/src/a.ts", "/repo/src/b.ts", "/repo/src/c.ts", "/repo/src/old.ts"]); + }); +}); diff --git a/apps/web/src/hooks/FileMetadataThreadProvider.tsx b/apps/web/src/hooks/FileMetadataThreadProvider.tsx index 77b082d1f5435..b6bf0a311b387 100644 --- a/apps/web/src/hooks/FileMetadataThreadProvider.tsx +++ b/apps/web/src/hooks/FileMetadataThreadProvider.tsx @@ -1,62 +1,103 @@ import { useAtomValue } from "@effect/atom-react"; -import type { OrchestrationV2ThreadProjection, ScopedThreadRef } from "@t3tools/contracts"; +import type { + EnvironmentId, + OrchestrationV2ThreadProjection, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import { resolveWorkspaceFilePath } from "@t3tools/shared/path"; import { Atom } from "effect/reactivity"; -import { createContext, useEffect, useRef, type ReactNode } from "react"; +import { createContext, type ReactNode } from "react"; import { projectEnvironment } from "../state/projects"; -import { useAtomCommand } from "../state/use-atom-command"; +import { environmentThreadDetails } from "../state/threads"; export const FileMetadataThreadContext = createContext(null); const EMPTY_RETENTION = Atom.make(undefined); -/** Keeps fetched metadata across virtualized message-row unmounts. */ +type Checkpoints = OrchestrationV2ThreadProjection["checkpoints"]; +interface CheckpointFiles { + readonly id: string; + readonly status: string; + readonly files: ReadonlyArray<{ readonly path: string }>; +} + +/** Workspace paths whose metadata a checkpoint change may have made stale. */ +export function changedCheckpointPaths( + before: ReadonlyArray, + after: ReadonlyArray, + cwd: string, +): string[] { + const oldById = new Map(before.map((checkpoint) => [checkpoint.id, checkpoint])); + const paths = new Set(); + for (const checkpoint of after) { + const old = oldById.get(checkpoint.id); + oldById.delete(checkpoint.id); + if (old?.files === checkpoint.files && old?.status === checkpoint.status) continue; + for (const file of checkpoint.files) paths.add(resolveWorkspaceFilePath(file.path, cwd)); + for (const file of old?.files ?? []) paths.add(resolveWorkspaceFilePath(file.path, cwd)); + } + for (const checkpoint of oldById.values()) + for (const file of checkpoint.files) paths.add(resolveWorkspaceFilePath(file.path, cwd)); + return [...paths]; +} + +// Retains the thread's metadata and invalidates the paths each checkpoint change +// touches. The first projection seen is a baseline, not a change. +const checkpointMetadataFamily = Atom.family((key: string) => { + const [environmentId, threadId, cwd] = JSON.parse(key) as [EnvironmentId, ThreadId, string]; + const ref = { environmentId, threadId }; + return Atom.make((get) => { + get(projectEnvironment.fileMetadataThread(ref)); + const registry = get.registry; + let previous: Checkpoints | undefined; + get.subscribe( + environmentThreadDetails.quietThreadAtom(ref), + (structure) => { + const before = previous; + previous = structure?.projection.checkpoints; + if (!before || !previous || before === previous) return; + const paths = changedCheckpointPaths(before, previous, cwd); + if (paths.length === 0) return; + void runAtomCommand( + registry, + projectEnvironment.invalidateFileMetadata, + { environmentId, input: { paths } }, + { reportFailure: false }, + ); + }, + { immediate: true }, + ); + }).pipe(Atom.withLabel(`file-metadata-thread-checkpoints:${key}`)); +}); + +/** Owned by the chat view so virtualized rows can unmount without losing metadata. + * Checkpoints are followed only through `detailRef` (null for drafts and until the + * shell exists): subscribing a thread the server does not know retries "not found" + * in a loop. */ export function FileMetadataThreadProvider(props: { threadRef: ScopedThreadRef | null; + detailRef: ScopedThreadRef | null; cwd: string | undefined; - checkpoints: OrchestrationV2ThreadProjection["checkpoints"] | undefined; children?: ReactNode; }) { - const { threadRef, cwd, checkpoints, children } = props; - useAtomValue(threadRef ? projectEnvironment.fileMetadataThread(threadRef) : EMPTY_RETENTION); - const invalidate = useAtomCommand(projectEnvironment.invalidateFileMetadata, { - reportFailure: false, - }); - const previous = useRef<{ - environmentId: ScopedThreadRef["environmentId"]; - threadId: ScopedThreadRef["threadId"]; - cwd: string; - checkpoints: OrchestrationV2ThreadProjection["checkpoints"]; - } | null>(null); - - useEffect(() => { - if (!threadRef || !cwd || !checkpoints) { - previous.current = null; - return; - } - const before = previous.current; - previous.current = { ...threadRef, cwd, checkpoints }; - if ( - !before || - before.environmentId !== threadRef.environmentId || - before.threadId !== threadRef.threadId || - before.cwd !== cwd - ) - return; - const oldById = new Map(before.checkpoints.map((checkpoint) => [checkpoint.id, checkpoint])); - const paths = new Set(); - for (const checkpoint of checkpoints) { - const old = oldById.get(checkpoint.id); - oldById.delete(checkpoint.id); - if (old?.files === checkpoint.files && old?.status === checkpoint.status) continue; - for (const file of checkpoint.files) paths.add(resolveWorkspaceFilePath(file.path, cwd)); - for (const file of old?.files ?? []) paths.add(resolveWorkspaceFilePath(file.path, cwd)); - } - for (const checkpoint of oldById.values()) - for (const file of checkpoint.files) paths.add(resolveWorkspaceFilePath(file.path, cwd)); - if (paths.size > 0) - void invalidate({ environmentId: threadRef.environmentId, input: { paths: [...paths] } }); - }, [threadRef, cwd, checkpoints, invalidate]); - + const { threadRef, detailRef, cwd, children } = props; + const followsCheckpoints = + threadRef !== null && + detailRef !== null && + cwd !== undefined && + cwd !== "" && + detailRef.environmentId === threadRef.environmentId && + detailRef.threadId === threadRef.threadId; + useAtomValue( + threadRef === null + ? EMPTY_RETENTION + : followsCheckpoints + ? checkpointMetadataFamily( + JSON.stringify([threadRef.environmentId, threadRef.threadId, cwd]), + ) + : projectEnvironment.fileMetadataThread(threadRef), + ); return {children}; } diff --git a/apps/web/src/hooks/useActiveThreadRef.ts b/apps/web/src/hooks/useActiveThreadRef.ts index 6880a7f1ffe65..94d450233b41d 100644 --- a/apps/web/src/hooks/useActiveThreadRef.ts +++ b/apps/web/src/hooks/useActiveThreadRef.ts @@ -1,6 +1,9 @@ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import type { AnyRouter } from "@tanstack/react-router"; import { useState } from "react"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { resolveThreadRouteTarget } from "../threadRoutes"; function toThreadRef( environmentId: EnvironmentId | null, @@ -25,3 +28,18 @@ export function useActiveThreadRef( } return ref; } + +/** + * The thread key the router is showing now, for async continuations that must not touch a thread + * the user has since left. A draft route resolves through its session, or its promotion redirect. + */ +export function readRouteThreadKey(router: Pick): string | null { + const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + if (target === null) return null; + if (target.kind === "server") return scopedThreadKey(target.threadRef); + const drafts = useComposerDraftStore.getState(); + const session = drafts.getDraftSession(target.draftId); + if (session) return scopedThreadKey(scopeThreadRef(session.environmentId, session.threadId)); + const redirect = drafts.getPromotedDraftRedirect(target.draftId); + return redirect ? scopedThreadKey(redirect) : null; +} diff --git a/apps/web/src/hooks/useThreadVisitedMigration.ts b/apps/web/src/hooks/useThreadVisitedMigration.ts index b02cb05bb5f66..2d01d800360ea 100644 --- a/apps/web/src/hooks/useThreadVisitedMigration.ts +++ b/apps/web/src/hooks/useThreadVisitedMigration.ts @@ -1,19 +1,19 @@ -import { RegistryContext } from "@effect/atom-react"; import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { useContext, useEffect, useEffectEvent } from "react"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import type { AtomRegistry } from "effect/reactivity"; import { environmentThreadShells, threadEnvironment } from "../state/threads"; -import { useAtomCommand } from "../state/use-atom-command"; import { useUiStateStore } from "../uiStateStore"; -const migratedThreadKeys = new Set(); - -// Subscribe outside render; shell updates must not re-render the layout for this one-time migration. -export function useThreadVisitedMigration(): void { - const registry = useContext(RegistryContext); - const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); - const migrate = useEffectEvent((threads: ReadonlyArray) => { +/** + * Hands visit times recorded locally (before servers tracked visits) to each + * server once. Subscribed outside render: it only acts on threads it has not + * seen, so a shell update must not re-render anything. + */ +export function startThreadVisitedMigration(registry: AtomRegistry.AtomRegistry): () => void { + const migratedThreadKeys = new Set(); + const migrate = (threads: ReadonlyArray) => { for (const thread of threads) { // Missing means unsupported; retain the local value until the server upgrades. if (thread.lastVisitedAt === undefined) continue; @@ -26,15 +26,15 @@ export function useThreadVisitedMigration(): void { if (!Number.isFinite(localMs)) continue; const serverMs = thread.lastVisitedAt === null ? null : Date.parse(thread.lastVisitedAt); if (serverMs !== null && Number.isFinite(serverMs) && serverMs >= localMs) continue; - void visitThreadMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, visitedAt: local }, - }); + void runAtomCommand( + registry, + threadEnvironment.visit, + { environmentId: thread.environmentId, input: { threadId: thread.id, visitedAt: local } }, + { reportFailure: false }, + ); } + }; + return registry.subscribe(environmentThreadShells.threadShellsAtom, migrate, { + immediate: true, }); - useEffect( - () => - registry.subscribe(environmentThreadShells.threadShellsAtom, migrate, { immediate: true }), - [registry], - ); } diff --git a/apps/web/src/hooks/useWorkspaceMutationRefresh.ts b/apps/web/src/hooks/useWorkspaceMutationRefresh.ts deleted file mode 100644 index 1617ba500e90d..0000000000000 --- a/apps/web/src/hooks/useWorkspaceMutationRefresh.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect, useRef } from "react"; - -function workspaceMutationRefreshToken( - resourceKey: string, - mutationId: string | null, -): string | null { - return mutationId === null ? null : `${resourceKey}\u0000${mutationId}`; -} - -export function useWorkspaceMutationRefresh(input: { - readonly enabled?: boolean; - readonly mutationId: string | null; - readonly refresh: () => void; - readonly resourceKey: string; -}): void { - const { enabled = true, mutationId, refresh, resourceKey } = input; - const handledTokenRef = useRef(null); - - useEffect(() => { - if (!enabled) return; - const token = workspaceMutationRefreshToken(resourceKey, mutationId); - if (token === null || token === handledTokenRef.current) return; - handledTokenRef.current = token; - refresh(); - }, [enabled, mutationId, refresh, resourceKey]); -} diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index d3d3de58d3676..29526d1869395 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -264,14 +264,6 @@ export function threadJumpIndexFromCommand(command: string): number | null { return index === -1 ? null : index; } -export function threadTraversalDirectionFromCommand( - command: string | null, -): "previous" | "next" | null { - if (command === "thread.previous") return "previous"; - if (command === "thread.next") return "next"; - return null; -} - export function shouldShowThreadJumpHintsForModifiers( modifiers: ShortcutModifierStateLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/desktopPasteAsText.browser.test.ts b/apps/web/src/lib/desktopPasteAsText.browser.test.ts new file mode 100644 index 0000000000000..c99bfd1b0dd54 --- /dev/null +++ b/apps/web/src/lib/desktopPasteAsText.browser.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { armPasteAsText, consumePasteAsText, isPasteAsTextArmed } from "./desktopPasteAsText"; + +function composerWith(child: HTMLElement): Disposable & { form: HTMLFormElement } { + const form = document.createElement("form"); + form.append(child); + document.body.append(form); + return { form, [Symbol.dispose]: () => form.remove() }; +} + +describe("paste-as-text arm", () => { + it("covers one paste into the composer the shortcut was pressed in", () => { + const editor = document.createElement("div"); + using composer = composerWith(editor); + armPasteAsText("shortcut", editor, 0); + expect(isPasteAsTextArmed(composer.form, 500)).toBe(true); + expect(consumePasteAsText(composer.form, 500)).toBe(true); + expect(consumePasteAsText(composer.form, 600)).toBe(false); + }); + + it("expires, and ignores a shortcut pressed outside the composer", () => { + const editor = document.createElement("div"); + using composer = composerWith(editor); + armPasteAsText("shortcut", editor, 0); + expect(consumePasteAsText(composer.form, 1_001)).toBe(false); + armPasteAsText("shortcut", document.body, 0); + expect(consumePasteAsText(composer.form, 10)).toBe(false); + }); + + it("lets the menu reach the composer unless another field holds focus", () => { + using composer = composerWith(document.createElement("div")); + armPasteAsText("menu", document.body, 0); + expect(consumePasteAsText(composer.form, 10)).toBe(true); + const input = document.createElement("input"); + document.body.append(input); + armPasteAsText("menu", input, 0); + expect(consumePasteAsText(composer.form, 10)).toBe(false); + input.remove(); + }); + + it("is disarmed by a paste the composer did not handle", () => { + using composer = composerWith(document.createElement("div")); + armPasteAsText("menu", document.body); + document.body.dispatchEvent(new Event("paste", { bubbles: true })); + expect(isPasteAsTextArmed(composer.form)).toBe(false); + }); +}); diff --git a/apps/web/src/lib/desktopPasteAsText.ts b/apps/web/src/lib/desktopPasteAsText.ts index f106efbe4b13c..3ea45a3761cd7 100644 --- a/apps/web/src/lib/desktopPasteAsText.ts +++ b/apps/web/src/lib/desktopPasteAsText.ts @@ -1,15 +1,74 @@ -import type { DesktopBridge } from "@t3tools/contracts"; +import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; +import { registerMenuAction } from "../shortcuts/dispatcher"; +import { isMacPlatform } from "./utils"; -export const DESKTOP_PASTE_AS_TEXT_EVENT = "t3:paste-as-text"; +// Electron can deliver its native menu action just before the paste event, while browsers +// normally deliver keydown first. A short deadline bridges both event paths without leaving +// later pastes in bypass mode. +const ARM_WINDOW_MS = 1_000; -/** Arm composer paste handling before Electron delivers the native clipboard event. */ -export function installDesktopPasteAsText( - bridge: Pick | undefined, - target: EventTarget, -): (() => void) | undefined { - return bridge?.onMenuAction((action) => { - if (action !== "paste-as-text") return; - target.dispatchEvent(new Event(DESKTOP_PASTE_AS_TEXT_EVENT)); - void bridge.pasteAsText?.(); - }); +const FOCUS_TAKES_PASTE_SELECTOR = + 'input, textarea, select, button, a[href], summary, [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"], [role="button"], [role="menuitem"], [role="option"]'; + +type PasteAsTextArm = { + readonly until: number; + readonly origin: "shortcut" | "menu"; + readonly target: EventTarget | null; +}; + +let arm: PasteAsTextArm | null = null; + +function armsPasteFor(current: PasteAsTextArm, scope: Element | null): boolean { + if (current.target instanceof Node && scope?.contains(current.target)) return true; + // The menu item has no keyboard target. It reaches the composer when nothing else that takes + // a paste holds focus. + return ( + current.origin === "menu" && + !(current.target instanceof Element && current.target.closest(FOCUS_TAKES_PASTE_SELECTOR)) + ); +} + +/** Whether the next paste into `scope` should bypass auto-attachment, without consuming it. */ +export function isPasteAsTextArmed(scope: Element | null, now = Date.now()): boolean { + return arm !== null && now <= arm.until && armsPasteFor(arm, scope); +} + +/** Like isPasteAsTextArmed, but disarms: one shortcut press covers one paste. */ +export function consumePasteAsText(scope: Element | null, now = Date.now()): boolean { + const armed = isPasteAsTextArmed(scope, now); + arm = null; + return armed; } + +export function armPasteAsText( + origin: PasteAsTextArm["origin"], + target: EventTarget | null, + now = Date.now(), +): void { + arm = { until: now + ARM_WINDOW_MS, origin, target }; +} + +function disarmPasteAsText(): void { + arm = null; +} + +if (typeof window !== "undefined") { + window.addEventListener( + "keydown", + (event) => { + if (isPasteAsTextShortcut(event, isMacPlatform(navigator.platform))) { + armPasteAsText("shortcut", event.target); + } + }, + true, + ); + window.addEventListener("blur", disarmPasteAsText); + // Bubble phase: a composer that handles the paste consumes the arm first, and a paste anywhere + // else must not leave it armed for the next one. + window.addEventListener("paste", disarmPasteAsText); +} + +registerMenuAction("paste-as-text", () => { + armPasteAsText("menu", document.activeElement); + void window.desktopBridge?.pasteAsText?.(); +}); diff --git a/apps/web/src/lib/terminalFocus.test.ts b/apps/web/src/lib/terminalFocus.test.ts new file mode 100644 index 0000000000000..6e411fbbcf9f1 --- /dev/null +++ b/apps/web/src/lib/terminalFocus.test.ts @@ -0,0 +1,280 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; +import { useTerminalUiStateStore } from "../terminalUiStateStore"; +import { + consumePendingTerminalFocus, + focusActiveDrawerTerminal, + focusActivePanelTerminal, + focusPanelTerminalAfterClose, + focusTerminal, + focusTerminalOnThreadEnter, + registerTerminalFocusTarget, +} from "./terminalFocus"; + +const threadRef = (threadId: string) => + scopeThreadRef(EnvironmentId.make("env"), ThreadId.make(threadId)); + +describe("focusTerminal", () => { + it("focuses a mounted target right away", () => { + const ref = threadRef("mounted"); + const focus = vi.fn(() => true); + const unregister = registerTerminalFocusTarget({ threadRef: ref, terminalId: "a", focus }); + + focusTerminal(ref, "a"); + + expect(focus).toHaveBeenCalledTimes(1); + unregister(); + }); + + it("waits for the requested terminal to mount, then consumes the request", () => { + const ref = threadRef("pending"); + focusTerminal(ref, "b"); + + const other = vi.fn(() => true); + const unregisterOther = registerTerminalFocusTarget({ + threadRef: ref, + terminalId: "a", + focus: other, + }); + const target = vi.fn(() => true); + const unregisterTarget = registerTerminalFocusTarget({ + threadRef: ref, + terminalId: "b", + focus: target, + }); + unregisterTarget(); + const remount = vi.fn(() => true); + const unregisterRemount = registerTerminalFocusTarget({ + threadRef: ref, + terminalId: "b", + focus: remount, + }); + + expect(other).not.toHaveBeenCalled(); + expect(target).toHaveBeenCalledTimes(1); + expect(remount).not.toHaveBeenCalled(); + unregisterOther(); + unregisterRemount(); + }); + + it("keeps only the newest request for a thread", () => { + const ref = threadRef("newest"); + focusTerminal(ref, "a"); + focusTerminal(ref, "b"); + + const first = vi.fn(() => true); + const unregisterFirst = registerTerminalFocusTarget({ + threadRef: ref, + terminalId: "a", + focus: first, + }); + const second = vi.fn(() => true); + const unregisterSecond = registerTerminalFocusTarget({ + threadRef: ref, + terminalId: "b", + focus: second, + }); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + unregisterFirst(); + unregisterSecond(); + }); + + it("keeps the request when a mounted target cannot take focus", () => { + const ref = threadRef("hidden"); + const hidden = vi.fn(() => false); + const unregisterHidden = registerTerminalFocusTarget({ + threadRef: ref, + terminalId: "a", + focus: hidden, + }); + + focusTerminal(ref, "a"); + unregisterHidden(); + const shown = vi.fn(() => true); + const unregisterShown = registerTerminalFocusTarget({ + threadRef: ref, + terminalId: "a", + focus: shown, + }); + + expect(hidden).toHaveBeenCalledTimes(1); + expect(shown).toHaveBeenCalledTimes(1); + unregisterShown(); + }); + + it("focuses a registered hidden target once it is shown", () => { + const ref = threadRef("shown-later"); + let canFocus = false; + const focus = vi.fn(() => canFocus); + const unregister = registerTerminalFocusTarget({ threadRef: ref, terminalId: "a", focus }); + + focusTerminal(ref, "a"); + canFocus = true; + consumePendingTerminalFocus(ref, "a"); + consumePendingTerminalFocus(ref, "a"); + + expect(focus).toHaveBeenCalledTimes(2); + unregister(); + }); + + it("drops a parked request once a newer one targets another thread", () => { + const parked = threadRef("parked"); + const hidden = vi.fn(() => false); + const unregisterHidden = registerTerminalFocusTarget({ + threadRef: parked, + terminalId: "a", + focus: hidden, + }); + focusTerminal(parked, "a"); + + const other = threadRef("other"); + const unregisterOther = registerTerminalFocusTarget({ + threadRef: other, + terminalId: "b", + focus: () => true, + }); + focusTerminal(other, "b"); + unregisterHidden(); + const remount = vi.fn(() => true); + const unregisterRemount = registerTerminalFocusTarget({ + threadRef: parked, + terminalId: "a", + focus: remount, + }); + + expect(remount).not.toHaveBeenCalled(); + unregisterOther(); + unregisterRemount(); + }); + + it("drops a parked request when the thread's drawer closes", () => { + const ref = threadRef("closed"); + useTerminalUiStateStore.getState().ensureTerminal(ref, "a", { open: true, active: true }); + focusActiveDrawerTerminal(ref); + useTerminalUiStateStore.getState().setTerminalOpen(ref, false); + focusActiveDrawerTerminal(ref); + + const focus = vi.fn(() => true); + const unregister = registerTerminalFocusTarget({ threadRef: ref, terminalId: "a", focus }); + + expect(focus).not.toHaveBeenCalled(); + unregister(); + }); + + it("entering a thread with its drawer open focuses the active drawer terminal", () => { + const ref = threadRef("entered"); + useTerminalUiStateStore.getState().ensureTerminal(ref, "a", { open: true, active: true }); + const focus = vi.fn(() => true); + const unregister = registerTerminalFocusTarget({ threadRef: ref, terminalId: "a", focus }); + + focusTerminalOnThreadEnter(ref); + + expect(focus).toHaveBeenCalledTimes(1); + unregister(); + }); + + it("entering a thread keeps the request its navigating handler made", () => { + const ref = threadRef("revealed"); + useTerminalUiStateStore.getState().ensureTerminal(ref, "drawer", { open: true, active: true }); + focusTerminal(ref, "panel"); + focusTerminalOnThreadEnter(ref); + + const drawer = vi.fn(() => true); + const unregisterDrawer = registerTerminalFocusTarget({ + threadRef: ref, + terminalId: "drawer", + focus: drawer, + }); + const panel = vi.fn(() => true); + const unregisterPanel = registerTerminalFocusTarget({ + threadRef: ref, + terminalId: "panel", + focus: panel, + }); + + expect(drawer).not.toHaveBeenCalled(); + expect(panel).toHaveBeenCalledTimes(1); + unregisterDrawer(); + unregisterPanel(); + }); + + it("focuses the panel terminal a closed surface falls back to", () => { + const ref = threadRef("panel-fallback"); + const panel = useRightPanelStore.getState(); + panel.openTerminal(ref, "a"); + panel.openDiff(ref); + const { activeSurfaceId: diffSurfaceId } = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + ref, + ); + const focus = vi.fn(() => true); + const unregister = registerTerminalFocusTarget({ threadRef: ref, terminalId: "a", focus }); + expect(diffSurfaceId).not.toBeNull(); + + useRightPanelStore.getState().closeSurface(ref, diffSurfaceId!); + focusActivePanelTerminal(ref); + + expect(focus).toHaveBeenCalledTimes(1); + unregister(); + }); + + it("closing a surface's last terminal focuses the terminal surface it falls back to", () => { + const ref = threadRef("panel-terminal-fallback"); + const panel = useRightPanelStore.getState(); + panel.openTerminal(ref, "b"); + panel.openTerminal(ref, "a"); + const surfaceA = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + ref, + ).activeSurfaceId!; + const focusB = vi.fn(() => true); + const unregister = registerTerminalFocusTarget({ + threadRef: ref, + terminalId: "b", + focus: focusB, + }); + + panel.closeTerminal(ref, surfaceA, "a"); + focusActivePanelTerminal(ref); + + expect(focusB).toHaveBeenCalledTimes(1); + unregister(); + }); + + it("refocuses the panel terminal only when closing changed the active surface", () => { + const ref = threadRef("panel-after-close"); + const panel = useRightPanelStore.getState(); + panel.openTerminal(ref, "a"); + const terminalSurfaceId = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + ref, + ).activeSurfaceId!; + panel.openDiff(ref); + const diffSurfaceId = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + ref, + ).activeSurfaceId!; + const focus = vi.fn(() => true); + const unregister = registerTerminalFocusTarget({ threadRef: ref, terminalId: "a", focus }); + + panel.activateSurface(ref, terminalSurfaceId); + panel.closeSurface(ref, diffSurfaceId); + focusPanelTerminalAfterClose(ref, terminalSurfaceId); + expect(focus).not.toHaveBeenCalled(); + + panel.openDiff(ref); + const reopenedDiffId = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + ref, + ).activeSurfaceId!; + panel.closeSurface(ref, reopenedDiffId); + focusPanelTerminalAfterClose(ref, reopenedDiffId); + expect(focus).toHaveBeenCalledTimes(1); + unregister(); + }); +}); diff --git a/apps/web/src/lib/windowRefocus.ts b/apps/web/src/lib/windowRefocus.ts new file mode 100644 index 0000000000000..a194904558a12 --- /dev/null +++ b/apps/web/src/lib/windowRefocus.ts @@ -0,0 +1,18 @@ +// Returning to the window re-fires focus on the element that already held it. That focus arrives +// after the window's own focus event, so the window focus marks the frame in which element focus +// is a refocus rather than the user moving focus. +let refocusFrame: number | null = null; + +if (typeof window !== "undefined") { + window.addEventListener("focus", () => { + if (refocusFrame !== null) window.cancelAnimationFrame(refocusFrame); + refocusFrame = window.requestAnimationFrame(() => { + refocusFrame = null; + }); + }); +} + +/** True between the window regaining focus and the next frame. */ +export function isWindowRefocusFrame(): boolean { + return refocusFrame !== null; +} diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 22d5521f5e78c..38cd335bb4994 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -112,35 +112,6 @@ export function setPendingUserInputCustomAnswer( }; } -export function pendingUserInputRequestKey( - environmentId: string, - threadId: string | null, - requestId: string | undefined, -): string { - return JSON.stringify([environmentId, threadId, requestId]); -} - -/** Drops a thread's drafts for requests that are no longer pending. Unchanged input is returned as is. */ -export function pruneResolvedUserInputDrafts( - draftsByRequestKey: Record, - environmentId: string, - threadId: string, - pendingRequestIds: ReadonlyArray, -): Record { - const threadPrefix = JSON.stringify([environmentId, threadId]).slice(0, -1); - const pendingKeys = new Set( - pendingRequestIds.map((requestId) => - pendingUserInputRequestKey(environmentId, threadId, requestId), - ), - ); - const retained = Object.entries(draftsByRequestKey).filter( - ([key]) => !key.startsWith(threadPrefix) || pendingKeys.has(key), - ); - return retained.length === Object.keys(draftsByRequestKey).length - ? draftsByRequestKey - : Object.fromEntries(retained); -} - const DISPLACED_ANSWER_SEPARATOR = "\n\n"; export function carryDisplacedCustomAnswerIntoPrompt( diff --git a/apps/web/src/questionAttachments.test.ts b/apps/web/src/questionAttachments.test.ts index 808f02a4c5a4c..a84cc4d91a1dc 100644 --- a/apps/web/src/questionAttachments.test.ts +++ b/apps/web/src/questionAttachments.test.ts @@ -1,12 +1,15 @@ -import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { beforeEach, expect, it, vi } from "vite-plus/test"; +import { ApprovalRequestId, EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { useComposerDraftStore } from "./composerDraftStore"; import { questionAttachmentDraftId, countQuestionAttachments, questionAttachmentDraftPrefix, + questionAttachmentDraftThreadRef, + resolveOpenQuestionAttachmentDraft, changeQuestionAttachmentPreparation, clearQuestionAttachmentDraft, + pruneResolvedQuestionAttachmentDrafts, useQuestionAttachmentPreparation, } from "./questionAttachments"; @@ -114,3 +117,82 @@ it("counts files, images, and pending preparation across the shared question bud changeQuestionAttachmentPreparation(second, -1); expect(countQuestionAttachments([first, second])).toBe(7); }); + +it("clears attachment drafts for the thread's resolved requests only", () => { + const store = useComposerDraftStore.getState(); + const pending = RuntimeRequestId.make("pending"); + const resolved = RuntimeRequestId.make("resolved"); + const otherThread = ThreadId.make("thread-2"); + const keep = questionAttachmentDraftId(environmentId, threadId, pending, "first"); + const drop = questionAttachmentDraftId(environmentId, threadId, resolved, "first"); + const elsewhere = questionAttachmentDraftId(environmentId, otherThread, resolved, "first"); + for (const key of [keep, drop, elsewhere]) store.setPrompt(key, "Answer context"); + changeQuestionAttachmentPreparation(drop, 1); + + pruneResolvedQuestionAttachmentDrafts(environmentId, threadId, [pending]); + + expect(store.getComposerDraft(keep)?.prompt).toBe("Answer context"); + expect(store.getComposerDraft(drop)).toBeNull(); + expect(store.getComposerDraft(elsewhere)?.prompt).toBe("Answer context"); + expect(useQuestionAttachmentPreparation.getState().counts[drop]).toBeUndefined(); +}); + +it("reads the thread back out of a question draft key, even when ids contain dashes", () => { + const dashedThread = ThreadId.make("thread-with-dash-es"); + const key = questionAttachmentDraftId(environmentId, dashedThread, "request-1", "q-1"); + expect(questionAttachmentDraftThreadRef(key)).toEqual({ environmentId, threadId: dashedThread }); + expect(questionAttachmentDraftThreadRef("not-a-question-key")).toBeNull(); +}); + +describe("open question attachment draft", () => { + const requestId = RuntimeRequestId.make("question-request"); + const question = (id: string, allowCustomAnswer?: boolean) => ({ + id, + header: id, + question: `Question ${id}?`, + options: [], + multiSelect: false, + ...(allowCustomAnswer === undefined ? {} : { allowCustomAnswer }), + }); + const request = { + requestId, + createdAt: "2026-10-24T00:00:00.000Z", + questions: [question("q1"), question("q2"), question("q3", false)], + responseCapability: "live" as const, + dismissible: true, + }; + const open = { + threadRef: { environmentId, threadId }, + request, + questionIndex: 0, + held: false, + responding: false, + questionAttachmentsEnabled: true, + }; + const draftFor = (questionId: string) => + questionAttachmentDraftId(environmentId, threadId, requestId, questionId); + + it("follows the active question and spans the whole request", () => { + expect(resolveOpenQuestionAttachmentDraft(open)).toEqual({ + draftId: draftFor("q1"), + requestKeys: [draftFor("q1"), draftFor("q2"), draftFor("q3")], + }); + expect(resolveOpenQuestionAttachmentDraft({ ...open, questionIndex: 1 })?.draftId).toBe( + draftFor("q2"), + ); + }); + + it.each([ + ["no pending question", { request: null }], + ["a held question", { held: true }], + ["a question being answered", { responding: true }], + [ + "a question that cannot resume", + { request: { ...request, responseCapability: "not_resumable" as const } }, + ], + ["an environment without question attachments", { questionAttachmentsEnabled: false }], + ["an active question without a custom answer", { questionIndex: 2 }], + ])("claims nothing for %s", (_name, overrides) => { + expect(resolveOpenQuestionAttachmentDraft({ ...open, ...overrides })).toBeNull(); + }); +}); diff --git a/apps/web/src/questionAttachments.ts b/apps/web/src/questionAttachments.ts index 28472c0599aa7..7bf1a1b86bd1a 100644 --- a/apps/web/src/questionAttachments.ts +++ b/apps/web/src/questionAttachments.ts @@ -1,8 +1,13 @@ -import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { + onThreadRequestsResolved, + resolvedQuestionDraftKeys, + type ThreadPendingUserInput, +} from "@t3tools/client-runtime/state/thread-requests"; +import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; import { DraftId, useComposerDraftStore } from "./composerDraftStore"; import { releaseDraftAttachments } from "./lib/attachmentUploadQueue"; +import { derivePendingUserInputProgress } from "./pendingUserInput"; export function questionAttachmentDraftPrefix( environmentId: EnvironmentId, @@ -11,6 +16,18 @@ export function questionAttachmentDraftPrefix( return `${encodeURIComponent(JSON.stringify(environmentId))}:question-${encodeURIComponent(JSON.stringify(threadId))}-`; } +/** Question drafts have no draft session, so their key is the only record of the environment. */ +export function questionAttachmentDraftEnvironmentId(key: string): EnvironmentId | null { + const match = /^([^:]+):question-/.exec(key); + if (!match) return null; + try { + const environmentId: unknown = JSON.parse(decodeURIComponent(match[1]!)); + return typeof environmentId === "string" ? EnvironmentId.make(environmentId) : null; + } catch { + return null; + } +} + export function questionAttachmentDraftId( environmentId: EnvironmentId, threadId: ThreadId, @@ -22,34 +39,67 @@ export function questionAttachmentDraftId( ); } -type OpenQuestionAttachmentDraft = { +/** The thread a question draft key belongs to. */ +export function questionAttachmentDraftThreadRef(key: string): ScopedThreadRef | null { + const environmentId = questionAttachmentDraftEnvironmentId(key); + if (!environmentId) return null; + const rest = key.slice(key.indexOf(":question-") + ":question-".length); + // Encoding keeps "-", so take the first split whose head decodes to a JSON string. + for (let index = rest.indexOf("-"); index !== -1; index = rest.indexOf("-", index + 1)) { + try { + const threadId: unknown = JSON.parse(decodeURIComponent(rest.slice(0, index))); + if (typeof threadId === "string") return { environmentId, threadId: ThreadId.make(threadId) }; + } catch { + // Not a split point. + } + } + return null; +} + +export type OpenQuestionAttachmentDraft = { readonly draftId: DraftId; readonly requestKeys: ReadonlyArray; }; -const openQuestionDrafts = new Map(); - -export function openQuestionAttachmentDraft( - threadRef: ScopedThreadRef, -): OpenQuestionAttachmentDraft | null { - return openQuestionDrafts.get(scopedThreadKey(threadRef)) ?? null; -} - -export function countQuestionRequestAttachments(draftId: DraftId): number | null { - for (const draft of openQuestionDrafts.values()) { - if (draft.draftId === draftId) return countQuestionAttachments(draft.requestKeys); +/** + * The question draft that takes attachments for a thread whose oldest pending question is + * `request`: none while the question is hidden from the composer, being answered, or cannot + * take attachments. + */ +export function resolveOpenQuestionAttachmentDraft(input: { + readonly threadRef: ScopedThreadRef; + readonly request: ThreadPendingUserInput | null; + readonly questionIndex: number; + readonly held: boolean; + readonly responding: boolean; + readonly questionAttachmentsEnabled: boolean; +}): OpenQuestionAttachmentDraft | null { + const { threadRef, request } = input; + if ( + !request || + request.responseCapability === "not_resumable" || + input.held || + input.responding || + !input.questionAttachmentsEnabled + ) { + return null; } - return null; -} - -export function trackOpenQuestionAttachmentDraft( - threadRef: ScopedThreadRef, - draft: OpenQuestionAttachmentDraft, -): () => void { - const key = scopedThreadKey(threadRef); - openQuestionDrafts.set(key, draft); - return () => { - if (openQuestionDrafts.get(key) === draft) openQuestionDrafts.delete(key); + const { activeQuestion } = derivePendingUserInputProgress( + request.questions, + {}, + input.questionIndex, + ); + if (!activeQuestion || activeQuestion.allowCustomAnswer === false) return null; + const draftIdFor = (questionId: string) => + questionAttachmentDraftId( + threadRef.environmentId, + threadRef.threadId, + request.requestId, + questionId, + ); + return { + draftId: draftIdFor(activeQuestion.id), + requestKeys: request.questions.map((question) => draftIdFor(question.id)), }; } @@ -92,3 +142,23 @@ export function clearQuestionAttachmentDraft(key: DraftId): void { return { counts: next }; }); } + +/** Clears the thread's question attachment drafts for every request outside `pendingRequestIds`. */ +export function pruneResolvedQuestionAttachmentDrafts( + environmentId: EnvironmentId, + threadId: ThreadId, + pendingRequestIds: ReadonlyArray, +): void { + const keys = new Set([ + ...Object.keys(useComposerDraftStore.getState().draftsByThreadKey), + ...Object.keys(useQuestionAttachmentPreparation.getState().counts), + ]); + const prefix = questionAttachmentDraftPrefix(environmentId, threadId); + for (const key of resolvedQuestionDraftKeys(keys, prefix, pendingRequestIds)) { + clearQuestionAttachmentDraft(DraftId.make(key)); + } +} + +onThreadRequestsResolved(({ environmentId, threadId, pendingRequestIds }) => + pruneResolvedQuestionAttachmentDrafts(environmentId, threadId, pendingRequestIds), +); diff --git a/apps/web/src/reopenClosedView.ts b/apps/web/src/reopenClosedView.ts index 2ae5e0874e1c5..3796a202b1dc9 100644 --- a/apps/web/src/reopenClosedView.ts +++ b/apps/web/src/reopenClosedView.ts @@ -134,6 +134,9 @@ export async function reopenClosedView( case "pull-request": panels.openPullRequest(ref, surface); break; + case "diff": + panels.openDiff(ref); + break; default: panels.open(ref, surface.kind); } diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index a29cecac37095..ed424cf9b0ebe 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -1,8 +1,9 @@ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { type EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; import { useClosedViewStore } from "./closedViewStore"; +import { useDiffPanelStore } from "./diffPanelStore"; import { reopenClosedView } from "./reopenClosedView"; import { migratePersistedRightPanelState, @@ -464,7 +465,7 @@ describe("rightPanelStore", () => { store.openFile(refA, "src/app.ts"); const revision = store.getUserActionRevision(refA); store.reconcileBrowserSurfaces(refA, ["agent-browser"]); - store.reconcileFileSurfaces(refA, false); + store.reconcileAllFileSurfaces(() => false); expect(store.openProactive(refA, completedDiff, revision)).toBe(true); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); @@ -926,16 +927,22 @@ describe("rightPanelStore", () => { useRightPanelStore.getState().open(refA, "device"); useRightPanelStore.getState().openFile(refA, "README.md"); - useRightPanelStore.getState().reconcileFileSurfaces(refA, false); + useRightPanelStore.getState().openFile(refB, "conductor.json"); + // Thread B's workspace is not known yet, so its tabs wait. + useRightPanelStore + .getState() + .reconcileAllFileSurfaces((ref) => (ref.threadId === refA.threadId ? false : null)); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: true, activeSurfaceId: "device", surfaces: [{ id: "device", kind: "device" }], }); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refB).surfaces, + ).toHaveLength(1); - useRightPanelStore.getState().openFile(refB, "conductor.json"); - useRightPanelStore.getState().reconcileFileSurfaces(refB, false); + useRightPanelStore.getState().reconcileAllFileSurfaces(() => false); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refB)).toEqual({ isOpen: false, activeSurfaceId: null, @@ -954,7 +961,7 @@ describe("rightPanelStore", () => { useRightPanelStore.getState().openFile(refA, "README.md"); useRightPanelStore.getState().openAttachment(refA, attachment); - useRightPanelStore.getState().reconcileFileSurfaces(refA, false); + useRightPanelStore.getState().reconcileAllFileSurfaces(() => false); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: true, @@ -1203,3 +1210,63 @@ describe("rightPanelStore", () => { expect(store.getUserActionRevision(refA)).toBe(revision); }); }); + +describe("openDiff", () => { + it("opens the branch diff unless a scope or turn is chosen", () => { + const threadKey = scopedThreadKey(refA); + const turnId = RunId.make("run-1"); + const store = useRightPanelStore.getState(); + store.openDiff(refA, { scope: "unstaged" }); + expect(useDiffPanelStore.getState().byThreadKey[threadKey]).toEqual({ kind: "unstaged" }); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); + + store.openDiff(refA); + expect(useDiffPanelStore.getState().byThreadKey[threadKey]).toMatchObject({ kind: "branch" }); + + store.openDiff(refA, { turnId, filePath: "src/a.ts" }); + expect(useDiffPanelStore.getState().byThreadKey[threadKey]).toMatchObject({ + kind: "turn", + turnId, + filePath: "src/a.ts", + }); + }); +}); + +describe("diff scope on activation", () => { + const threadKey = scopedThreadKey(refA); + const turnId = RunId.make("run-1"); + const diffSelection = () => useDiffPanelStore.getState().byThreadKey[threadKey]; + + it("shows the branch when a generic transition makes the diff active", () => { + const store = useRightPanelStore.getState(); + store.openDiff(refA, { turnId }); + store.open(refA, "device"); + store.closeSurface(refA, "device"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); + expect(diffSelection()).toMatchObject({ kind: "branch" }); + + store.openDiff(refA, { scope: "unstaged" }); + store.toggleVisibility(refA); + store.toggleVisibility(refA); + expect(diffSelection()).toMatchObject({ kind: "branch" }); + }); + + it("shows the branch when the diff opens proactively", () => { + useDiffPanelStore.getState().selectGitScope(refA, "unstaged"); + const store = useRightPanelStore.getState(); + expect(store.openProactive(refA, { kind: "diff", turnId }, 0)).toBe(true); + expect(diffSelection()).toMatchObject({ kind: "branch" }); + }); + + it("keeps the chosen scope when the diff opens explicitly", () => { + const store = useRightPanelStore.getState(); + store.openDiff(refA, { scope: "unstaged" }); + store.open(refA, "device"); + store.activateSurface(refA, "diff"); + expect(diffSelection()).toEqual({ kind: "unstaged" }); + + store.open(refA, "device"); + store.openDiff(refA, { turnId, filePath: "src/a.ts" }); + expect(diffSelection()).toMatchObject({ kind: "turn", turnId, filePath: "src/a.ts" }); + }); +}); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index ecc43b7aeda0d..4b05b0d145b47 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -4,7 +4,7 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { EnvironmentId, RunId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; -import { create } from "zustand"; +import { create, type StateCreator } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { useClosedViewStore } from "./closedViewStore"; @@ -107,11 +107,6 @@ export type DiffOpenSelection = interface RightPanelStoreState { byThreadKey: Record; - // Compat shim for the base ChatView until the web-chat-view layer. - pendingMaximizeThreadKey: string | null; - requestMaximize: (ref: ScopedThreadRef) => void; - consumeMaximizeRequest: (ref: ScopedThreadRef) => boolean; - reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void; threadPanelVisibilityByThreadKey: Record; userActionRevisionByThreadKey: Record; closeRevisionByThreadKey: Record; @@ -615,24 +610,38 @@ export function migratePersistedRightPanelState(persistedState: unknown): { return { byThreadKey, threadPanelVisibilityByThreadKey }; } +// Generic ways onto the diff (tab-close fallback, reveal, proactive open) always show the +// checkout. openDiff and clicking the Diff tab write through api.setState, which skips this, +// because they pick or keep a specific scope. +const resetDiffScopeWhenActivated = + (config: StateCreator): StateCreator => + (set, get, api) => + config( + (updater) => { + const before = get().byThreadKey; + set(updater); + const after = get().byThreadKey; + if (after === before) return; + for (const threadKey of Object.keys(after)) { + if (after[threadKey] === before[threadKey]) continue; + const ref = parseScopedThreadKey(threadKey); + if ( + ref && + selectActiveRightPanel(after, ref) === "diff" && + selectActiveRightPanel(before, ref) !== "diff" + ) { + useDiffPanelStore.getState().selectGitScope(ref, "branch"); + } + } + }, + get, + api, + ); + export const useRightPanelStore = create()( persist( - (set, get) => ({ + resetDiffScopeWhenActivated((set, get, api) => ({ byThreadKey: {}, - // Compat shim for the base ChatView until the web-chat-view layer. - pendingMaximizeThreadKey: null, - requestMaximize: (ref) => set({ pendingMaximizeThreadKey: scopedThreadKey(ref) }), - consumeMaximizeRequest: (ref) => { - if (get().pendingMaximizeThreadKey !== scopedThreadKey(ref)) return false; - set({ pendingMaximizeThreadKey: null }); - return true; - }, - reconcileFileSurfaces: (ref, workspaceAvailable) => - set((state) => - automaticUpdate(state, scopedThreadKey(ref), (current) => - workspaceAvailable ? current : withoutWorkspaceFileSurfaces(current), - ), - ), threadPanelVisibilityByThreadKey: {}, userActionRevisionByThreadKey: {}, closeRevisionByThreadKey: {}, @@ -689,10 +698,14 @@ export const useRightPanelStore = create()( }), ), openDiff: (ref, selection = { scope: "branch" }) => { + api.setState((state) => + userAction(state, scopedThreadKey(ref), (current) => + upsertSurface(current, singletonSurface("diff")), + ), + ); const diffPanel = useDiffPanelStore.getState(); if ("turnId" in selection) diffPanel.selectTurn(ref, selection.turnId, selection.filePath); else diffPanel.selectGitScope(ref, selection.scope); - get().open(ref, "diff"); }, openDevice: (ref, target, automatic = false) => set((state) => @@ -925,7 +938,7 @@ export const useRightPanelStore = create()( }), ), activateSurface: (ref, surfaceId) => - set((state) => + api.setState((state) => userAction(state, scopedThreadKey(ref), (current) => current.surfaces.some((surface) => surface.id === surfaceId) ? { ...current, isOpen: true, activeSurfaceId: surfaceId } @@ -1133,7 +1146,7 @@ export const useRightPanelStore = create()( closeRevisionByThreadKey, }; }), - }), + })), { name: RIGHT_PANEL_STORAGE_KEY, version: RIGHT_PANEL_STORAGE_VERSION, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 2da38ef04e966..21e1bdd1a0b69 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -44,7 +44,6 @@ import { hasHostedPairingAttempt } from "../components/auth/pairingRouteEntry"; import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot"; import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume"; -import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; export const Route = createRootRoute({ beforeLoad: async ({ location, preload }) => { @@ -186,8 +185,6 @@ function RootRouteBranch() { - {/* Compat shim until the web-chat-view layer starts it from appInit. */} - diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index a9038887b7658..0b6658d5246fe 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,210 +1,12 @@ -import { AuthPreviewOperateScope } from "@t3tools/contracts"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; -import { useAtomValue } from "@effect/atom-react"; -import { useEffect } from "react"; -import { isCommandPaletteOpen } from "../commandPaletteBus"; import { ThreadRouteView } from "../components/ThreadRouteView"; -import { ThreadLinkPrompt } from "../components/promptLinks/ThreadLinkPrompt"; -import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; -import { openCommandPalette } from "../commandPaletteBus"; -import { useProjects } from "../state/entities"; -import { isPreviewAvailableFor } from "../browser/previewRuntime"; -import { usePrimaryEnvironmentId } from "../state/environments"; -import { useEnvironmentScope } from "../state/session"; -import { selectProjectGroupingSettings } from "../logicalProject"; -import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; -import { dispatchPreviewAction } from "../components/preview/previewActionBus"; -import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { useScratchProject } from "../hooks/useScratchProject"; -import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { isPreviewFocused } from "../lib/previewFocus"; -import { isTerminalFocused } from "../lib/terminalFocus"; -import { isEditableFocused } from "../lib/editableFocus"; -import { isModelPickerOpen } from "../modelPickerVisibility"; -import { resolveShortcutCommand } from "../keybindings"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; -import { useThreadSelectionStore } from "../threadSelectionStore"; -import { stackedThreadToast, toastManager } from "~/components/ui/toast"; -import { primaryServerKeybindingsAtom } from "~/state/server"; import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; -function ChatRouteGlobalShortcuts() { - const clearSelection = useThreadSelectionStore((state) => state.clearSelection); - const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); - const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = - useHandleNewThread(); - const canOperatePreview = useEnvironmentScope( - routeThreadRef?.environmentId ?? null, - AuthPreviewOperateScope, - ); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const legacySidebarEnabled = useLegacySidebarEnabled(); - const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const projects = useProjects(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const { scratchEnvironmentId, startScratchThread } = useScratchProject(); - const projectGroupCount = buildSidebarProjectSnapshots({ - projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: () => null, - }).length; - const terminalOpen = useTerminalUiStateStore((state) => - routeThreadRef - ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen - : false, - ); - // This flag is store-only; dispatch the action bus to reuse ChatView's URL-aware arbitration. - const previewOpen = useRightPanelStore((state) => - routeThreadRef - ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" - : false, - ); - useEffect(() => { - const onWindowKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - const command = resolveShortcutCommand(event, keybindings, { - context: { - terminalFocus: isTerminalFocused(), - terminalOpen, - previewFocus: isPreviewFocused(), - previewOpen, - editableFocus: isEditableFocused(event.target), - modelPickerOpen: isModelPickerOpen(), - }, - }); - - if (isCommandPaletteOpen()) { - return; - } - - if (event.key === "Escape" && selectedThreadKeysSize > 0) { - event.preventDefault(); - clearSelection(); - return; - } - - if (command === "chat.newLocal") { - event.preventDefault(); - event.stopPropagation(); - void startNewThreadFromContext({ - activeDraftThread, - activeThread: activeThread ?? undefined, - defaultProjectRef, - handleNewThread, - }); - return; - } - - if (command === "chat.newWithoutProject") { - const environmentId = scratchEnvironmentId( - activeThread?.environmentId ?? activeDraftThread?.environmentId ?? primaryEnvironmentId, - ); - if (environmentId === null) return; - event.preventDefault(); - event.stopPropagation(); - void startScratchThread(environmentId); - return; - } - - if (command === "chat.new") { - event.preventDefault(); - event.stopPropagation(); - if (!legacySidebarEnabled && projectGroupCount > 1) { - openCommandPalette({ open: "new-thread-in" }); - return; - } - void startNewThreadFromContext({ - activeDraftThread, - activeThread: activeThread ?? undefined, - defaultProjectRef, - handleNewThread, - }); - return; - } - - if (command === "preview.toggle") { - event.preventDefault(); - event.stopPropagation(); - if (!routeThreadRef || (!canOperatePreview && !previewOpen)) return; - if (!isPreviewAvailableFor(routeThreadRef.environmentId)) { - toastManager.add( - stackedThreadToast({ - type: "info", - title: "Preview is desktop-only", - description: "Open T3 Code in the desktop app to use the in-app preview.", - }), - ); - return; - } - dispatchPreviewAction("toggle-panel"); - return; - } - - if ( - command === "preview.refresh" || - command === "preview.focusUrl" || - command === "preview.zoomIn" || - command === "preview.zoomOut" || - command === "preview.resetZoom" - ) { - event.preventDefault(); - event.stopPropagation(); - if (!canOperatePreview) return; - const action = - command === "preview.refresh" - ? "refresh" - : command === "preview.focusUrl" - ? "focus-url" - : command === "preview.zoomIn" - ? "zoom-in" - : command === "preview.zoomOut" - ? "zoom-out" - : "reset-zoom"; - dispatchPreviewAction(action); - } - }; - - window.addEventListener("keydown", onWindowKeyDown); - return () => { - window.removeEventListener("keydown", onWindowKeyDown); - }; - }, [ - activeDraftThread, - activeThread, - clearSelection, - canOperatePreview, - handleNewThread, - keybindings, - defaultProjectRef, - previewOpen, - primaryEnvironmentId, - projectGroupCount, - routeThreadRef, - scratchEnvironmentId, - selectedThreadKeysSize, - startScratchThread, - legacySidebarEnabled, - terminalOpen, - ]); - - return null; -} - function ChatRouteLayout() { // Render both routes here so draft promotion keeps the same ChatView mounted. const threadTarget = useThreadRouteTarget(); - return ( - <> - - {threadTarget?.kind === "server" ? ( - - ) : null} - {threadTarget ? : } - - ); + return threadTarget ? : ; } export const Route = createFileRoute("/_chat")({ diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts new file mode 100644 index 0000000000000..6d58700079081 --- /dev/null +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -0,0 +1,173 @@ +import { AuthPreviewOperateScope, type EnvironmentId } from "@t3tools/contracts"; +import type { AnyRouter } from "@tanstack/react-router"; + +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { isPreviewAvailableFor } from "../browser/previewRuntime"; +import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { dispatchPreviewAction, type PreviewAction } from "../components/preview/previewActionBus"; +import { createNewThreadHandler, resolveDefaultProjectRef } from "../hooks/useHandleNewThread"; +import { createScratchProjectActions } from "../hooks/useScratchProject"; +import { getClientSettings } from "../hooks/useSettings"; +import { startNewThreadFromContext } from "../lib/chatThreadActions"; +import { selectProjectGroupingSettings } from "../logicalProject"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; +import { readProjects, readThreadShell } from "../state/entities"; +import { environmentPresentations } from "../state/presentation"; +import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment"; +import { readEnvironmentScope } from "../state/session"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useThreadSelectionStore } from "../threadSelectionStore"; +import { resolveThreadRouteTarget } from "../threadRoutes"; +import { useUiStateStore } from "../uiStateStore"; +import { type CommandHandler, registerCommands } from "./dispatcher"; + +const CHAT_ROUTE_ID = "/_chat"; + +/** New-thread and preview commands, live while a chat route is showing. */ +export function installChatRouteCommands(router: AnyRouter): () => void { + const handleNewThread = createNewThreadHandler(router); + const scratch = createScratchProjectActions({ + environments: () => + [...appAtomRegistry.get(environmentPresentations.presentationsAtom)].map( + ([environmentId, presentation]) => ({ ...presentation, environmentId }), + ), + registry: appAtomRegistry, + handleNewThread, + }); + + const readRouteTarget = () => resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + const readRouteThreadRef = () => { + const target = readRouteTarget(); + return target?.kind === "server" ? target.threadRef : null; + }; + const readPreviewOpen = () => { + const threadRef = readRouteThreadRef(); + // Store-only on purpose: the URL-aware arbitration lives in ChatView's + // preview toggle, which the action bus reaches. + return threadRef + ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, threadRef) === "preview" + : false; + }; + const readThreadContext = () => { + const target = readRouteTarget(); + const drafts = useComposerDraftStore.getState(); + const activeThread = target?.kind === "server" ? readThreadShell(target.threadRef) : null; + const activeDraftThread = target + ? target.kind === "server" + ? drafts.getDraftThread(target.threadRef) + : drafts.getDraftSession(target.draftId) + : null; + return { + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef: resolveDefaultProjectRef( + readProjects(), + useUiStateStore.getState().projectOrder, + ), + handleNewThread, + }; + }; + const readProjectGroupCount = () => + buildSidebarProjectSnapshots({ + projects: readProjects(), + settings: selectProjectGroupingSettings(getClientSettings()), + primaryEnvironmentId: appAtomRegistry.get(primaryEnvironmentIdAtom), + resolveEnvironmentLabel: () => null, + }).length; + const canOperatePreview = (environmentId: EnvironmentId) => + readEnvironmentScope(environmentId, AuthPreviewOperateScope); + + const claim = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const startNewLocal: CommandHandler = (event) => { + claim(event); + void startNewThreadFromContext(readThreadContext()); + }; + + const previewCommand = + (action: PreviewAction): CommandHandler => + (event) => { + claim(event); + const threadRef = readRouteThreadRef(); + if (threadRef && canOperatePreview(threadRef.environmentId)) dispatchPreviewAction(action); + }; + + return registerCommands( + { + "chat.newLocal": startNewLocal, + "chat.newWithoutProject": (event) => { + const { activeThread, activeDraftThread } = readThreadContext(); + const environmentId = scratch.scratchEnvironmentId( + activeThread?.environmentId ?? + activeDraftThread?.environmentId ?? + appAtomRegistry.get(primaryEnvironmentIdAtom), + ); + if (environmentId === null) return; + claim(event); + void scratch.startScratchThread(environmentId); + }, + "chat.new": (event) => { + // Pre-hydration settings are the defaults, where the legacy sidebar is off. + if (!getClientSettings().legacySidebarEnabled && readProjectGroupCount() > 1) { + claim(event); + openCommandPalette({ open: "new-thread-in" }); + return; + } + startNewLocal(event); + }, + "preview.toggle": (event) => { + claim(event); + const threadRef = readRouteThreadRef(); + if (!threadRef || (!canOperatePreview(threadRef.environmentId) && !readPreviewOpen())) { + return; + } + if (!isPreviewAvailableFor(threadRef.environmentId)) { + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Preview is desktop-only", + description: "Open T3 Code in the desktop app to use the in-app preview.", + }), + ); + return; + } + dispatchPreviewAction("toggle-panel"); + }, + "preview.refresh": previewCommand("refresh"), + "preview.focusUrl": previewCommand("focus-url"), + "preview.zoomIn": previewCommand("zoom-in"), + "preview.zoomOut": previewCommand("zoom-out"), + "preview.resetZoom": previewCommand("reset-zoom"), + }, + { + when: () => + router.state.matches.some((match) => match.routeId === CHAT_ROUTE_ID) && + !isCommandPaletteOpen(), + context: () => { + const threadRef = readRouteThreadRef(); + return { + terminalOpen: threadRef + ? selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + threadRef, + ).terminalOpen + : false, + previewOpen: readPreviewOpen(), + }; + }, + onKeyDown: (event) => { + if (event.key !== "Escape") return false; + const selection = useThreadSelectionStore.getState(); + if (selection.selectedThreadKeys.size === 0) return false; + event.preventDefault(); + selection.clearSelection(); + }, + }, + ); +} diff --git a/apps/web/src/shortcuts/commandPaletteCommands.browser.test.tsx b/apps/web/src/shortcuts/commandPaletteCommands.browser.test.tsx new file mode 100644 index 0000000000000..4484b60b7b78c --- /dev/null +++ b/apps/web/src/shortcuts/commandPaletteCommands.browser.test.tsx @@ -0,0 +1,47 @@ +import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { commandPaletteMountRef, useCommandPaletteStore } from "../commandPaletteStore"; +import { installCommandPaletteCommands } from "./commandPaletteCommands"; + +const keybindings = compileResolvedKeybindingsConfig([ + { key: "mod+k", command: "commandPalette.toggle" }, +]); + +function setup() { + useCommandPaletteStore.setState(useCommandPaletteStore.getInitialState(), true); +} + +describe("command palette shortcuts", () => { + it("stay inert on routes without the palette", async () => { + setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + await using screen = await render(null, { router: "/pair" }); + using commands = new DisposableStack(); + commands.defer( + installCommandPaletteCommands(screen.router!, { keybindings: () => keybindings }), + ); + + await userEvent.keyboard("{Control>}k{/Control}"); + + expect(useCommandPaletteStore.getState().open).toBe(false); + }); + + it("open the palette while it is mounted, and it closes when it unmounts", async () => { + setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + await using screen = await render(
    , { router: "/" }); + using commands = new DisposableStack(); + commands.defer( + installCommandPaletteCommands(screen.router!, { keybindings: () => keybindings }), + ); + + await userEvent.keyboard("{Control>}k{/Control}"); + expect(useCommandPaletteStore.getState().open).toBe(true); + + await screen.rerender(
    ); + expect(useCommandPaletteStore.getState().open).toBe(false); + }); +}); diff --git a/apps/web/src/shortcuts/commandPaletteCommands.ts b/apps/web/src/shortcuts/commandPaletteCommands.ts new file mode 100644 index 0000000000000..716717061e868 --- /dev/null +++ b/apps/web/src/shortcuts/commandPaletteCommands.ts @@ -0,0 +1,125 @@ +import type { AnyRouter } from "@tanstack/react-router"; + +import { + dispatchCommandPalette, + isCommandPaletteMounted, + useCommandPaletteStore, +} from "../commandPaletteStore"; +import { overlayModeForCommand } from "../components/CommandPalette.logic"; +import { APPEARANCE_OPTIONS, notifyThemeSaveFailure } from "../components/CommandPaletteThemeItems"; +import { toggleThemeEditorForTheme } from "../components/settings/themeEditorStore"; +import { toastManager } from "../components/ui/toast"; +import { getThemeSnapshot, setAppearanceMode } from "../hooks/useTheme"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; +import { type CommandHandler, type CommandScopeOptions, registerCommands } from "./dispatcher"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { resolveThreadRouteTarget } from "../threadRoutes"; + +function consume(event: KeyboardEvent): void { + event.preventDefault(); + event.stopPropagation(); +} + +const toggleOverlay = + (command: Parameters[0]): CommandHandler => + (event) => { + const mode = overlayModeForCommand(command); + if (mode === null) return false; + consume(event); + dispatchCommandPalette({ _tag: "ToggleMode", mode }); + }; + +/** + * Palette, theme and usage shortcuts. They read the route, panels and theme + * when the key is pressed, and are live only while the palette is mounted. + */ +export function installCommandPaletteCommands( + router: AnyRouter, + options: Pick = {}, +): () => void { + const readRouteThreadRef = () => { + const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + return target?.kind === "server" ? target.threadRef : null; + }; + + // A child search (files, contents) goes back to the palette on Escape before + // its own widgets or the dialog can treat the key as a close. + const disposeEscape = registerCommands( + {}, + { + capture: true, + when: isCommandPaletteMounted, + onKeyDown: (event) => { + if (event.isComposing || event.key !== "Escape") return false; + const { open, mode } = useCommandPaletteStore.getState(); + if (!open || mode === "command") return false; + consume(event); + dispatchCommandPalette({ _tag: "BackToCommand" }); + }, + }, + ); + + const disposeCommands = registerCommands( + { + "appearance.cycle": (event) => { + consume(event); + if (event.repeat) return; + const { appearanceMode } = getThemeSnapshot(); + const nextMode = + appearanceMode === "system" ? "light" : appearanceMode === "light" ? "dark" : "system"; + if (!setAppearanceMode(nextMode)) { + notifyThemeSaveFailure(); + return; + } + toastManager.add({ + id: "appearance-cycle", + title: `Appearance: ${APPEARANCE_OPTIONS.find((option) => option.mode === nextMode)?.label}`, + timeout: 1500, + }); + }, + "theme.select": (event) => { + consume(event); + if (event.repeat) return; + dispatchCommandPalette({ _tag: "OpenChangeTheme" }); + }, + "themeEditor.toggle": (event) => { + consume(event); + const { theme, themeHalves, resolvedTheme } = getThemeSnapshot(); + toggleThemeEditorForTheme({ theme, themeHalves, initialAppearance: resolvedTheme }); + }, + "usage.open": (event) => { + consume(event); + dispatchCommandPalette({ _tag: "SetOpen", open: false }); + void router.navigate({ to: "/usage" }); + }, + "commandPalette.toggle": toggleOverlay("commandPalette.toggle"), + "filePicker.toggle": toggleOverlay("filePicker.toggle"), + "projectSearch.toggle": toggleOverlay("projectSearch.toggle"), + }, + { + ...options, + when: isCommandPaletteMounted, + context: () => { + const threadRef = readRouteThreadRef(); + return { + terminalOpen: threadRef + ? selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + threadRef, + ).terminalOpen + : false, + previewOpen: threadRef + ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, threadRef) === + "preview" + : false, + usagePageOpen: router.state.location.pathname === "/usage", + }; + }, + }, + ); + + return () => { + disposeEscape(); + disposeCommands(); + }; +} diff --git a/apps/web/src/sidebarPendingFileDropStore.test.ts b/apps/web/src/sidebarPendingFileDropStore.test.ts index 4d31f4b65313d..fb63f88101254 100644 --- a/apps/web/src/sidebarPendingFileDropStore.test.ts +++ b/apps/web/src/sidebarPendingFileDropStore.test.ts @@ -1,84 +1,97 @@ -import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; import { - useSidebarPendingFileDropStore, - type SidebarPendingFileDrop, + clearPendingFileDrop, + queuePendingFileDrop, + registerSidebarFileDropReceiver, + retainPendingFileDropsForThread, } from "./sidebarPendingFileDropStore"; function makeFiles(...names: string[]): File[] { return names.map((name) => new File(["x"], name)); } -function makeEntry( - environmentId: string, - threadId: string, - files: File[], -): Omit { - return { - threadRef: scopeThreadRef(environmentId as EnvironmentId, ThreadId.make(threadId)), - files, - }; +function refOf(environmentId: string, threadId: string) { + return scopeThreadRef(environmentId as EnvironmentId, ThreadId.make(threadId)); } -function fileNames(files: File[]): string[] { - return files.map((file) => file.name); +/** Registers a receiver for the thread and returns the names it was handed, per delivery. */ +function receive(environmentId: string, threadId: string) { + const deliveries: string[][] = []; + const unregister = registerSidebarFileDropReceiver(refOf(environmentId, threadId), (files) => { + deliveries.push(files.map((file) => file.name)); + }); + return { deliveries, [Symbol.dispose]: unregister }; } -function refOf(environmentId: string, threadId: string) { - return makeEntry(environmentId, threadId, []).threadRef; +function setup() { + retainPendingFileDropsForThread(null); } -beforeEach(() => { - useSidebarPendingFileDropStore.setState({ pending: [] }); -}); - describe("sidebarPendingFileDropStore", () => { - it("accumulates repeat drops onto the same thread instead of replacing", () => { - const store = useSidebarPendingFileDropStore.getState(); - store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png"))); - store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png"))); + it("hands repeat drops to the composer that mounts later, in one delivery", () => { + setup(); + queuePendingFileDrop({ threadRef: refOf("env-1", "thread-1"), files: makeFiles("a.png") }); + queuePendingFileDrop({ threadRef: refOf("env-1", "thread-1"), files: makeFiles("b.png") }); + + using receiver = receive("env-1", "thread-1"); + expect(receiver.deliveries).toEqual([["a.png", "b.png"]]); + using again = receive("env-1", "thread-1"); + expect(again.deliveries).toEqual([]); + }); + + it("delivers straight to a mounted composer and stops once it unregisters", () => { + setup(); + { + using receiver = receive("env-1", "thread-1"); + expect( + queuePendingFileDrop({ threadRef: refOf("env-1", "thread-1"), files: makeFiles("a.png") }), + ).toBeNull(); + expect(receiver.deliveries).toEqual([["a.png"]]); + } expect( - fileNames( - useSidebarPendingFileDropStore - .getState() - .consumePendingFileDrop(refOf("env-1", "thread-1")) ?? [], - ), - ).toEqual(["a.png", "b.png"]); - expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]); + queuePendingFileDrop({ threadRef: refOf("env-1", "thread-1"), files: makeFiles("b.png") }), + ).not.toBeNull(); + using later = receive("env-1", "thread-1"); + expect(later.deliveries).toEqual([["b.png"]]); }); it("keeps a newer drop deliverable after the first navigation fails", () => { - const store = useSidebarPendingFileDropStore.getState(); - const firstId = store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png"))); - const secondId = store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png"))); - - useSidebarPendingFileDropStore.getState().clearPendingFileDrop(firstId); - const remaining = useSidebarPendingFileDropStore.getState().pending; - expect(remaining).toHaveLength(1); - expect(remaining[0]?.id).toBe(secondId); - expect(fileNames(remaining[0]?.files ?? [])).toEqual(["b.png"]); + setup(); + const firstId = queuePendingFileDrop({ + threadRef: refOf("env-1", "thread-1"), + files: makeFiles("a.png"), + }); + queuePendingFileDrop({ threadRef: refOf("env-1", "thread-1"), files: makeFiles("b.png") }); - expect( - fileNames( - useSidebarPendingFileDropStore - .getState() - .consumePendingFileDrop(refOf("env-1", "thread-1")) ?? [], - ), - ).toEqual(["b.png"]); - expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]); + clearPendingFileDrop(firstId); + using receiver = receive("env-1", "thread-1"); + expect(receiver.deliveries).toEqual([["b.png"]]); }); it("does not confuse refs whose joined keys collide on colons", () => { - const store = useSidebarPendingFileDropStore.getState(); - store.queuePendingFileDrop(makeEntry("a", "b:c", makeFiles("a.png"))); + setup(); + using other = receive("a:b", "c"); + queuePendingFileDrop({ threadRef: refOf("a", "b:c"), files: makeFiles("a.png") }); + expect(other.deliveries).toEqual([]); - expect( - useSidebarPendingFileDropStore.getState().consumePendingFileDrop(refOf("a:b", "c")), - ).toBeNull(); - expect(useSidebarPendingFileDropStore.getState().pending).toHaveLength(1); + using receiver = receive("a", "b:c"); + expect(receiver.deliveries).toEqual([["a.png"]]); + }); + + it("discards drops for every thread but the one the route now shows", () => { + setup(); + queuePendingFileDrop({ threadRef: refOf("env-1", "thread-1"), files: makeFiles("a.png") }); + queuePendingFileDrop({ threadRef: refOf("env-1", "thread-2"), files: makeFiles("b.png") }); + + retainPendingFileDropsForThread(refOf("env-1", "thread-2")); + using first = receive("env-1", "thread-1"); + using second = receive("env-1", "thread-2"); + expect(first.deliveries).toEqual([]); + expect(second.deliveries).toEqual([["b.png"]]); }); }); diff --git a/apps/web/src/sidebarPendingFileDropStore.ts b/apps/web/src/sidebarPendingFileDropStore.ts index 96db00bbcd9b3..d082a5b0b2fd8 100644 --- a/apps/web/src/sidebarPendingFileDropStore.ts +++ b/apps/web/src/sidebarPendingFileDropStore.ts @@ -1,54 +1,73 @@ -import { create } from "zustand"; - import type { ScopedThreadRef } from "@t3tools/contracts"; /** Compare both ids; scopedThreadKey can collide when ids contain colons. */ -export function isSameSidebarThreadRef(a: ScopedThreadRef, b: ScopedThreadRef): boolean { +function isSameSidebarThreadRef(a: ScopedThreadRef, b: ScopedThreadRef): boolean { return a.environmentId === b.environmentId && a.threadId === b.threadId; } -export interface SidebarPendingFileDrop { - id: string; - threadRef: ScopedThreadRef; - files: File[]; +interface SidebarPendingFileDrop { + readonly id: string; + readonly threadRef: ScopedThreadRef; + readonly files: File[]; } -interface SidebarPendingFileDropStoreState { - pending: SidebarPendingFileDrop[]; - queuePendingFileDrop: (entry: Omit) => string; - clearPendingFileDrop: (id: string) => void; - clearPendingFileDropsForThread: (threadRef: ScopedThreadRef) => void; - consumePendingFileDrop: (threadRef: ScopedThreadRef) => File[] | null; +interface SidebarFileDropReceiver { + readonly threadRef: ScopedThreadRef; + readonly receive: (files: File[]) => void; } +let pending: SidebarPendingFileDrop[] = []; +let receivers: SidebarFileDropReceiver[] = []; let nextPendingFileDropId = 0; -export const useSidebarPendingFileDropStore = create()( - (set, get) => ({ - pending: [], - queuePendingFileDrop: (entry) => { - const id = `sidebar-file-drop-${(nextPendingFileDropId += 1)}`; - set((state) => ({ pending: [...state.pending, { ...entry, id }] })); - return id; - }, - clearPendingFileDrop: (id) => { - set((state) => ({ pending: state.pending.filter((drop) => drop.id !== id) })); - }, - clearPendingFileDropsForThread: (threadRef) => { - set((state) => ({ - pending: state.pending.filter((drop) => !isSameSidebarThreadRef(drop.threadRef, threadRef)), - })); - }, - consumePendingFileDrop: (threadRef) => { - const matches = get().pending.filter((drop) => - isSameSidebarThreadRef(drop.threadRef, threadRef), - ); - if (matches.length === 0) { - return null; - } - const matchedIds = new Set(matches.map((drop) => drop.id)); - set((state) => ({ pending: state.pending.filter((drop) => !matchedIds.has(drop.id)) })); - return matches.flatMap((drop) => drop.files); - }, - }), -); +/** + * Hands files dropped on a sidebar thread to that thread's composer. With no composer + * mounted for it yet, the drop waits until one registers; the returned id clears it. + */ +export function queuePendingFileDrop(entry: { + readonly threadRef: ScopedThreadRef; + readonly files: File[]; +}): string | null { + const receiver = receivers.findLast((candidate) => + isSameSidebarThreadRef(candidate.threadRef, entry.threadRef), + ); + if (receiver) { + receiver.receive(entry.files); + return null; + } + const id = `sidebar-file-drop-${(nextPendingFileDropId += 1)}`; + pending = [...pending, { ...entry, id }]; + return id; +} + +export function clearPendingFileDrop(id: string | null): void { + if (id === null) return; + pending = pending.filter((drop) => drop.id !== id); +} + +/** Drops only wait for their thread's route; leaving it (or never reaching it) discards them. */ +export function retainPendingFileDropsForThread(threadRef: ScopedThreadRef | null): void { + pending = pending.filter( + (drop) => threadRef !== null && isSameSidebarThreadRef(drop.threadRef, threadRef), + ); +} + +/** + * A composer takes its thread's drops while registered, starting with any that arrived + * before it mounted. + */ +export function registerSidebarFileDropReceiver( + threadRef: ScopedThreadRef, + receive: (files: File[]) => void, +): () => void { + const receiver: SidebarFileDropReceiver = { threadRef, receive }; + receivers = [...receivers, receiver]; + const waiting = pending.filter((drop) => isSameSidebarThreadRef(drop.threadRef, threadRef)); + if (waiting.length > 0) { + pending = pending.filter((drop) => !waiting.includes(drop)); + receive(waiting.flatMap((drop) => drop.files)); + } + return () => { + receivers = receivers.filter((candidate) => candidate !== receiver); + }; +} diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index e137548a068dc..f7c5fc1d75629 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -75,19 +75,6 @@ const EMPTY_THREAD_HISTORY_ATOM = Atom.make(EMPTY_THREAD_HIST Atom.withLabel("web-thread-history:empty"), ); -const activeEnvironmentIdAtom = Atom.make(null).pipe( - Atom.keepAlive, - Atom.withLabel("web-active-environment-id"), -); - -export function useActiveEnvironmentId(): EnvironmentId | null { - return useAtomValue(activeEnvironmentIdAtom); -} - -export function setActiveEnvironmentId(environmentId: EnvironmentId | null): void { - appAtomRegistry.set(activeEnvironmentIdAtom, environmentId); -} - export function useThreadRefs(enabled = true): ReadonlyArray { return useAtomValue(enabled ? environmentThreadShells.threadRefsAtom : EMPTY_THREAD_REFS_ATOM); } diff --git a/apps/web/src/state/terminalSessions.test.ts b/apps/web/src/state/terminalSessions.test.ts index 5d9256ec3fdc7..c4b55772b3403 100644 --- a/apps/web/src/state/terminalSessions.test.ts +++ b/apps/web/src/state/terminalSessions.test.ts @@ -1,10 +1,32 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ThreadId, type TerminalSummary } from "@t3tools/contracts"; import { selectRunningSubprocessTerminalIds } from "@t3tools/client-runtime/state/terminal"; +import { AtomRegistry } from "effect/reactivity"; -import { selectKnownTerminalSessions } from "./terminalSessions"; +import { attachedTerminalSessionAtom, selectKnownTerminalSessions } from "./terminalSessions"; -vi.mock("./terminal", () => ({ terminalEnvironment: {} })); +const attachCalls = vi.hoisted(() => [] as unknown[]); +vi.mock("./terminal", async () => { + const { AsyncResult, Atom } = await import("effect/reactivity"); + return { + terminalEnvironment: { + attach: (request: unknown) => { + attachCalls.push(request); + return Atom.make(AsyncResult.initial(true)); + }, + metadata: () => Atom.make(AsyncResult.initial(true)), + }, + }; +}); +vi.mock("./session", async () => { + const { AsyncResult, Atom } = await import("effect/reactivity"); + const granted = Atom.make(AsyncResult.success({})); + return { + environmentSession: { sessionStateAtom: () => granted }, + sessionHasScope: () => true, + useEnvironmentScope: () => true, + }; +}); const environmentA = EnvironmentId.make("environment-a"); const threadA = ThreadId.make("thread-a"); @@ -77,3 +99,21 @@ describe("selectKnownTerminalSessions", () => { expect(selectKnownTerminalSessions([other], environmentA, threadB)).toBe(beforeB); }); }); + +describe("attachedTerminalSessionAtom", () => { + it("attaches with the launch input it was keyed by", () => { + attachCalls.length = 0; + const input = { + threadId: threadA, + terminalId: "terminal-1", + cwd: "/repo", + worktreePath: null, + env: { T3CODE_PROJECT_ROOT: "/repo" }, + }; + const atom = attachedTerminalSessionAtom(environmentA, input); + expect(attachedTerminalSessionAtom(environmentA, { ...input })).toBe(atom); + + AtomRegistry.make().get(atom); + expect(attachCalls).toEqual([{ environmentId: environmentA, input }]); + }); +}); diff --git a/apps/web/src/state/terminalSessions.ts b/apps/web/src/state/terminalSessions.ts index 51fe87aa892d7..3683a8fba6da1 100644 --- a/apps/web/src/state/terminalSessions.ts +++ b/apps/web/src/state/terminalSessions.ts @@ -7,17 +7,21 @@ import { type TerminalSessionState, } from "@t3tools/client-runtime/state/terminal"; import { + EnvironmentId, ThreadId, AuthTerminalReadScope, AuthTerminalOperateScope, - type EnvironmentId, - type TerminalAttachInput, + TerminalAttachInput, type TerminalSummary, } from "@t3tools/contracts"; -import { useEnvironmentQuery } from "./query"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { AsyncResult, Atom } from "effect/reactivity"; + +import { formatEnvironmentQueryError, useEnvironmentQuery } from "./query"; import { terminalEnvironment } from "./terminal"; -import { useEnvironmentScope } from "./session"; +import { environmentSession, sessionHasScope, useEnvironmentScope } from "./session"; const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze>([]); @@ -121,47 +125,72 @@ export function selectKnownTerminalSessions( return sessions; } -export function useAttachedTerminalSession(input: { - readonly environmentId: EnvironmentId | null; - readonly terminal: TerminalAttachInput | null; -}): TerminalSessionState { - const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope); - const canOperate = useEnvironmentScope(input.environmentId, AuthTerminalOperateScope); - const attach = useEnvironmentQuery( - input.environmentId !== null && input.terminal !== null - ? canOperate - ? terminalEnvironment.attach({ - environmentId: input.environmentId, - input: input.terminal, - }) - : canRead - ? terminalEnvironment.observe({ - environmentId: input.environmentId, - input: { threadId: input.terminal.threadId, terminalId: input.terminal.terminalId }, - }) - : null - : null, - ); - const metadata = useEnvironmentQuery( - input.environmentId === null || !canRead - ? null - : terminalEnvironment.metadata({ - environmentId: input.environmentId, - input: null, - }), - ); +const EMPTY_TERMINAL_SESSION_ATOM = Atom.make(EMPTY_TERMINAL_SESSION_STATE).pipe( + Atom.withLabel("web-terminal-session:empty"), +); - if (input.environmentId === null || input.terminal === null) { - return EMPTY_TERMINAL_SESSION_STATE; +const decodeAttachKey = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Tuple([EnvironmentId, TerminalAttachInput])), +); + +// The family key carries the whole launch so an idle atom leaves nothing behind. +const attachedTerminalSessionFamily = Atom.family((key: string) => { + const decoded = decodeAttachKey(key); + if (Option.isNone(decoded)) { + return Atom.make({ + ...EMPTY_TERMINAL_SESSION_STATE, + error: "Invalid terminal launch input", + status: "error", + }).pipe(Atom.withLabel(`web-terminal-session:${key}`)); } - const summary = - (metadata.data === null - ? null - : terminalMetadataIndex(metadata.data) - .byThreadId.get(input.terminal.threadId) - ?.find((terminal) => terminal.terminalId === input.terminal?.terminalId)) ?? null; - const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); - return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; + const [environmentId, terminal] = decoded.value; + return Atom.make((get): TerminalSessionState => { + const session = get(environmentSession.sessionStateAtom(environmentId)); + const canRead = sessionHasScope(session, AuthTerminalReadScope); + const canOperate = sessionHasScope(session, AuthTerminalOperateScope); + const attach = canOperate + ? get(terminalEnvironment.attach({ environmentId, input: terminal })) + : canRead + ? get( + terminalEnvironment.observe({ + environmentId, + input: { threadId: terminal.threadId, terminalId: terminal.terminalId }, + }), + ) + : null; + const metadata = canRead + ? Option.getOrNull( + AsyncResult.value(get(terminalEnvironment.metadata({ environmentId, input: null }))), + ) + : null; + const summary = + (metadata === null + ? null + : terminalMetadataIndex(metadata) + .byThreadId.get(terminal.threadId) + ?.find((candidate) => candidate.terminalId === terminal.terminalId)) ?? null; + const buffer = + attach === null + ? EMPTY_TERMINAL_BUFFER_STATE + : Option.getOrElse(AsyncResult.value(attach), () => EMPTY_TERMINAL_BUFFER_STATE); + const state = combineTerminalSessionState(summary, buffer); + return attach?._tag === "Failure" + ? { ...state, error: formatEnvironmentQueryError(attach.cause), status: "error" } + : state; + }).pipe(Atom.withLabel(`web-terminal-session:${key}`)); +}); + +/** + * One shown terminal's session: its attach (or read-only observe) buffer + * combined with the metadata summary. Surfaces subscribe to it imperatively so + * output chunks never re-render React. + */ +export function attachedTerminalSessionAtom( + environmentId: EnvironmentId | null, + terminal: TerminalAttachInput | null, +): Atom.Atom { + if (environmentId === null || terminal === null) return EMPTY_TERMINAL_SESSION_ATOM; + return attachedTerminalSessionFamily(JSON.stringify([environmentId, terminal])); } export function useKnownTerminalSessions(input: { diff --git a/knip.jsonc b/knip.jsonc index 6afa23625da81..ed757fe9c20c9 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -2,23 +2,6 @@ "$schema": "https://unpkg.com/knip@6/schema.json", // Exported types are part of our contracts even before they have consumers. "rules": { "types": "off", "nsTypes": "off" }, - // Temporary for this stacked PR: these exports gain their consumers in a later layer. - "ignoreIssues": { - "apps/web/src/components/chat/citationProcedure.ts": ["exports"], - "apps/web/src/components/chat/threadFindStore.ts": ["exports"], - "apps/web/src/components/chat/threadOverlayRetention.ts": ["exports"], - "apps/web/src/components/chat/timelineViewport.ts": ["exports"], - "apps/web/src/components/cloud/relayDiscoveryStore.ts": ["exports"], - "apps/web/src/components/device/deviceStreamSession.ts": ["exports"], - "apps/web/src/components/preview/agentOpenedPreviews.ts": ["exports"], - "apps/web/src/composerDraftStore.ts": ["exports"], - "apps/web/src/hooks/useEnvironmentDisconnectDelay.ts": ["exports"], - "apps/web/src/hooks/useLiveRegion.ts": ["exports"], - "apps/web/src/hooks/useMediaQuery.ts": ["exports"], - "apps/web/src/lib/terminalFocus.ts": ["exports"], - "apps/web/src/state/queries.ts": ["exports"], - "apps/web/src/state/revisionedVcsStatus.ts": ["exports"], - }, // These executables are supplied by the OS or installed separately from npm. "ignoreBinaries": ["eas", "file", "mkfifo", "pkill", "pkg-config", "plutil", "sips"], "workspaces": {