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": {