From 9c3e743a4560d91bfb480d46a4b6bc1f979b7bd7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 01:36:15 -0700 Subject: [PATCH 001/153] refactor(web): ChatView's module-level helpers live in their own files (#22665) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.logic.ts | 109 +++- apps/web/src/components/ChatView.tsx | 550 +----------------- .../components/chat/ThreadVisitWatermark.tsx | 86 +++ apps/web/src/components/chat/chatViewSend.ts | 237 ++++++++ .../chat/useDraftHeroLayoutTransition.ts | 69 +++ .../components/chat/useLocalDispatchState.ts | 66 +++ 6 files changed, 587 insertions(+), 530 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadVisitWatermark.tsx create mode 100644 apps/web/src/components/chat/chatViewSend.ts create mode 100644 apps/web/src/components/chat/useDraftHeroLayoutTransition.ts create mode 100644 apps/web/src/components/chat/useLocalDispatchState.ts diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 36f0feda2846..a64dbfb3e520 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,8 @@ import * as Option from "effect/Option"; +import type { SetStateAction } from "react"; +import { applyClaudePromptEffortPrefix, resolvePromptInjectedEffort } from "@t3tools/shared/model"; +import { getProviderModelCapabilities } from "../providerModels"; +import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { ANTIGRAVITY_DEFAULT_MODEL, @@ -48,7 +52,11 @@ import { type Thread, type TurnDiffSummary, } from "../types"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { + type ComposerImageAttachment, + type DraftId, + type DraftThreadState, +} from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadShells, environmentThreadDetails } from "../state/threads"; @@ -1335,3 +1343,102 @@ export function mergeDirectPreviewAnnotation(input: { } export { isThreadWokeUnseen } from "@t3tools/client-runtime/state/thread-settled"; + +export function formatOutgoingPrompt(params: { + provider: ProviderDriverKind; + model: string | null; + models: ReadonlyArray; + effort: string | null; + text: string; +}): string { + const caps = getProviderModelCapabilities(params.models, params.model, params.provider); + const promptEffort = resolvePromptInjectedEffort(caps, params.effort); + return applyClaudePromptEffortPrefix(params.text, promptEffort); +} + +export function isCompactCommandMessage(message: ChatMessage): boolean { + const text = message.text.trim().toLowerCase(); + return message.role === "user" && text === "/compact" && !message.attachments?.length; +} + +// Errors surface through two maps (draft-keyed and thread-keyed) whose entries +// can race around promotion, so each write carries its time to let the latest +// one win when they collide. +export type LocalThreadErrorEntry = { + readonly message: string | null; + readonly at: number; +}; + +export function chatActionErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "An error occurred."; +} + +export function releaseChatTimelineAnchor( + current: T, +): T { + return current.messageId === null ? current : { ...current, messageId: null }; +} + +// The factories below build functions ChatView calls during render, or hands +// to ChatComposer, which can call them after an await. They live outside the +// component so each closure keeps only its inputs, not a whole render scope +// (see useStableCallback), while still acting on the render that made them. + +/** Records a thread error in local state, keyed by the route the making render saw. */ +export function makeThreadErrorSetter(input: { + readonly routeThreadRef: ScopedThreadRef; + readonly routeThreadKey: string; + readonly draftId: DraftId | null; + readonly serverThreadRef: ScopedThreadRef | null; + readonly setLocalServerErrorsByThreadKey: ( + update: SetStateAction>, + ) => void; + readonly setLocalDraftErrorsByDraftId: ( + update: SetStateAction>, + ) => void; +}) { + return (targetThreadId: ThreadId | null, error: string | null) => { + if (!targetThreadId) return; + const nextError = sanitizeThreadErrorMessage(error); + const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; + const { routeThreadRef, serverThreadRef } = input; + if ( + serverThreadRef && + targetThreadId === routeThreadRef.threadId && + serverThreadRef.environmentId === routeThreadRef.environmentId && + serverThreadRef.threadId === targetThreadId + ) { + input.setLocalServerErrorsByThreadKey((existing) => { + if ((existing[input.routeThreadKey]?.message ?? null) === nextError) { + return existing; + } + return { ...existing, [input.routeThreadKey]: nextEntry }; + }); + return; + } + const localDraftErrorKey = input.draftId ?? targetThreadId; + input.setLocalDraftErrorsByDraftId((existing) => { + if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { + return existing; + } + return { ...existing, [localDraftErrorKey]: nextEntry }; + }); + }; +} + +/** Why the model picker should disable a model, or null when it can be chosen. */ +export function makeModelDisabledReason( + context: Omit< + Parameters[0], + "nextModelSelection" + > | null, +) { + return (instanceId: ProviderInstanceId, model: string): string | null => { + if (!context) return null; + const reason = getStartedThreadModelChangeBlockReason({ + ...context, + nextModelSelection: { instanceId, model }, + }); + return reason ? `${reason.description} Start a new thread to use this model.` : null; + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f8b37133d460..b5c536bffd8b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -60,7 +60,6 @@ import { AuthTerminalOperateScope, type AssistantCitation, type ChatFileAttachment, - CommandId, DEFAULT_MODEL, isProviderNativeSubagentThread, type EnvironmentId, @@ -75,19 +74,13 @@ import { type ThreadId, type RunId, type RunAttemptId, - type RuntimeRequestId, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode, - ProviderDriverKind, resolveProjectProviderInstanceEnabled, RuntimeMode, } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; -import { - wasBootstrapThreadDeleted, - wasBootstrapThreadNotCreated, -} from "@t3tools/client-runtime/errors"; +import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { readPastedComposerContext } from "./composerInlineTokenPaste"; import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; @@ -128,10 +121,8 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { - applyClaudePromptEffortPrefix, createModelSelection, formatModelSlugName, - resolvePromptInjectedEffort, resolveSelectableModel, } from "@t3tools/shared/model"; import { @@ -152,7 +143,6 @@ import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReferenc import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; -import { Atom } from "effect/reactivity"; import { Fragment, type SetStateAction, @@ -214,13 +204,7 @@ import { buildPlanImplementationPrompt, resolvePlanFollowUpSubmission, } from "../proposedPlan"; -import { - DEFAULT_INTERACTION_MODE, - MAX_TERMINALS_PER_GROUP, - type ChatMessage, - type SessionPhase, - type Thread, -} from "../types"; +import { DEFAULT_INTERACTION_MODE, MAX_TERMINALS_PER_GROUP, type ChatMessage } from "../types"; import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isCommandPaletteOpen } from "../commandPaletteBus"; @@ -284,13 +268,12 @@ import { TargetIcon, WifiOffIcon, } from "lucide-react"; -import { cn, randomUUID } from "~/lib/utils"; +import { cn } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; -import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -395,7 +378,6 @@ import { useThreadStatus, useThreadHistory, useQuietThreadShell, - useThreadShell, useChildThreadInputs, useThreadRefs, useThreadVisibleTurnItems, @@ -480,8 +462,6 @@ import { latestProviderTurnTokenUsage, } from "../lib/contextWindow"; import { - DRAFT_HERO_TRANSITION_ANIMATION_ID, - DRAFT_HERO_TRANSITION_EASING, MOBILE_COMPOSER_VIEW_TRANSITION_NAME, MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, runMobileComposerTransition, @@ -493,13 +473,11 @@ import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, - createLocalDispatchSnapshot, deriveCommittedServerUserMessageIds, deriveComposerSendState, dismissBranchMismatchForSession, hasEnvironmentReconnectWarningGraceElapsed, scheduleEnvironmentReconnectWarning, - hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, @@ -508,13 +486,10 @@ import { getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, - type LocalDispatchSnapshot, cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, resolveFileAttachmentUrl, - prepareRevertedMessageAttachments, - waitForRevertedMessage, reconcileMountedTerminalThreadIds, resolveComposerInteractionMode, resolveComposerProviderSelection, @@ -538,7 +513,6 @@ import { retryAttachmentUpload, startAttachmentUpload, } from "../lib/attachmentUploadQueue"; -import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; @@ -571,8 +545,25 @@ import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, threadContextsOutsideEnvironment, } from "@t3tools/shared/composerContextReferences"; -import { recallableComposerPrompt } from "./chat/composerPromptHistory"; import { observeResize } from "~/lib/observeResize"; +import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; +import { useDraftHeroLayoutTransition } from "./chat/useDraftHeroLayoutTransition"; +import { useLocalDispatchState } from "./chat/useLocalDispatchState"; +import { + draftFanoutSelectionsAtom, + draftSendState, + rewindToMessage, + startFanoutThread, +} from "./chat/chatViewSend"; +import { + chatActionErrorMessage, + formatOutgoingPrompt, + isCompactCommandMessage, + type LocalThreadErrorEntry, + makeModelDisabledReason, + makeThreadErrorSetter, + releaseChatTimelineAnchor, +} from "./ChatView.logic"; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = []; @@ -581,75 +572,9 @@ import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplat const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray = []; -// During an active turn the thread's updatedAt advances several times per -// second, and every server-side visit is a full command dispatch plus a -// broadcast to all shell subscribers. -const VISIT_DISPATCH_THROTTLE_MS = 10_000; const RELATED_THREAD_SHELL_WAIT_MS = 1_000; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_AUTO_UPDATE_ENVIRONMENTS: ReadonlyArray = []; -function useDraftHeroLayoutTransition( - isDraftHeroState: boolean, - animationsActive: boolean, - animationDurationMs: number, -) { - const transitionGroupRef = useRef(null); - const composerAnchorRef = useRef(null); - const previousStateRef = useRef(isDraftHeroState); - const previousComposerRectRef = useRef(null); - const animationRef = useRef(null); - const captureComposerRect = useStableCallback(() => { - previousComposerRectRef.current = composerAnchorRef.current?.getBoundingClientRect() ?? null; - }); - - useLayoutEffect(() => { - const transitionGroup = transitionGroupRef.current; - const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; - const stateChanged = previousStateRef.current !== isDraftHeroState; - const mobileComposerTransitionActive = - typeof document !== "undefined" && - document.documentElement.dataset.mobileComposerRouteTransition === "true"; - - animationRef.current?.cancel(); - animationRef.current = null; - const previousComposerRect = previousComposerRectRef.current; - if ( - stateChanged && - animationsActive && - !mobileComposerTransitionActive && - transitionGroup && - previousComposerRect && - nextComposerRect && - typeof transitionGroup.animate === "function" - ) { - const translateX = previousComposerRect.left - nextComposerRect.left; - const translateY = previousComposerRect.top - nextComposerRect.top; - if (Math.abs(translateX) >= 0.5 || Math.abs(translateY) >= 0.5) { - const animation = transitionGroup.animate( - [ - { transform: `translate3d(${translateX}px, ${translateY}px, 0)` }, - { transform: "translate3d(0, 0, 0)" }, - ], - { - duration: animationDurationMs, - easing: DRAFT_HERO_TRANSITION_EASING, - }, - ); - animation.id = DRAFT_HERO_TRANSITION_ANIMATION_ID; - animationRef.current = animation; - void animation.finished - .catch(() => undefined) - .then(() => { - if (animationRef.current === animation) animationRef.current = null; - }); - } - } - previousStateRef.current = isDraftHeroState; - previousComposerRectRef.current = nextComposerRect; - }, [animationDurationMs, animationsActive, isDraftHeroState]); - - return { transitionGroupRef, composerAnchorRef, captureComposerRect } as const; -} const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); type EnvironmentUnavailableState = { @@ -658,230 +583,9 @@ type EnvironmentUnavailableState = { readonly connection: EnvironmentConnectionPresentation; }; -// Kept per route so a send in flight, and the multiple-model selections, survive -// ChatView remounting while the user navigates. -const draftFanoutSelectionsAtom = Atom.family((_routeKey: string) => - Atom.make | null>(null).pipe(Atom.keepAlive), -); -const draftSendStateByRouteKey = new Map< - string, - { - readonly sendInFlight: { current: boolean }; - readonly uncertainSubmissions: { current: Map }; - } ->(); -function draftSendState(routeKey: string) { - let state = draftSendStateByRouteKey.get(routeKey); - if (!state) { - state = { sendInFlight: { current: false }, uncertainSubmissions: { current: new Map() } }; - draftSendStateByRouteKey.set(routeKey, state); - } - return state; -} - -/** - * Starts one thread of a multiple-model send. A request that may have - * reached the server before failing is remembered by `retryKey`, so sending - * again cannot silently create a duplicate thread; its failure toast offers - * to open that thread or to allow the retry. - */ -async function startFanoutThread(input: { - readonly retryKey: string; - readonly model: string; - readonly uncertainSubmissions: Map; - readonly start: (threadId: ThreadId) => Promise>; - readonly openThread: (threadId: ThreadId) => void; -}): Promise { - const { retryKey, uncertainSubmissions } = input; - const uncertainThreadId = uncertainSubmissions.get(retryKey); - const targetThreadId = uncertainThreadId ?? newThreadId(); - let requestMayHaveStarted = false; - try { - if (uncertainThreadId) { - throw new Error( - "The previous request may have started. Open its thread to check before sending again.", - ); - } - requestMayHaveStarted = true; - const result = await input.start(targetThreadId); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - if (wasBootstrapThreadDeleted(error) || wasBootstrapThreadNotCreated(error)) { - requestMayHaveStarted = false; - } - throw error; - } - return true; - } catch (error) { - if (requestMayHaveStarted && !uncertainSubmissions.has(retryKey)) { - uncertainSubmissions.set(retryKey, targetThreadId); - } - const retainedThreadId = uncertainSubmissions.get(retryKey); - const failureToastId = toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not start ${input.model}`, - description: error instanceof Error ? error.message : "Failed to send message.", - ...(retainedThreadId - ? { - timeout: 0, - data: { - secondaryActionProps: { - children: "Allow retry", - onClick: () => { - void readLocalApi() - ?.dialogs.confirm( - "The previous request may already be running. Check its thread first. Allow another send that could create a duplicate thread?", - ) - .then( - (confirmed) => { - if ( - confirmed && - uncertainSubmissions.get(retryKey) === retainedThreadId - ) { - uncertainSubmissions.delete(retryKey); - toastManager.close(failureToastId); - } - }, - () => undefined, - ); - }, - }, - }, - actionProps: { - children: "Open thread", - onClick: () => input.openThread(retainedThreadId), - }, - } - : {}), - }), - ); - return false; - } -} - -/** - * Reverts the thread to before `message` and puts that message's text and - * attachments back in the composer draft. Returns the restored prompt, or the - * error to show; on error the draft is untouched. - */ -async function rewindToMessage(input: { - readonly message: ChatMessage; - readonly environmentId: EnvironmentId; - readonly routeThreadRef: ScopedThreadRef; - readonly threadId: ThreadId; - readonly turnCount: number; - readonly restoreFiles: boolean; - readonly checkpoint: ReturnType; - readonly draftTarget: ScopedThreadRef | DraftId; - readonly composerHasPendingAttachments: boolean; - readonly createAssetUrl: Parameters< - typeof prepareRevertedMessageAttachments - >[0]["createAssetUrl"]; - readonly revertThreadCheckpoint: (value: { - environmentId: EnvironmentId; - input: { - commandId: CommandId; - threadId: ThreadId; - turnCount: number; - restoreFiles: boolean; - checkpointId?: string; - scopeId?: string; - }; - }) => Promise>; -}): Promise< - | { readonly error: string; readonly prompt?: never } - | { readonly error: null; readonly prompt: string } -> { - const { message, environmentId, draftTarget } = input; - try { - if (input.composerHasPendingAttachments) { - throw new Error("Wait for attachments to finish preparing before rewinding."); - } - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error("The environment is not connected."); - const files = await prepareRevertedMessageAttachments({ - message, - environmentId, - httpBaseUrl: connection.httpBaseUrl, - createAssetUrl: input.createAssetUrl, - }); - const store = useComposerDraftStore.getState(); - const draft = store.getComposerDraft(draftTarget); - if ( - (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length > - PROVIDER_SEND_TURN_MAX_ATTACHMENTS - ) { - throw new Error("Make room for this message's attachments in the composer before rewinding."); - } - const commandId = CommandId.make(randomUUID()); - await waitForRevertedMessage(input.routeThreadRef, input.turnCount, commandId, async () => { - const result = await input.revertThreadCheckpoint({ - environmentId, - input: { - commandId, - threadId: input.threadId, - turnCount: input.turnCount, - restoreFiles: input.restoreFiles, - ...(input.checkpoint === undefined - ? {} - : { checkpointId: input.checkpoint.id, scopeId: input.checkpoint.scopeId }), - }, - }); - if (result._tag === "Failure") throw squashAtomCommandFailure(result); - }); - const currentPrompt = store.getComposerDraft(draftTarget)?.prompt ?? ""; - const restoredPrompt = recallableComposerPrompt(message.text); - const nextPrompt = - restoredPrompt.length === 0 - ? currentPrompt - : currentPrompt.length > 0 - ? `${currentPrompt}\n\n${restoredPrompt}` - : restoredPrompt; - store.setPrompt(draftTarget, nextPrompt); - const images: ComposerImageAttachment[] = []; - const restoredFiles: ComposerFileAttachment[] = []; - files.forEach((file, index) => { - const attachment = { - id: randomUUID(), - name: file.name, - mimeType: file.type, - sizeBytes: file.size, - file, - }; - if (message.attachments?.[index]?.type === "image") { - images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) }); - } else { - restoredFiles.push({ ...attachment, type: "file" }); - } - }); - store.addImages(draftTarget, images, { allowDuplicates: true }); - store.addFiles(draftTarget, restoredFiles, { allowDuplicates: true }); - return { error: null, prompt: nextPrompt }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Failed to revert thread state." }; - } -} - -function formatOutgoingPrompt(params: { - provider: ProviderDriverKind; - model: string | null; - models: ReadonlyArray; - effort: string | null; - text: string; -}): string { - const caps = getProviderModelCapabilities(params.models, params.model, params.provider); - const promptEffort = resolvePromptInjectedEffort(caps, params.effort); - return applyClaudePromptEffortPrefix(params.text, promptEffort); -} const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; -function isCompactCommandMessage(message: ChatMessage): boolean { - const text = message.text.trim().toLowerCase(); - return message.role === "user" && text === "/compact" && !message.attachments?.length; -} - type ChatViewProps = | { environmentId: EnvironmentId; @@ -908,151 +612,12 @@ interface TerminalLaunchContext { worktreePath: string | null; } -function useLocalDispatchState(input: { - activeThread: Thread | undefined; - activeLatestRun: Thread["latestRun"] | null; - latestUserMessageId: MessageId | null; - phase: SessionPhase; - activePendingApproval: RuntimeRequestId | null; - activePendingUserInput: RuntimeRequestId | null; - threadError: string | null | undefined; -}) { - const [localDispatch, setLocalDispatch] = useState(null); - - const resetLocalDispatch = () => { - setLocalDispatch(null); - }; - - const serverAcknowledgedLocalDispatch = hasServerAcknowledgedLocalDispatch({ - localDispatch, - phase: input.phase, - latestRun: input.activeLatestRun, - latestUserMessageId: input.latestUserMessageId, - runtime: input.activeThread?.runtime ?? null, - hasPendingApproval: input.activePendingApproval !== null, - hasPendingUserInput: input.activePendingUserInput !== null, - threadError: input.threadError, - }); - const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; - const beginLocalDispatch = (options?: { - preparingWorktree?: boolean; - submissionIntent?: ComposerSubmissionIntent; - }) => { - const preparingWorktree = Boolean(options?.preparingWorktree); - setLocalDispatch((current) => { - const active = serverAcknowledgedLocalDispatch ? null : current; - if (active) { - const submissionIntent = options?.submissionIntent ?? active.submissionIntent; - return active.preparingWorktree === preparingWorktree && - active.submissionIntent === submissionIntent - ? active - : { ...active, preparingWorktree, submissionIntent }; - } - return createLocalDispatchSnapshot(input.activeThread, { - ...options, - latestUserMessageId: input.latestUserMessageId, - }); - }); - }; - - return { - beginLocalDispatch, - resetLocalDispatch, - localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, - isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, - isSendBusy: activeLocalDispatch !== null, - backgroundSubmissionPending: activeLocalDispatch?.submissionIntent === "background", - }; -} - -// Errors surface through two maps (draft-keyed and thread-keyed) whose entries -// can race around promotion, so each write carries its time to let the latest -// one win when they collide. -type LocalThreadErrorEntry = { - readonly message: string | null; - readonly at: number; -}; - -function chatActionErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : "An error occurred."; -} - const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = []; const noopHeldTurnDiff = (_turnId: RunId, _filePath?: string) => {}; const noopHeldRevert = (_targetTurnCount: number) => {}; const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; -function releaseChatTimelineAnchor( - current: T, -): T { - return current.messageId === null ? current : { ...current, messageId: null }; -} - -// The factories below build functions ChatView calls during render, or hands -// to ChatComposer, which can call them after an await. They live outside the -// component so each closure keeps only its inputs, not a whole render scope -// (see useStableCallback), while still acting on the render that made them. - -/** Records a thread error in local state, keyed by the route the making render saw. */ -function makeThreadErrorSetter(input: { - readonly routeThreadRef: ScopedThreadRef; - readonly routeThreadKey: string; - readonly draftId: DraftId | null; - readonly serverThreadRef: ScopedThreadRef | null; - readonly setLocalServerErrorsByThreadKey: ( - update: SetStateAction>, - ) => void; - readonly setLocalDraftErrorsByDraftId: ( - update: SetStateAction>, - ) => void; -}) { - return (targetThreadId: ThreadId | null, error: string | null) => { - if (!targetThreadId) return; - const nextError = sanitizeThreadErrorMessage(error); - const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; - const { routeThreadRef, serverThreadRef } = input; - if ( - serverThreadRef && - targetThreadId === routeThreadRef.threadId && - serverThreadRef.environmentId === routeThreadRef.environmentId && - serverThreadRef.threadId === targetThreadId - ) { - input.setLocalServerErrorsByThreadKey((existing) => { - if ((existing[input.routeThreadKey]?.message ?? null) === nextError) { - return existing; - } - return { ...existing, [input.routeThreadKey]: nextEntry }; - }); - return; - } - const localDraftErrorKey = input.draftId ?? targetThreadId; - input.setLocalDraftErrorsByDraftId((existing) => { - if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { - return existing; - } - return { ...existing, [localDraftErrorKey]: nextEntry }; - }); - }; -} - -/** Why the model picker should disable a model, or null when it can be chosen. */ -function makeModelDisabledReason( - context: Omit< - Parameters[0], - "nextModelSelection" - > | null, -) { - return (instanceId: ProviderInstanceId, model: string): string | null => { - if (!context) return null; - const reason = getStartedThreadModelChangeBlockReason({ - ...context, - nextModelSelection: { instanceId, model }, - }); - return reason ? `${reason.description} Start a new thread to use this model.` : null; - }; -} - export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -9018,76 +8583,3 @@ function PreviewSessionSync(props: { readonly threadRef: ScopedThreadRef }) { usePreviewSession(props.threadRef); return null; } - -// The visit watermark is the one part of the chat view that follows the -// shell's updatedAt, which ticks while a run streams. Keeping it in a leaf lets -// ChatView read the quiet shell. -function ThreadVisitWatermark(props: { readonly threadRef: ScopedThreadRef }) { - const serverThread = useThreadShell(props.threadRef); - const threadKey = scopedThreadKey(props.threadRef); - const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLocalLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[threadKey], - ); - const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); - const lastDispatchedVisitRef = useRef(null); - const lastVisitDispatchAtRef = useRef(0); - - useEffect(() => { - if (!serverThread?.id) return; - const threadUpdatedAt = Date.parse(serverThread.updatedAt); - if (Number.isNaN(threadUpdatedAt)) return; - const effectiveLastVisitedAt = resolveThreadLastVisitedAt( - serverThread.lastVisitedAt, - activeThreadLocalLastVisitedAt, - ); - const lastVisitedAt = effectiveLastVisitedAt ? Date.parse(effectiveLastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; - - if (serverThread.lastVisitedAt !== undefined) { - // Dedupe per watermark — the effect re-runs before the command's echo - // lands. The dedupe also keeps a mark-unread on the open thread sticky: - // the rewind leaves updatedAt untouched, so the already-dispatched key - // skips a fresh visit until new activity lands or the thread is reopened. - const dispatchKey = `${threadKey}:${serverThread.updatedAt}`; - if (lastDispatchedVisitRef.current === dispatchKey) return; - const dispatch = () => { - lastDispatchedVisitRef.current = dispatchKey; - lastVisitDispatchAtRef.current = Date.now(); - void visitThreadMutation({ - environmentId: serverThread.environmentId, - input: { threadId: serverThread.id, visitedAt: serverThread.updatedAt }, - }); - }; - const latestRunCompletedAtMs = serverThread.latestRun?.completedAt - ? Date.parse(serverThread.latestRun.completedAt) - : NaN; - const hasUnseenCompletion = - !Number.isNaN(latestRunCompletedAtMs) && - (Number.isNaN(lastVisitedAt) || latestRunCompletedAtMs > lastVisitedAt); - const elapsed = Date.now() - lastVisitDispatchAtRef.current; - if (hasUnseenCompletion || elapsed >= VISIT_DISPATCH_THROTTLE_MS) { - dispatch(); - return; - } - const timer = setTimeout(dispatch, VISIT_DISPATCH_THROTTLE_MS - elapsed); - return () => clearTimeout(timer); - } - - markThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - serverThread.updatedAt, - ); - }, [ - activeThreadLocalLastVisitedAt, - markThreadVisited, - threadKey, - serverThread?.environmentId, - serverThread?.id, - serverThread?.lastVisitedAt, - serverThread?.latestRun?.completedAt, - serverThread?.updatedAt, - visitThreadMutation, - ]); - return null; -} diff --git a/apps/web/src/components/chat/ThreadVisitWatermark.tsx b/apps/web/src/components/chat/ThreadVisitWatermark.tsx new file mode 100644 index 000000000000..9d36f6f73ca3 --- /dev/null +++ b/apps/web/src/components/chat/ThreadVisitWatermark.tsx @@ -0,0 +1,86 @@ +import { type ScopedThreadRef } from "@t3tools/contracts"; +import { resolveThreadLastVisitedAt } from "../Sidebar.logic"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useEffect, useRef } from "react"; +import { useUiStateStore } from "../../uiStateStore"; +import { threadEnvironment } from "../../state/threads"; +import { useThreadShell } from "../../state/entities"; +import { useAtomCommand } from "../../state/use-atom-command"; + +// During an active turn the thread's updatedAt advances several times per +// second, and every server-side visit is a full command dispatch plus a +// broadcast to all shell subscribers. +const VISIT_DISPATCH_THROTTLE_MS = 10_000; + +// The visit watermark is the one part of the chat view that follows the +// shell's updatedAt, which ticks while a run streams. Keeping it in a leaf lets +// ChatView read the quiet shell. +export function ThreadVisitWatermark(props: { readonly threadRef: ScopedThreadRef }) { + const serverThread = useThreadShell(props.threadRef); + const threadKey = scopedThreadKey(props.threadRef); + const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); + const activeThreadLocalLastVisitedAt = useUiStateStore( + (store) => store.threadLastVisitedAtById[threadKey], + ); + const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); + const lastDispatchedVisitRef = useRef(null); + const lastVisitDispatchAtRef = useRef(0); + + useEffect(() => { + if (!serverThread?.id) return; + const threadUpdatedAt = Date.parse(serverThread.updatedAt); + if (Number.isNaN(threadUpdatedAt)) return; + const effectiveLastVisitedAt = resolveThreadLastVisitedAt( + serverThread.lastVisitedAt, + activeThreadLocalLastVisitedAt, + ); + const lastVisitedAt = effectiveLastVisitedAt ? Date.parse(effectiveLastVisitedAt) : NaN; + if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; + + if (serverThread.lastVisitedAt !== undefined) { + // Dedupe per watermark — the effect re-runs before the command's echo + // lands. The dedupe also keeps a mark-unread on the open thread sticky: + // the rewind leaves updatedAt untouched, so the already-dispatched key + // skips a fresh visit until new activity lands or the thread is reopened. + const dispatchKey = `${threadKey}:${serverThread.updatedAt}`; + if (lastDispatchedVisitRef.current === dispatchKey) return; + const dispatch = () => { + lastDispatchedVisitRef.current = dispatchKey; + lastVisitDispatchAtRef.current = Date.now(); + void visitThreadMutation({ + environmentId: serverThread.environmentId, + input: { threadId: serverThread.id, visitedAt: serverThread.updatedAt }, + }); + }; + const latestRunCompletedAtMs = serverThread.latestRun?.completedAt + ? Date.parse(serverThread.latestRun.completedAt) + : NaN; + const hasUnseenCompletion = + !Number.isNaN(latestRunCompletedAtMs) && + (Number.isNaN(lastVisitedAt) || latestRunCompletedAtMs > lastVisitedAt); + const elapsed = Date.now() - lastVisitDispatchAtRef.current; + if (hasUnseenCompletion || elapsed >= VISIT_DISPATCH_THROTTLE_MS) { + dispatch(); + return; + } + const timer = setTimeout(dispatch, VISIT_DISPATCH_THROTTLE_MS - elapsed); + return () => clearTimeout(timer); + } + + markThreadVisited( + scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), + serverThread.updatedAt, + ); + }, [ + activeThreadLocalLastVisitedAt, + markThreadVisited, + threadKey, + serverThread?.environmentId, + serverThread?.id, + serverThread?.lastVisitedAt, + serverThread?.latestRun?.completedAt, + serverThread?.updatedAt, + visitThreadMutation, + ]); + return null; +} diff --git a/apps/web/src/components/chat/chatViewSend.ts b/apps/web/src/components/chat/chatViewSend.ts new file mode 100644 index 000000000000..b5404bc47c2c --- /dev/null +++ b/apps/web/src/components/chat/chatViewSend.ts @@ -0,0 +1,237 @@ +import { + CommandId, + type EnvironmentId, + type ModelSelection, + type ScopedThreadRef, + type ThreadId, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, +} from "@t3tools/contracts"; +import { + wasBootstrapThreadDeleted, + wasBootstrapThreadNotCreated, +} from "@t3tools/client-runtime/errors"; +import { Atom } from "effect/reactivity"; +import { + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { readLocalApi } from "../../localApi"; +import { findRevertCheckpoint } from "../../session-logic"; +import { type ChatMessage } from "../../types"; +import { randomUUID } from "~/lib/utils"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { newThreadId } from "~/lib/utils"; +import { + type ComposerFileAttachment, + type ComposerImageAttachment, + useComposerDraftStore, + DraftId, +} from "../../composerDraftStore"; +import { prepareRevertedMessageAttachments, waitForRevertedMessage } from "../ChatView.logic"; +import { readPreparedConnection } from "../../state/session"; +import { recallableComposerPrompt } from "./composerPromptHistory"; + +// Kept per route so a send in flight, and the multiple-model selections, survive +// ChatView remounting while the user navigates. +export const draftFanoutSelectionsAtom = Atom.family((_routeKey: string) => + Atom.make | null>(null).pipe(Atom.keepAlive), +); +const draftSendStateByRouteKey = new Map< + string, + { + readonly sendInFlight: { current: boolean }; + readonly uncertainSubmissions: { current: Map }; + } +>(); +export function draftSendState(routeKey: string) { + let state = draftSendStateByRouteKey.get(routeKey); + if (!state) { + state = { sendInFlight: { current: false }, uncertainSubmissions: { current: new Map() } }; + draftSendStateByRouteKey.set(routeKey, state); + } + return state; +} + +/** + * Starts one thread of a multiple-model send. A request that may have + * reached the server before failing is remembered by `retryKey`, so sending + * again cannot silently create a duplicate thread; its failure toast offers + * to open that thread or to allow the retry. + */ +export async function startFanoutThread(input: { + readonly retryKey: string; + readonly model: string; + readonly uncertainSubmissions: Map; + readonly start: (threadId: ThreadId) => Promise>; + readonly openThread: (threadId: ThreadId) => void; +}): Promise { + const { retryKey, uncertainSubmissions } = input; + const uncertainThreadId = uncertainSubmissions.get(retryKey); + const targetThreadId = uncertainThreadId ?? newThreadId(); + let requestMayHaveStarted = false; + try { + if (uncertainThreadId) { + throw new Error( + "The previous request may have started. Open its thread to check before sending again.", + ); + } + requestMayHaveStarted = true; + const result = await input.start(targetThreadId); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + if (wasBootstrapThreadDeleted(error) || wasBootstrapThreadNotCreated(error)) { + requestMayHaveStarted = false; + } + throw error; + } + return true; + } catch (error) { + if (requestMayHaveStarted && !uncertainSubmissions.has(retryKey)) { + uncertainSubmissions.set(retryKey, targetThreadId); + } + const retainedThreadId = uncertainSubmissions.get(retryKey); + const failureToastId = toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not start ${input.model}`, + description: error instanceof Error ? error.message : "Failed to send message.", + ...(retainedThreadId + ? { + timeout: 0, + data: { + secondaryActionProps: { + children: "Allow retry", + onClick: () => { + void readLocalApi() + ?.dialogs.confirm( + "The previous request may already be running. Check its thread first. Allow another send that could create a duplicate thread?", + ) + .then( + (confirmed) => { + if ( + confirmed && + uncertainSubmissions.get(retryKey) === retainedThreadId + ) { + uncertainSubmissions.delete(retryKey); + toastManager.close(failureToastId); + } + }, + () => undefined, + ); + }, + }, + }, + actionProps: { + children: "Open thread", + onClick: () => input.openThread(retainedThreadId), + }, + } + : {}), + }), + ); + return false; + } +} + +/** + * Reverts the thread to before `message` and puts that message's text and + * attachments back in the composer draft. Returns the restored prompt, or the + * error to show; on error the draft is untouched. + */ +export async function rewindToMessage(input: { + readonly message: ChatMessage; + readonly environmentId: EnvironmentId; + readonly routeThreadRef: ScopedThreadRef; + readonly threadId: ThreadId; + readonly turnCount: number; + readonly restoreFiles: boolean; + readonly checkpoint: ReturnType; + readonly draftTarget: ScopedThreadRef | DraftId; + readonly composerHasPendingAttachments: boolean; + readonly createAssetUrl: Parameters< + typeof prepareRevertedMessageAttachments + >[0]["createAssetUrl"]; + readonly revertThreadCheckpoint: (value: { + environmentId: EnvironmentId; + input: { + commandId: CommandId; + threadId: ThreadId; + turnCount: number; + restoreFiles: boolean; + checkpointId?: string; + scopeId?: string; + }; + }) => Promise>; +}): Promise< + | { readonly error: string; readonly prompt?: never } + | { readonly error: null; readonly prompt: string } +> { + const { message, environmentId, draftTarget } = input; + try { + if (input.composerHasPendingAttachments) { + throw new Error("Wait for attachments to finish preparing before rewinding."); + } + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error("The environment is not connected."); + const files = await prepareRevertedMessageAttachments({ + message, + environmentId, + httpBaseUrl: connection.httpBaseUrl, + createAssetUrl: input.createAssetUrl, + }); + const store = useComposerDraftStore.getState(); + const draft = store.getComposerDraft(draftTarget); + if ( + (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length > + PROVIDER_SEND_TURN_MAX_ATTACHMENTS + ) { + throw new Error("Make room for this message's attachments in the composer before rewinding."); + } + const commandId = CommandId.make(randomUUID()); + await waitForRevertedMessage(input.routeThreadRef, input.turnCount, commandId, async () => { + const result = await input.revertThreadCheckpoint({ + environmentId, + input: { + commandId, + threadId: input.threadId, + turnCount: input.turnCount, + restoreFiles: input.restoreFiles, + ...(input.checkpoint === undefined + ? {} + : { checkpointId: input.checkpoint.id, scopeId: input.checkpoint.scopeId }), + }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + }); + const currentPrompt = store.getComposerDraft(draftTarget)?.prompt ?? ""; + const restoredPrompt = recallableComposerPrompt(message.text); + const nextPrompt = + restoredPrompt.length === 0 + ? currentPrompt + : currentPrompt.length > 0 + ? `${currentPrompt}\n\n${restoredPrompt}` + : restoredPrompt; + store.setPrompt(draftTarget, nextPrompt); + const images: ComposerImageAttachment[] = []; + const restoredFiles: ComposerFileAttachment[] = []; + files.forEach((file, index) => { + const attachment = { + id: randomUUID(), + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + file, + }; + if (message.attachments?.[index]?.type === "image") { + images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) }); + } else { + restoredFiles.push({ ...attachment, type: "file" }); + } + }); + store.addImages(draftTarget, images, { allowDuplicates: true }); + store.addFiles(draftTarget, restoredFiles, { allowDuplicates: true }); + return { error: null, prompt: nextPrompt }; + } catch (error) { + return { error: error instanceof Error ? error.message : "Failed to revert thread state." }; + } +} diff --git a/apps/web/src/components/chat/useDraftHeroLayoutTransition.ts b/apps/web/src/components/chat/useDraftHeroLayoutTransition.ts new file mode 100644 index 000000000000..a156512a3d37 --- /dev/null +++ b/apps/web/src/components/chat/useDraftHeroLayoutTransition.ts @@ -0,0 +1,69 @@ +import { useStableCallback } from "../../hooks/useStableCallback"; +import { useLayoutEffect, useRef } from "react"; +import { + DRAFT_HERO_TRANSITION_ANIMATION_ID, + DRAFT_HERO_TRANSITION_EASING, +} from "./draftHeroTransition"; + +export function useDraftHeroLayoutTransition( + isDraftHeroState: boolean, + animationsActive: boolean, + animationDurationMs: number, +) { + const transitionGroupRef = useRef(null); + const composerAnchorRef = useRef(null); + const previousStateRef = useRef(isDraftHeroState); + const previousComposerRectRef = useRef(null); + const animationRef = useRef(null); + const captureComposerRect = useStableCallback(() => { + previousComposerRectRef.current = composerAnchorRef.current?.getBoundingClientRect() ?? null; + }); + + useLayoutEffect(() => { + const transitionGroup = transitionGroupRef.current; + const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; + const stateChanged = previousStateRef.current !== isDraftHeroState; + const mobileComposerTransitionActive = + typeof document !== "undefined" && + document.documentElement.dataset.mobileComposerRouteTransition === "true"; + + animationRef.current?.cancel(); + animationRef.current = null; + const previousComposerRect = previousComposerRectRef.current; + if ( + stateChanged && + animationsActive && + !mobileComposerTransitionActive && + transitionGroup && + previousComposerRect && + nextComposerRect && + typeof transitionGroup.animate === "function" + ) { + const translateX = previousComposerRect.left - nextComposerRect.left; + const translateY = previousComposerRect.top - nextComposerRect.top; + if (Math.abs(translateX) >= 0.5 || Math.abs(translateY) >= 0.5) { + const animation = transitionGroup.animate( + [ + { transform: `translate3d(${translateX}px, ${translateY}px, 0)` }, + { transform: "translate3d(0, 0, 0)" }, + ], + { + duration: animationDurationMs, + easing: DRAFT_HERO_TRANSITION_EASING, + }, + ); + animation.id = DRAFT_HERO_TRANSITION_ANIMATION_ID; + animationRef.current = animation; + void animation.finished + .catch(() => undefined) + .then(() => { + if (animationRef.current === animation) animationRef.current = null; + }); + } + } + previousStateRef.current = isDraftHeroState; + previousComposerRectRef.current = nextComposerRect; + }, [animationDurationMs, animationsActive, isDraftHeroState]); + + return { transitionGroupRef, composerAnchorRef, captureComposerRect } as const; +} diff --git a/apps/web/src/components/chat/useLocalDispatchState.ts b/apps/web/src/components/chat/useLocalDispatchState.ts new file mode 100644 index 000000000000..977028db17b9 --- /dev/null +++ b/apps/web/src/components/chat/useLocalDispatchState.ts @@ -0,0 +1,66 @@ +import { type MessageId, type RuntimeRequestId } from "@t3tools/contracts"; +import { useState } from "react"; +import { type ComposerSubmissionIntent } from "../../composer-logic"; +import { type SessionPhase, type Thread } from "../../types"; +import { + createLocalDispatchSnapshot, + hasServerAcknowledgedLocalDispatch, + type LocalDispatchSnapshot, +} from "../ChatView.logic"; + +export function useLocalDispatchState(input: { + activeThread: Thread | undefined; + activeLatestRun: Thread["latestRun"] | null; + latestUserMessageId: MessageId | null; + phase: SessionPhase; + activePendingApproval: RuntimeRequestId | null; + activePendingUserInput: RuntimeRequestId | null; + threadError: string | null | undefined; +}) { + const [localDispatch, setLocalDispatch] = useState(null); + + const resetLocalDispatch = () => { + setLocalDispatch(null); + }; + + const serverAcknowledgedLocalDispatch = hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: input.phase, + latestRun: input.activeLatestRun, + latestUserMessageId: input.latestUserMessageId, + runtime: input.activeThread?.runtime ?? null, + hasPendingApproval: input.activePendingApproval !== null, + hasPendingUserInput: input.activePendingUserInput !== null, + threadError: input.threadError, + }); + const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; + const beginLocalDispatch = (options?: { + preparingWorktree?: boolean; + submissionIntent?: ComposerSubmissionIntent; + }) => { + const preparingWorktree = Boolean(options?.preparingWorktree); + setLocalDispatch((current) => { + const active = serverAcknowledgedLocalDispatch ? null : current; + if (active) { + const submissionIntent = options?.submissionIntent ?? active.submissionIntent; + return active.preparingWorktree === preparingWorktree && + active.submissionIntent === submissionIntent + ? active + : { ...active, preparingWorktree, submissionIntent }; + } + return createLocalDispatchSnapshot(input.activeThread, { + ...options, + latestUserMessageId: input.latestUserMessageId, + }); + }); + }; + + return { + beginLocalDispatch, + resetLocalDispatch, + localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, + isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, + isSendBusy: activeLocalDispatch !== null, + backgroundSubmissionPending: activeLocalDispatch?.submissionIntent === "background", + }; +} From a97f7f8e8d872a4ed1c8ecdedbd7e75803f5d276 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 01:36:15 -0700 Subject: [PATCH 002/153] refactor(web): ChatView's persistent terminal drawers live in their own component (#22680) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 82 +++---------- .../components/chat/ChatTerminalDrawers.tsx | 109 ++++++++++++++++++ 2 files changed, 123 insertions(+), 68 deletions(-) create mode 100644 apps/web/src/components/chat/ChatTerminalDrawers.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b5c536bffd8b..aa33ad2517f3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -255,7 +255,6 @@ import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { shortcutLabelForCommand } from "../keybindings"; -import { PersistentThreadTerminalDrawer } from "./PersistentThreadTerminals"; import { AlarmClockIcon, CheckCircle2Icon, @@ -379,7 +378,6 @@ import { useThreadHistory, useQuietThreadShell, useChildThreadInputs, - useThreadRefs, useThreadVisibleTurnItems, useThreadCheckpointSummaries, readThreadShell, @@ -468,7 +466,6 @@ import { } from "./chat/draftHeroTransition"; import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import { - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, @@ -490,7 +487,6 @@ import { deriveLockedProvider, readFileAsDataUrl, resolveFileAttachmentUrl, - reconcileMountedTerminalThreadIds, resolveComposerInteractionMode, resolveComposerProviderSelection, getAntigravitySendBlockReason, @@ -547,6 +543,7 @@ import { } from "@t3tools/shared/composerContextReferences"; import { observeResize } from "~/lib/observeResize"; import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; +import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; import { useDraftHeroLayoutTransition } from "./chat/useDraftHeroLayoutTransition"; import { useLocalDispatchState } from "./chat/useLocalDispatchState"; import { @@ -1033,31 +1030,12 @@ export default function ChatView(props: ChatViewProps) { const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); - const openTerminalThreadKeys = useTerminalUiStateStore( - useShallow((state) => - Object.entries(state.terminalUiStateByThreadKey).flatMap( - ([nextThreadKey, nextTerminalUiState]) => - nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], - ), - ), - ); const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); const storeEnsureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal); const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); - const serverThreadRefs = useThreadRefs(); - const serverThreadKeys = serverThreadRefs.map(scopedThreadKey); - const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftThreadKeys = Object.values(draftThreadsByThreadKey).map((draftThread) => - scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), - ); - const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); - const mountedTerminalThreadRefs = mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; - }); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) @@ -1310,13 +1288,6 @@ export default function ChatView(props: ChatViewProps) { const rightPanelOpen = rightPanelState.isOpen; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); - const activeTerminalDrawerPresence = usePanelPresence( - Boolean(activeThreadKey && terminalUiState.terminalOpen), - true, - panelAnimationsActive, - activeThreadKey, - panelAnimationDurationMs, - ); const rightPanelPresenceValue = { activeSurface: activeRightPanelSurface, surfaces: rightPanelState.surfaces, @@ -1380,25 +1351,6 @@ export default function ChatView(props: ChatViewProps) { } }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]); - const existingOpenTerminalThreadKeys = (() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - })(); - const reconciledMountedTerminalThreadKeys = reconcileMountedTerminalThreadIds({ - currentThreadIds: mountedTerminalThreadKeys, - openThreadIds: existingOpenTerminalThreadKeys, - activeThreadId: activeThreadKey, - activeThreadTerminalOpen: activeTerminalDrawerPresence.present, - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - }); - if ( - reconciledMountedTerminalThreadKeys.length !== mountedTerminalThreadKeys.length || - reconciledMountedTerminalThreadKeys.some( - (threadKey, index) => threadKey !== mountedTerminalThreadKeys[index], - ) - ) { - setMountedTerminalThreadKeys(reconciledMountedTerminalThreadKeys); - } const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime); const activePlan = deriveActivePlanState(serverProjection, activeActivityRun?.runId); const activeActivityRunSettled = isLatestRunSettled(activeActivityRun, activeRuntime); @@ -8438,25 +8390,19 @@ export default function ChatView(props: ChatViewProps) { - {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} + {rightPanelPresent && !shouldUsePlanSidebarSheet && activeThreadRef ? ( diff --git a/apps/web/src/components/chat/ChatTerminalDrawers.tsx b/apps/web/src/components/chat/ChatTerminalDrawers.tsx new file mode 100644 index 000000000000..78b63a210bff --- /dev/null +++ b/apps/web/src/components/chat/ChatTerminalDrawers.tsx @@ -0,0 +1,109 @@ +import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { + parseScopedThreadKey, + scopedThreadKey, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import { useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useComposerDraftStore } from "../../composerDraftStore"; +import type { TerminalContextSelection } from "../../lib/terminalContext"; +import { usePanelAnimationSettings, usePanelPresence } from "../../panelAnimations"; +import { useThreadRefs } from "../../state/entities"; +import { useTerminalUiStateStore } from "../../terminalUiStateStore"; +import { + MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + reconcileMountedTerminalThreadIds, +} from "../ChatView.logic"; +import { + PersistentThreadTerminalDrawer, + type PersistentTerminalLaunchContext, +} from "../PersistentThreadTerminals"; + +interface ChatTerminalDrawersProps { + readonly activeThreadKey: string | null; + readonly activeTerminalOpen: boolean; + readonly composerHeight: number; + readonly launchContext: PersistentTerminalLaunchContext | null; + readonly focusRequestId: number; + readonly splitShortcutLabel: string | undefined; + readonly splitVerticalShortcutLabel: string | undefined; + readonly newShortcutLabel: string | undefined; + readonly closeShortcutLabel: string | undefined; + readonly keybindings: ResolvedKeybindingsConfig; + readonly onAddTerminalContext: (selection: TerminalContextSelection) => void; +} + +/** + * Keeps terminal drawers mounted for recently open threads, so switching back + * to one finds its sessions as they were. Every thread's terminal and draft + * state feeds the reconcile, so it subscribes here instead of in ChatView. + */ +export function ChatTerminalDrawers(props: ChatTerminalDrawersProps) { + const { activeThreadKey } = props; + const openTerminalThreadKeys = useTerminalUiStateStore( + useShallow((state) => + Object.entries(state.terminalUiStateByThreadKey).flatMap( + ([nextThreadKey, nextTerminalUiState]) => + nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], + ), + ), + ); + const serverThreadRefs = useThreadRefs(); + const serverThreadKeys = serverThreadRefs.map(scopedThreadKey); + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const draftThreadKeys = Object.values(draftThreadsByThreadKey).map((draftThread) => + scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), + ); + const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = + usePanelAnimationSettings(); + const activeTerminalDrawerPresence = usePanelPresence( + Boolean(activeThreadKey && props.activeTerminalOpen), + true, + panelAnimationsActive, + activeThreadKey, + panelAnimationDurationMs, + ); + const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); + const existingOpenTerminalThreadKeys = (() => { + const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); + return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); + })(); + const reconciledMountedTerminalThreadKeys = reconcileMountedTerminalThreadIds({ + currentThreadIds: mountedTerminalThreadKeys, + openThreadIds: existingOpenTerminalThreadKeys, + activeThreadId: activeThreadKey, + activeThreadTerminalOpen: activeTerminalDrawerPresence.present, + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + }); + if ( + reconciledMountedTerminalThreadKeys.length !== mountedTerminalThreadKeys.length || + reconciledMountedTerminalThreadKeys.some( + (threadKey, index) => threadKey !== mountedTerminalThreadKeys[index], + ) + ) { + setMountedTerminalThreadKeys(reconciledMountedTerminalThreadKeys); + } + const mountedTerminalThreadRefs = mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { + const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); + return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; + }); + + return mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( + + )); +} From 8da8f613ffdaaed6f3b01bf6d461f34e40b8b859 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 01:36:16 -0700 Subject: [PATCH 003/153] refactor(web): ChatView's right panel lives in its own component (#22700) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 82 ++++--------------- apps/web/src/components/RightPanelTabs.tsx | 2 +- .../src/components/chat/ChatRightPanel.tsx | 51 ++++++++++++ 3 files changed, 66 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/components/chat/ChatRightPanel.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aa33ad2517f3..1082c518c560 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -212,7 +212,6 @@ import { settleAndAdvance } from "../settleAndAdvance"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider"; -import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, type ThreadPanelPresentation, @@ -247,7 +246,6 @@ import { usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; import { threadPullRequestPanelTarget } from "./pullRequest/pullRequestDetail.logic"; -import { RightPanelTabs } from "./RightPanelTabs"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { useDeviceState } from "~/state/device"; import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; @@ -509,7 +507,6 @@ import { retryAttachmentUpload, startAttachmentUpload, } from "../lib/attachmentUploadQueue"; -import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -544,6 +541,7 @@ import { import { observeResize } from "~/lib/observeResize"; import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; +import { ChatRightPanel } from "./chat/ChatRightPanel"; import { useDraftHeroLayoutTransition } from "./chat/useDraftHeroLayoutTransition"; import { useLocalDispatchState } from "./chat/useLocalDispatchState"; import { @@ -1209,10 +1207,6 @@ export default function ChatView(props: ChatViewProps) { activeThreadRef?.environmentId ?? null, ); const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; - const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, { - container: workspaceLayoutElement, - widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`, - }); const activeThreadShell = serverThread; const timelineThreadError = serverRuntime?.status === "failed" && @@ -8405,14 +8399,22 @@ export default function ChatView(props: ChatViewProps) { /> - {rightPanelPresent && !shouldUsePlanSidebarSheet && activeThreadRef ? ( - {panelToggleControls} + ) : null + } surfaces={renderedRightPanelSurfaces} environmentId={activeThreadRef.environmentId} activeSurfaceId={renderedRightPanelSurface?.id ?? null} @@ -8449,63 +8451,7 @@ export default function ChatView(props: ChatViewProps) { deviceAvailable={activeThreadRef !== null} > {rightPanelContent} - - ) : null} - {rightPanelPresent && shouldUsePlanSidebarSheet && activeThreadRef ? ( - - {panelToggleControls} - ) : null - } - surfaces={renderedRightPanelSurfaces} - environmentId={activeThreadRef.environmentId} - activeSurfaceId={renderedRightPanelSurface?.id ?? null} - pendingSurfaceIds={pendingFileSurfaceIds} - previewSessions={activePreviewState.sessions} - desktopByTabId={activePreviewState.desktopByTabId} - previewRuntimeTabId={resolvePreviewRuntimeTabId} - terminalLabelsById={activeTerminalLabelsById} - onActivate={activateRightPanelSurfaceStable} - onCloseSurface={closeRightPanelSurfaceStable} - onRenameDevice={(surfaceId, title) => { - if (activeThreadRef) - useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); - }} - onCloseOtherSurfaces={closeOtherRightPanelSurfacesStable} - onCloseSurfacesToRight={closeRightPanelSurfacesToRightStable} - onCloseAllSurfaces={closeAllRightPanelSurfacesStable} - onMoveSurface={moveRightPanelSurfaceStable} - onCopyFilePath={copyRightPanelFilePathStable} - onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurfaceStable} - onAddTerminal={addTerminalSurfaceStable} - onAddDiff={addDiffSurfaceStable} - onAddFiles={addFilesSurfaceStable} - onAddPullRequest={addPullRequestSurfaceStable} - onAddPullRequests={addPullRequestsSurfaceStable} - onAddDevice={addDeviceSurfaceStable} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} - > - {rightPanelContent} - - + ) : null} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 0ba589444def..4c1059eaeeb4 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -93,7 +93,7 @@ import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { observeResize } from "~/lib/observeResize"; -interface RightPanelTabsProps { +export interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; open?: boolean; diff --git a/apps/web/src/components/chat/ChatRightPanel.tsx b/apps/web/src/components/chat/ChatRightPanel.tsx new file mode 100644 index 000000000000..812532dc6742 --- /dev/null +++ b/apps/web/src/components/chat/ChatRightPanel.tsx @@ -0,0 +1,51 @@ +import { usePreviewPanelInlineSize } from "../../hooks/usePreviewPanelInlineSize"; +import { RightPanelSheet } from "../RightPanelSheet"; +import { RightPanelTabs, type RightPanelTabsProps } from "../RightPanelTabs"; + +type ChatRightPanelProps = Omit & { + readonly sheet: boolean; + readonly maximized: boolean; + readonly workspaceLayoutElement: HTMLElement | null; + readonly widthStorageKey: string; + readonly sheetAnimationDurationMs: number; + readonly onCloseSheet: () => void; +}; + +/** + * The right panel, inline beside the chat column or as a sheet on narrow + * layouts. Its width follows a resize drag every frame, so the width state + * lives here rather than in ChatView. + */ +export function ChatRightPanel(props: ChatRightPanelProps) { + const { + sheet, + workspaceLayoutElement, + widthStorageKey, + sheetAnimationDurationMs, + onCloseSheet, + maximized, + layoutControls, + ...tabs + } = props; + const inlineSize = usePreviewPanelInlineSize(undefined, { + container: workspaceLayoutElement, + widthStorageKey, + }); + if (!sheet) { + return ; + } + return ( + + + + ); +} From 6b13e8f01ce68a2b936f709fccbe803c43b41a05 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 01:36:16 -0700 Subject: [PATCH 004/153] refactor(web): ChatView's timeline follow and anchoring lives in useTimelineFollow (#22701) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 624 +--------------- .../src/components/chat/useTimelineFollow.ts | 665 ++++++++++++++++++ 2 files changed, 702 insertions(+), 587 deletions(-) create mode 100644 apps/web/src/components/chat/useTimelineFollow.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1082c518c560..3956d4b65fad 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -44,11 +44,8 @@ import { useUsageLimitsPanel } from "./chat/useUsageLimitsPanel"; import { useWorktreeSetup } from "./chat/useWorktreeSetup"; import { useChatShortcuts, type ChatShortcutHandler } from "./chat/useChatShortcuts"; import { - eventPathContainsSelector, pasteTextToFocusComposer, shouldRedirectInputToComposer, - TYPE_TO_FOCUS_EDITABLE_SELECTOR, - TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR, } from "./chat/composerFocusRedirect"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Schema from "effect/Schema"; @@ -130,7 +127,6 @@ import { projectScriptTerminalInput, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; -import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { latestUnheldRun, @@ -141,17 +137,8 @@ import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; -import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; -import { - Fragment, - type SetStateAction, - useEffect, - useEffectEvent, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { Fragment, type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; @@ -189,14 +176,6 @@ import { isLatestRunSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { - CHAT_TIMELINE_ANCHOR_OFFSET, - timelineContentOverflowsViewport, - readTimelinePosition, - observeTimelineRun, - type TimelineRunObservation, - type TimelineScrollMode, -} from "./chat/timelineScrollAnchoring"; import { useUiStateStore } from "../uiStateStore"; import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; import { @@ -383,8 +362,6 @@ import { } from "../state/entities"; import { environmentShell, useEnvironmentShellHasSnapshot } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; -import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; -import { createUpwardScrollDetector, isTimelineScrollTarget } from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialogHost, openExpandedImagePreview } from "./chat/ExpandedImageDialog"; import { @@ -401,7 +378,6 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; -import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { overlayComposerIsResting, resolveComposerTimelineInset, @@ -476,7 +452,6 @@ import { isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, - shouldReleaseTimelineAnchorForToolActivity, shouldRenderPreviewMiniPlayer, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -542,6 +517,7 @@ import { observeResize } from "~/lib/observeResize"; import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; import { ChatRightPanel } from "./chat/ChatRightPanel"; +import { useTimelineFollow } from "./chat/useTimelineFollow"; import { useDraftHeroLayoutTransition } from "./chat/useDraftHeroLayoutTransition"; import { useLocalDispatchState } from "./chat/useLocalDispatchState"; import { @@ -557,7 +533,6 @@ import { type LocalThreadErrorEntry, makeModelDisabledReason, makeThreadErrorSetter, - releaseChatTimelineAnchor, } from "./ChatView.logic"; const EMPTY_PROVIDERS: ServerProvider[] = []; @@ -565,7 +540,6 @@ const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = []; const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; -const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray = []; const RELATED_THREAD_SHELL_WAIT_MS = 1_000; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; @@ -912,7 +886,6 @@ export default function ChatView(props: ChatViewProps) { return inserted; }; const citeAssistantTextStable = useStableCallback(citeAssistantText); - const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); // Set by "Work locally": the draft whose restored message should be resent @@ -996,10 +969,6 @@ export default function ChatView(props: ChatViewProps) { const composerRestingRef = useRef(false); const composerMountedRef = useRef(true); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); - const isAtEndRef = useRef(true); - const isTimelineAtLogicalEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current; - const isTimelineAtLogicalEndStable = useStableCallback(isTimelineAtLogicalEnd); const [timelineOverflows, setTimelineOverflows] = useState(false); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); @@ -3673,541 +3642,38 @@ export default function ChatView(props: ChatViewProps) { return result; }; - // 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 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; - } - if (wasProgrammaticScrollMode) { - // While following or anchoring, our scrollToEnd/scrollToOffset calls can - // sit in LegendList's pending-imperative-scroll queue (it defers them - // while layout settles) and fire seconds later with stale targets, - // yanking the view away after the user scrolled. Starting a new - // imperative scroll cancels everything queued; targeting an item that - // is not in the data makes the new request itself resolve without ever - // scrolling, so this is a pure cancel. - void legendListRef.current?.scrollToItem({ - item: TIMELINE_SCROLL_CANCEL_SENTINEL, - animated: false, - }); - // An already-started animated scroll (behavior: smooth) keeps running in - // the browser regardless of the queue; a same-position instant write is - // the only way to halt it where it is. - const scrollNode = legendListRef.current?.getScrollableNode() as - | { scrollTop?: number } - | null - | undefined; - const currentScrollTop = scrollNode?.scrollTop; - if (scrollNode && typeof currentScrollTop === "number") { - scrollNode.scrollTop = currentScrollTop; - } - } - }; - const cancelTimelineLiveFollowForUserNavigationStable = useStableCallback( - cancelTimelineLiveFollowForUserNavigation, - ); - const cancelTimelineLiveFollowForUserNavigationRef = useRef( + const { + showScrollToBottom, + timelineLiveFollowEnabled, + cancelPositionRestoreRef, + isTimelineAtLogicalEndStable, 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, threadStatus]); - const timelineRealContentOverflowsViewport = (list?: LegendListRef | null) => - timelineContentOverflowsViewport((list ?? 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"; - composerRef.current?.collapseForTimelineScrollKey(key); - if ( - (key === "PageUp" && timelineRealContentOverflowsViewportStable()) || - !isTimelineAtLogicalEndStable() - ) { - cancelTimelineLiveFollowForUserNavigationStable(); - } - }); - 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]); - const onComposerPageScrollKeyDown = (key: PageScrollKey) => { - pageScrollControllerRef.current?.handleKeyDown(key); - }; - const onComposerPageScrollKeyDownStable = useStableCallback(onComposerPageScrollKeyDown); - const onComposerPageScrollKeyUp = (key: string) => { - pageScrollControllerRef.current?.handleKeyUp(key); - }; - const onComposerPageScrollKeyUpStable = useStableCallback(onComposerPageScrollKeyUp); - const onComposerPageScrollRelease = () => { - pageScrollControllerRef.current?.releaseActiveKey(); - }; - 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(); - setShowScrollToBottom(false); - setTimelineAnchor(releaseChatTimelineAnchor); - // The anchored end space must be gone before the scroll measures, or the - // list lands short of the real end (#6519). - requestAnimationFrame(() => { - void legendListRef.current?.scrollToEnd?.({ - animated: animated && !window.matchMedia("(prefers-reduced-motion: reduce)").matches, - }); - }); - }; - 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(); - }; - // The gestures below must only break follow when they can actually - // move the viewport away from the live edge (#5566): a spurious break - // while pinned at the end produces no scroll event, never re-arms, - // and streaming silently stops following. Underflowing content can't - // scroll at all, so nothing there should break follow. - 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 (#17056). - 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); - } - removeListeners?.(); - }; - }, [ - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A thread switch remounts the list's scroll node, so the listeners reattach. - activeThread?.id, + onComposerPageScrollKeyDownStable, + onComposerPageScrollKeyUpStable, + onComposerPageScrollReleaseStable, + scrollToEnd, + onTimelineAnchorReadyStable, + onTimelineAnchorSizeChangedStable, + onToolOutputCollapsedAtEndStable, + onIsAtEndChangeStable, + anchorNewTurn, + } = useTimelineFollow({ + legendListRef, composerRef, - isTimelineAtLogicalEndStable, - timelineRealContentOverflowsViewportStable, - ]); - - const onTimelineAnchorReady = (messageId: MessageId, anchorIndex: number) => { - if (pendingTimelineAnchorRef.current === messageId) { - pendingTimelineAnchorRef.current = null; - } - activeTimelineAnchorIndexRef.current = anchorIndex; - if (positionedTimelineAnchorRef.current === messageId) { - return; - } - positionedTimelineAnchorRef.current = messageId; - settledTimelineAnchorRef.current = null; - const positionAnchor = (remainingAttempts: number) => { - requestAnimationFrame(() => { - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const list = legendListRef.current; - if (!list) { - if (remainingAttempts > 0) { - positionAnchor(remainingAttempts - 1); - } - return; - } - // LegendList waits for queued layout and the animation before resolving. - void list - .scrollToIndex({ - index: anchorIndex, - animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, - viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, - }) - .then(() => { - if ( - legendListRef.current === list && - positionedTimelineAnchorRef.current === messageId - ) { - settledTimelineAnchorRef.current = messageId; - } - }); - }); - }; - requestAnimationFrame(() => positionAnchor(12)); - }; - const onTimelineAnchorReadyStable = useStableCallback(onTimelineAnchorReady); - const onTimelineAnchorSizeChanged = (messageId: MessageId) => { - if (settledTimelineAnchorRef.current !== messageId) { - return; - } - if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { - return; - } - const scrollOffset = legendListRef.current?.getState().scroll; - if (scrollOffset === undefined) { - return; - } - if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { - messageId, - offset: scrollOffset, - userScrollGeneration: anchorUserScrollGenerationRef.current, - }; - } - if (anchorScrollRestoreFrameRef.current !== 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 - ) { - const list = legendListRef.current; - const currentScrollOffset = list?.getState().scroll; - if ( - typeof currentScrollOffset === "number" && - Math.abs(currentScrollOffset - pending.offset) <= 2 - ) { - void list?.scrollToOffset({ offset: pending.offset, animated: false }); - } - } - }); - }; - const onTimelineAnchorSizeChangedStable = useStableCallback(onTimelineAnchorSizeChanged); - - const onToolOutputCollapsedAtEnd = () => { - composerRef.current?.restoreAfterTimelineReachedEnd(); - }; - const onToolOutputCollapsedAtEndStable = useStableCallback(onToolOutputCollapsedAtEnd); - - const onIsAtEndChange = (isAtEnd: boolean) => { - if ( - !isAtEnd && - liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current - ) { - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - return; - } - if (isAtEndRef.current === isAtEnd) return; - isAtEndRef.current = isAtEnd; - if (isAtEnd) { - if (timelineScrollIntentRef.current === "toward-end") { - composerRef.current?.restoreAfterTimelineReachedEnd(); - } - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - setTimelineAnchor(releaseChatTimelineAnchor); - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - } else { - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - showScrollDebouncer.current.maybeExecute(); - } - }; - const onIsAtEndChangeStable = useStableCallback(onIsAtEndChange); - - useLayoutEffect(() => { - if (timelineScrollModeRef.current !== "anchoring-new-turn") return; - if ( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId: timelineAnchorMessageId, - liveFollowEnabled: timelineLiveFollowEnabled, - runningTurnId: activeRunningTurnId, - timelineEntries, - }) - ) { - scrollToEndStable(); - } - }, [ + composerOverlayElement, + composerTimelineInset, + routeThreadKey, + activeThreadKey, + activeThreadId: activeThread?.id, + isServerThread, + serverProjection, + threadStatus, + activeActivityRun, activeRunningTurnId, - scrollToEndStable, - timelineAnchorMessageId, timelineEntries, - 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, + timelineAnchorMessageId, + setTimelineAnchor, }); - 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]); useEffect(() => { if (!activeThread?.id || terminalUiState.terminalOpen) return; @@ -6658,18 +6124,10 @@ export default function ChatView(props: ChatViewProps) { }, ); if (!shouldQueueBehindActiveRun) { - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); + anchorNewTurn( + scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), + messageIdForSend, + ); } setOptimisticUserMessages((existing) => [ ...existing, @@ -7066,18 +6524,10 @@ export default function ChatView(props: ChatViewProps) { beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); + anchorNewTurn( + scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), + messageIdForSend, + ); setOptimisticUserMessages((existing) => [ ...existing, diff --git a/apps/web/src/components/chat/useTimelineFollow.ts b/apps/web/src/components/chat/useTimelineFollow.ts new file mode 100644 index 000000000000..f0a96742c3d2 --- /dev/null +++ b/apps/web/src/components/chat/useTimelineFollow.ts @@ -0,0 +1,665 @@ +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 { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; +import { Debouncer } from "@tanstack/react-pacer"; +import { + type SetStateAction, + useEffect, + useEffectEvent, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { type LegendListRef } from "@legendapp/list/react"; +import { + CHAT_TIMELINE_ANCHOR_OFFSET, + timelineContentOverflowsViewport, + 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 { shouldReleaseTimelineAnchorForToolActivity } from "../ChatView.logic"; +import { releaseChatTimelineAnchor } from "../ChatView.logic"; +import type { Dispatch, RefObject } from "react"; +import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell"; +import type { deriveThreadActivityRun } from "@t3tools/client-runtime/state/thread-execution"; +import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; +import type { TimelineEntry } from "../../session-logic"; + +const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); + +interface TimelineAnchor { + readonly threadKey: string | null; + readonly messageId: MessageId | null; +} + +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: EnvironmentThread["projection"] | null; + readonly threadStatus: EnvironmentThreadStatus; + readonly activeActivityRun: ReturnType; + readonly activeRunningTurnId: RunId | null; + readonly timelineEntries: ReadonlyArray; + readonly timelineAnchorMessageId: MessageId | null; + readonly setTimelineAnchor: Dispatch>; +} + +/** + * 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. + */ +export function useTimelineFollow(input: TimelineFollowInput) { + const { + legendListRef, + composerRef, + composerOverlayElement, + composerTimelineInset, + routeThreadKey, + activeThreadKey, + activeThreadId, + isServerThread, + serverProjection, + threadStatus, + activeActivityRun, + activeRunningTurnId, + timelineEntries, + 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 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; + } + if (wasProgrammaticScrollMode) { + // While following or anchoring, our scrollToEnd/scrollToOffset calls can + // sit in LegendList's pending-imperative-scroll queue (it defers them + // while layout settles) and fire seconds later with stale targets, + // yanking the view away after the user scrolled. Starting a new + // imperative scroll cancels everything queued; targeting an item that + // is not in the data makes the new request itself resolve without ever + // scrolling, so this is a pure cancel. + void legendListRef.current?.scrollToItem({ + item: TIMELINE_SCROLL_CANCEL_SENTINEL, + animated: false, + }); + // An already-started animated scroll (behavior: smooth) keeps running in + // the browser regardless of the queue; a same-position instant write is + // the only way to halt it where it is. + const scrollNode = legendListRef.current?.getScrollableNode() as + | { scrollTop?: number } + | null + | undefined; + const currentScrollTop = scrollNode?.scrollTop; + if (scrollNode && typeof currentScrollTop === "number") { + scrollNode.scrollTop = currentScrollTop; + } + } + }; + 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(), { + 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"; + composerRef.current?.collapseForTimelineScrollKey(key); + if ( + (key === "PageUp" && timelineRealContentOverflowsViewportStable()) || + !isTimelineAtLogicalEndStable() + ) { + cancelTimelineLiveFollowForUserNavigationStable(); + } + }); + 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); + }; + const onComposerPageScrollKeyDownStable = useStableCallback(onComposerPageScrollKeyDown); + const onComposerPageScrollKeyUp = (key: string) => { + pageScrollControllerRef.current?.handleKeyUp(key); + }; + const onComposerPageScrollKeyUpStable = useStableCallback(onComposerPageScrollKeyUp); + const onComposerPageScrollRelease = () => { + pageScrollControllerRef.current?.releaseActiveKey(); + }; + 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(); + setShowScrollToBottom(false); + setTimelineAnchor(releaseChatTimelineAnchor); + // The anchored end space must be gone before the scroll measures, or the + // list lands short of the real end (#6519). + requestAnimationFrame(() => { + void legendListRef.current?.scrollToEnd?.({ + animated: animated && !window.matchMedia("(prefers-reduced-motion: reduce)").matches, + }); + }); + }; + 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(); + }; + // The gestures below must only break follow when they can actually + // move the viewport away from the live edge (#5566): a spurious break + // while pinned at the end produces no scroll event, never re-arms, + // and streaming silently stops following. Underflowing content can't + // scroll at all, so nothing there should break follow. + 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 (#17056). + 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); + } + removeListeners?.(); + }; + }, [ + // 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) { + return; + } + positionedTimelineAnchorRef.current = messageId; + settledTimelineAnchorRef.current = null; + const positionAnchor = (remainingAttempts: number) => { + requestAnimationFrame(() => { + if (positionedTimelineAnchorRef.current !== messageId) { + return; + } + const list = legendListRef.current; + if (!list) { + if (remainingAttempts > 0) { + positionAnchor(remainingAttempts - 1); + } + return; + } + // LegendList waits for queued layout and the animation before resolving. + void list + .scrollToIndex({ + index: anchorIndex, + animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, + viewPosition: 0, + viewOffset: CHAT_LIST_ANCHOR_OFFSET, + }) + .then(() => { + if ( + legendListRef.current === list && + positionedTimelineAnchorRef.current === messageId + ) { + settledTimelineAnchorRef.current = messageId; + } + }); + }); + }; + requestAnimationFrame(() => positionAnchor(12)); + }; + const onTimelineAnchorReadyStable = useStableCallback(onTimelineAnchorReady); + const onTimelineAnchorSizeChanged = (messageId: MessageId) => { + if (settledTimelineAnchorRef.current !== messageId) { + return; + } + if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { + return; + } + const scrollOffset = legendListRef.current?.getState().scroll; + if (scrollOffset === undefined) { + return; + } + if (pendingAnchorScrollRestoreRef.current === null) { + pendingAnchorScrollRestoreRef.current = { + messageId, + offset: scrollOffset, + userScrollGeneration: anchorUserScrollGenerationRef.current, + }; + } + if (anchorScrollRestoreFrameRef.current !== 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 + ) { + const list = legendListRef.current; + const currentScrollOffset = list?.getState().scroll; + if ( + typeof currentScrollOffset === "number" && + Math.abs(currentScrollOffset - pending.offset) <= 2 + ) { + void list?.scrollToOffset({ offset: pending.offset, animated: false }); + } + } + }); + }; + const onTimelineAnchorSizeChangedStable = useStableCallback(onTimelineAnchorSizeChanged); + + const onToolOutputCollapsedAtEnd = () => { + composerRef.current?.restoreAfterTimelineReachedEnd(); + }; + const onToolOutputCollapsedAtEndStable = useStableCallback(onToolOutputCollapsedAtEnd); + + const onIsAtEndChange = (isAtEnd: boolean) => { + if ( + !isAtEnd && + liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current + ) { + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + return; + } + if (isAtEndRef.current === isAtEnd) return; + isAtEndRef.current = isAtEnd; + if (isAtEnd) { + if (timelineScrollIntentRef.current === "toward-end") { + composerRef.current?.restoreAfterTimelineReachedEnd(); + } + timelineScrollModeRef.current = "following-end"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); + setTimelineAnchor(releaseChatTimelineAnchor); + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + } else { + timelineScrollModeRef.current = "free-scrolling"; + liveFollowUserScrollGenerationRef.current = null; + showScrollDebouncer.current.maybeExecute(); + } + }; + const onIsAtEndChangeStable = useStableCallback(onIsAtEndChange); + + useLayoutEffect(() => { + if (timelineScrollModeRef.current !== "anchoring-new-turn") return; + if ( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId: timelineAnchorMessageId, + liveFollowEnabled: timelineLiveFollowEnabled, + runningTurnId: activeRunningTurnId, + timelineEntries, + }) + ) { + scrollToEndStable(); + } + }, [ + activeRunningTurnId, + scrollToEndStable, + timelineAnchorMessageId, + timelineEntries, + 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(); + setShowScrollToBottom(false); + setTimelineAnchor({ threadKey, messageId }); + }; + + return { + showScrollToBottom, + timelineLiveFollowEnabled, + cancelPositionRestoreRef, + isTimelineAtLogicalEndStable, + cancelTimelineLiveFollowForUserNavigationStable, + onComposerPageScrollKeyDownStable, + onComposerPageScrollKeyUpStable, + onComposerPageScrollReleaseStable, + scrollToEnd, + onTimelineAnchorReadyStable, + onTimelineAnchorSizeChangedStable, + onToolOutputCollapsedAtEndStable, + onIsAtEndChangeStable, + anchorNewTurn, + }; +} From 030be16739858801ba4ebd125a1da63aababcca7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 01:36:17 -0700 Subject: [PATCH 005/153] refactor(web): ChatView's machine and server-update banners live in useSystemComposerBanners (#22710) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 292 +--------------- .../chat/useSystemComposerBanners.tsx | 323 ++++++++++++++++++ 2 files changed, 338 insertions(+), 277 deletions(-) create mode 100644 apps/web/src/components/chat/useSystemComposerBanners.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3956d4b65fad..c51aa7e33565 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -75,7 +75,6 @@ import { resolveProjectProviderInstanceEnabled, RuntimeMode, } from "@t3tools/contracts"; -import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { readPastedComposerContext } from "./composerInlineTokenPaste"; @@ -242,7 +241,6 @@ import { GitBranchIcon, ShieldQuestionIcon, TargetIcon, - WifiOffIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; @@ -318,10 +316,6 @@ import { terminalContextReference, } from "../lib/composerContextRecords"; import { type ReviewCommentContext } from "../reviewCommentContext"; -import { environmentCatalog } from "../connection/catalog"; -import { ConnectionDiagnosisHeadline } from "./settings/ConnectionDiagnosisDetails"; -import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; -import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; @@ -447,8 +441,6 @@ import { deriveCommittedServerUserMessageIds, deriveComposerSendState, dismissBranchMismatchForSession, - hasEnvironmentReconnectWarningGraceElapsed, - scheduleEnvironmentReconnectWarning, isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, @@ -493,21 +485,6 @@ import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { Button, InlineButton } from "./ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { ServerUpdateAction, serverUpdateStageLabel } from "./ServerUpdateAction"; -import { useAutoBalanceUpdateBanner } from "./chat/useAutoBalanceUpdateBanner"; -import { ComposerServerUpdateIcon } from "./chat/ComposerServerUpdateStatus"; -import { - buildVersionMismatchDismissalKey, - dismissServerUpdateFailure, - dismissVersionMismatch, - isServerUpdateFailureDismissed, - isVersionMismatchDismissed, - resolveServerConfigVersionMismatch, - resolveServerSelfUpdateCapability, - serverUpdateGuidance, - supportsDesktopAppUpdate, - supportsServerUpdateThreadContinuation, -} from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, @@ -518,6 +495,10 @@ import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; import { ChatRightPanel } from "./chat/ChatRightPanel"; import { useTimelineFollow } from "./chat/useTimelineFollow"; +import { + type EnvironmentUnavailableState, + useSystemComposerBanners, +} from "./chat/useSystemComposerBanners"; import { useDraftHeroLayoutTransition } from "./chat/useDraftHeroLayoutTransition"; import { useLocalDispatchState } from "./chat/useLocalDispatchState"; import { @@ -546,11 +527,6 @@ const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_AUTO_UPDATE_ENVIRONMENTS: ReadonlyArray = []; const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); -type EnvironmentUnavailableState = { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly connection: EnvironmentConnectionPresentation; -}; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -656,10 +632,6 @@ export default function ChatView(props: ChatViewProps) { const { environments } = useEnvironments(); const providersByEnvironment = useEnvironmentProviders(); const primaryEnvironment = usePrimaryEnvironment(); - const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); - const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { - reportFailure: false, - }); const environmentById = new Map( environments.map((environment) => [environment.environmentId, environment]), ); @@ -1517,35 +1489,8 @@ export default function ChatView(props: ChatViewProps) { }, [activeProjectRef, activeThreadRef]); const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); - const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = - activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; - const activeReconnectingEnvironmentId = - activeEnvironmentConnectionPhase === "connecting" || - 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( - activeReconnectingEnvironmentId, - reconnectWarningGraceElapsedEnvironmentId, - ); - useEffect(() => { - if (activeReconnectingEnvironmentId === null) return; - return scheduleEnvironmentReconnectWarning(() => - setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), - ); - }, [activeReconnectingEnvironmentId]); + activeEnvironment !== null && activeEnvironment.connection.phase !== "connected"; const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = (() => { if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { @@ -1558,53 +1503,6 @@ export default function ChatView(props: ChatViewProps) { connection: activeEnvironment.connection, }; })() as EnvironmentUnavailableState | null; - const handleReconnectActiveEnvironment = async (environmentId: EnvironmentId) => { - const result = await retryEnvironment(environmentId); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not reconnect environment", - description: error instanceof Error ? error.message : "Failed to reconnect.", - }), - ); - } - }; - const handleReconnectActiveEnvironmentStable = useStableCallback( - handleReconnectActiveEnvironment, - ); - const disconnectDelayElapsed = useEnvironmentDisconnectDelay( - activeEnvironmentUnavailable ? activeEnvironment.environmentId : null, - ); - const canDisconnectActiveEnvironment = - disconnectDelayElapsed && - activeEnvironment !== null && - activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" && - !isDesktopLocalConnectionTarget(activeEnvironment.entry.target); - const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false); - const handleDisconnectActiveEnvironment = async (environmentId: EnvironmentId) => { - setDisconnectingEnvironment(true); - const result = await setEnvironmentEnabled({ environmentId, enabled: false }); - setDisconnectingEnvironment(false); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not disconnect server", - description: error instanceof Error ? error.message : "Failed to disconnect.", - }), - ); - } - return; - } - void navigate({ to: "/", replace: true }); - }; - const handleDisconnectActiveEnvironmentStable = useStableCallback( - handleDisconnectActiveEnvironment, - ); const { openScratchProject } = useScratchProject(); const activeProjectIsScratch = activeProject !== null && @@ -1774,176 +1672,16 @@ export default function ChatView(props: ChatViewProps) { return environment ? [environment] : []; }) : EMPTY_AUTO_UPDATE_ENVIRONMENTS; - const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments); - const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); - const versionMismatchDismissKey = - versionMismatch && activeThread - ? buildVersionMismatchDismissalKey(activeThread.environmentId, versionMismatch) - : null; - const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState( - null, - ); - const versionMismatchDismissed = - versionMismatchDismissKey === dismissedVersionMismatchKey || - isVersionMismatchDismissed(versionMismatchDismissKey); - const showVersionMismatchBanner = - versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; - const hasMultipleRegisteredEnvironments = environments.length > 1; - const versionMismatchServerLabel = - hasMultipleRegisteredEnvironments && activeThread - ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` - : "server"; - const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; - const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); - const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation; - const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); - const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig); - const serverUpdateState = useAtomValue( - serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), - ); - const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState< - typeof serverUpdateState | null - >(null); - const serverUpdateFailureDismissed = - serverUpdateState === dismissedServerUpdateState || - isServerUpdateFailureDismissed(serverUpdateState); - const systemComposerBannerItems = (() => { - const items: ComposerBannerStackItem[] = []; - const updateRunning = serverUpdateState.status === "running"; - const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; - const disconnectAction = - canDisconnectActiveEnvironment && activeEnvironmentUnavailableState ? ( - - ) : undefined; - const environmentReconnecting = - unavailableConnection !== null && - (unavailableConnection.phase === "connecting" || - unavailableConnection.phase === "reconnecting"); - const suppressUnavailableBanner = - environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed); - if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { - items.push({ - id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: unavailableConnection.phase === "error" ? "error" : "warning", - icon: , - title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, - description: activeEnvironment ? ( - - ) : undefined, - actions: ( - <> - {!environmentReconnecting ? ( - - ) : null} - {disconnectAction} - - ), - }); - } - if ( - !automaticEnvironment && - serverUpdateEnvironmentId && - (serverUpdateState.status === "idle" - ? showVersionMismatchBanner - : !serverUpdateFailureDismissed) - ) { - const updateInProgress = serverUpdateState.status === "running"; - const updateFailed = serverUpdateState.status === "failed"; - items.push({ - id: `server-version:${serverUpdateEnvironmentId}`, - variant: updateFailed ? "error" : "default", - priority: updateInProgress ? "urgent" : "notice", - icon: , - title: - updateInProgress || updateFailed ? ( - `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}` - ) : versionMismatch ? ( - - - Server update available - - } - /> - - {versionMismatchServerLabel} {versionMismatch.serverVersion}{" "} - {versionMismatch.clientVersion} - - - ) : ( - "Server update available" - ), - description: - serverUpdateState.status === "failed" - ? serverUpdateState.message - : serverUpdateState.status === "running" - ? serverUpdateStageLabel(serverUpdateState.stage) - : versionMismatchSelfUpdate !== null && - (versionMismatchSelfUpdate !== "desktop-managed" || - !versionMismatchDesktopAppUpdate) - ? serverUpdateGuidance(versionMismatchSelfUpdate) - : undefined, - actions: updateInProgress ? ( - disconnectAction - ) : !versionMismatch || - (versionMismatchSelfUpdate === "desktop-managed" && - !versionMismatchDesktopAppUpdate) ? undefined : ( - - ), - ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) - ? {} - : { - dismissLabel: "Dismiss update notice", - onDismiss: () => { - if (updateFailed) { - dismissServerUpdateFailure(serverUpdateState); - setDismissedServerUpdateState(serverUpdateState); - } - dismissVersionMismatch(versionMismatchDismissKey); - setDismissedVersionMismatchKey(versionMismatchDismissKey); - }, - }), - }); - } - if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner); - return items; - })() as ComposerBannerStackItem[]; + const systemComposerBannerItems = useSystemComposerBanners({ + activeEnvironment, + activeEnvironmentUnavailable, + activeEnvironmentUnavailableState, + activeThreadEnvironmentId: activeThread?.environmentId ?? null, + serverConfig, + hasMultipleRegisteredEnvironments: environments.length > 1, + automaticEnvironment, + autoUpdateEnvironments, + }); const providerInstanceEntries = sortProviderInstanceEntries( applyProviderInstanceSettings( deriveProviderInstanceEntries(providerStatuses), diff --git a/apps/web/src/components/chat/useSystemComposerBanners.tsx b/apps/web/src/components/chat/useSystemComposerBanners.tsx new file mode 100644 index 000000000000..e6fae8fd4d01 --- /dev/null +++ b/apps/web/src/components/chat/useSystemComposerBanners.tsx @@ -0,0 +1,323 @@ +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 { useNavigate } from "@tanstack/react-router"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { WifiOffIcon } from "lucide-react"; +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 { serverEnvironment } from "../../state/server"; +import { type EnvironmentPresentation } from "../../state/environments"; +import type { ComposerBannerStackItem } from "./ComposerBannerStack"; +import { + hasEnvironmentReconnectWarningGraceElapsed, + scheduleEnvironmentReconnectWarning, +} from "../ChatView.logic"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ServerUpdateAction, serverUpdateStageLabel } from "../ServerUpdateAction"; +import { useAutoBalanceUpdateBanner } from "./useAutoBalanceUpdateBanner"; +import { ComposerServerUpdateIcon } from "./ComposerServerUpdateStatus"; +import { + buildVersionMismatchDismissalKey, + dismissServerUpdateFailure, + dismissVersionMismatch, + isServerUpdateFailureDismissed, + isVersionMismatchDismissed, + resolveServerConfigVersionMismatch, + resolveServerSelfUpdateCapability, + serverUpdateGuidance, + supportsDesktopAppUpdate, + supportsServerUpdateThreadContinuation, +} from "../../versionSkew"; + +export type EnvironmentUnavailableState = { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly connection: EnvironmentConnectionPresentation; +}; + +interface SystemComposerBannersInput { + readonly activeEnvironment: EnvironmentPresentation | null; + readonly activeEnvironmentUnavailable: boolean; + readonly activeEnvironmentUnavailableState: EnvironmentUnavailableState | null; + readonly activeThreadEnvironmentId: EnvironmentId | null; + readonly serverConfig: EnvironmentPresentation["serverConfig"] | null; + readonly hasMultipleRegisteredEnvironments: boolean; + readonly automaticEnvironment: boolean; + readonly autoUpdateEnvironments: ReadonlyArray; +} + +/** + * The composer banners about the machine itself: offline or reconnecting, + * a server update, and auto-balanced machines that need one. + */ +export function useSystemComposerBanners(input: SystemComposerBannersInput) { + const { + activeEnvironment, + activeEnvironmentUnavailable, + activeEnvironmentUnavailableState, + activeThreadEnvironmentId, + serverConfig, + hasMultipleRegisteredEnvironments, + automaticEnvironment, + autoUpdateEnvironments, + } = input; + const navigate = useNavigate(); + const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); + const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { + reportFailure: false, + }); + const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; + const activeReconnectingEnvironmentId = + activeEnvironmentConnectionPhase === "connecting" || + 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( + activeReconnectingEnvironmentId, + reconnectWarningGraceElapsedEnvironmentId, + ); + 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)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not reconnect environment", + description: error instanceof Error ? error.message : "Failed to reconnect.", + }), + ); + } + }; + const handleReconnectActiveEnvironmentStable = useStableCallback( + handleReconnectActiveEnvironment, + ); + const disconnectDelayElapsed = useEnvironmentDisconnectDelay( + activeEnvironmentUnavailable && activeEnvironment ? activeEnvironment.environmentId : null, + ); + const canDisconnectActiveEnvironment = + disconnectDelayElapsed && + activeEnvironment !== null && + activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" && + !isDesktopLocalConnectionTarget(activeEnvironment.entry.target); + const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false); + const handleDisconnectActiveEnvironment = async (environmentId: EnvironmentId) => { + setDisconnectingEnvironment(true); + const result = await setEnvironmentEnabled({ environmentId, enabled: false }); + setDisconnectingEnvironment(false); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not disconnect server", + description: error instanceof Error ? error.message : "Failed to disconnect.", + }), + ); + } + return; + } + void navigate({ to: "/", replace: true }); + }; + const handleDisconnectActiveEnvironmentStable = useStableCallback( + handleDisconnectActiveEnvironment, + ); + const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments); + const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); + const versionMismatchDismissKey = + versionMismatch && activeThreadEnvironmentId !== null + ? buildVersionMismatchDismissalKey(activeThreadEnvironmentId, versionMismatch) + : null; + const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState( + null, + ); + const versionMismatchDismissed = + versionMismatchDismissKey === dismissedVersionMismatchKey || + isVersionMismatchDismissed(versionMismatchDismissKey); + const showVersionMismatchBanner = + versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; + const versionMismatchServerLabel = + hasMultipleRegisteredEnvironments && activeThreadEnvironmentId !== null + ? `${activeEnvironment?.label ?? serverConfig?.environment.label ?? activeThreadEnvironmentId} server` + : "server"; + const serverUpdateEnvironmentId = activeThreadEnvironmentId; + const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); + const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation; + const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); + const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig); + const serverUpdateState = useAtomValue( + serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), + ); + const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState< + typeof serverUpdateState | null + >(null); + const serverUpdateFailureDismissed = + serverUpdateState === dismissedServerUpdateState || + isServerUpdateFailureDismissed(serverUpdateState); + return (() => { + const items: ComposerBannerStackItem[] = []; + const updateRunning = serverUpdateState.status === "running"; + const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; + const disconnectAction = + canDisconnectActiveEnvironment && activeEnvironmentUnavailableState ? ( + + ) : undefined; + const environmentReconnecting = + unavailableConnection !== null && + (unavailableConnection.phase === "connecting" || + unavailableConnection.phase === "reconnecting"); + const suppressUnavailableBanner = + environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed); + if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { + items.push({ + id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, + variant: unavailableConnection.phase === "error" ? "error" : "warning", + icon: , + title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, + description: activeEnvironment ? ( + + ) : undefined, + actions: ( + <> + {!environmentReconnecting ? ( + + ) : null} + {disconnectAction} + + ), + }); + } + if ( + !automaticEnvironment && + serverUpdateEnvironmentId && + (serverUpdateState.status === "idle" + ? showVersionMismatchBanner + : !serverUpdateFailureDismissed) + ) { + const updateInProgress = serverUpdateState.status === "running"; + const updateFailed = serverUpdateState.status === "failed"; + items.push({ + id: `server-version:${serverUpdateEnvironmentId}`, + variant: updateFailed ? "error" : "default", + priority: updateInProgress ? "urgent" : "notice", + icon: , + title: + updateInProgress || updateFailed ? ( + `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}` + ) : versionMismatch ? ( + + + Server update available + + } + /> + + {versionMismatchServerLabel} {versionMismatch.serverVersion}{" "} + {versionMismatch.clientVersion} + + + ) : ( + "Server update available" + ), + description: + serverUpdateState.status === "failed" + ? serverUpdateState.message + : serverUpdateState.status === "running" + ? serverUpdateStageLabel(serverUpdateState.stage) + : versionMismatchSelfUpdate !== null && + (versionMismatchSelfUpdate !== "desktop-managed" || + !versionMismatchDesktopAppUpdate) + ? serverUpdateGuidance(versionMismatchSelfUpdate) + : undefined, + actions: updateInProgress ? ( + disconnectAction + ) : !versionMismatch || + (versionMismatchSelfUpdate === "desktop-managed" && + !versionMismatchDesktopAppUpdate) ? undefined : ( + + ), + ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) + ? {} + : { + dismissLabel: "Dismiss update notice", + onDismiss: () => { + if (updateFailed) { + dismissServerUpdateFailure(serverUpdateState); + setDismissedServerUpdateState(serverUpdateState); + } + dismissVersionMismatch(versionMismatchDismissKey); + setDismissedVersionMismatchKey(versionMismatchDismissKey); + }, + }), + }); + } + if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner); + return items; + })() as ComposerBannerStackItem[]; +} From bd7aab2c95ef13fe93956cea8d88ca1cfbaf9172 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 01:36:17 -0700 Subject: [PATCH 006/153] refactor(web): ChatView's subagent, goal, background-work and branch banners are built in ThreadActivityBanners (#22725) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 220 ++++-------------- .../components/chat/ThreadActivityBanners.tsx | 201 ++++++++++++++++ 2 files changed, 245 insertions(+), 176 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadActivityBanners.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c51aa7e33565..5148c226992f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -82,7 +82,6 @@ import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { declinePendingApprovals } from "@t3tools/client-runtime/state/thread-requests"; import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads"; import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; import { deriveProviderSubagentStatus, @@ -92,8 +91,6 @@ import { deriveThreadActivityRun, deriveLatestThreadRun, deriveThreadRuntime, - presentPendingBackgroundWork, - presentProviderGoal, } from "@t3tools/client-runtime/state/thread-execution"; import { resolveWorkflowOpening, @@ -137,7 +134,7 @@ import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { useAtomValue } from "@effect/atom-react"; -import { Fragment, type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; @@ -234,13 +231,9 @@ import { shortcutLabelForCommand } from "../keybindings"; import { AlarmClockIcon, CheckCircle2Icon, - MessageCircleIcon, PaperclipIcon, ChevronDownIcon, DownloadIcon, - GitBranchIcon, - ShieldQuestionIcon, - TargetIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; @@ -483,8 +476,7 @@ 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 { Button, InlineButton } from "./ui/button"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { Button } from "./ui/button"; import { useAssetUrls } from "../assets/assetUrls"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, @@ -494,6 +486,12 @@ import { observeResize } from "~/lib/observeResize"; import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; import { ChatRightPanel } from "./chat/ChatRightPanel"; +import { + backgroundWorkBannerItem, + branchMismatchBannerItem, + childInputBannerItem, + goalBannerItem, +} from "./chat/ThreadActivityBanners"; import { useTimelineFollow } from "./chat/useTimelineFollow"; import { type EnvironmentUnavailableState, @@ -4019,35 +4017,13 @@ export default function ChatView(props: ChatViewProps) { }; const onOpenRelatedThreadStable = useStableCallback(onOpenRelatedThread); const childThreadInputs = useChildThreadInputs(activeThreadRef); - const childInputBannerItem = (() => { - const first = childThreadInputs[0]; - const parentAwaitingUser = - activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput; - if (!first || parentAwaitingUser) return null; - return { - id: `child-input:${first.id}`, - variant: "info", - priority: "activity", - icon: first.hasPendingApprovals ? : , - title: childThreadInputsTitle(childThreadInputs), - description: childThreadInputs.map((child, index) => ( - - {index > 0 ? " " : null} - - onOpenRelatedThreadStable(child.id)}> - {child.title} - - {index < childThreadInputs.length - 1 ? "," : null} - - - )), - actions: ( - - ), - }; - })() as ComposerBannerStackItem | null; + const childInputBanner = childInputBannerItem({ + childThreadInputs, + parentAwaitingUser: Boolean( + activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput, + ), + onOpenThread: onOpenRelatedThreadStable, + }); const sendStandaloneCommand = async (text: string, failureMessage: string) => { if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return; @@ -4111,104 +4087,26 @@ export default function ChatView(props: ChatViewProps) { }; const sendStandaloneCommandStable = useStableCallback(sendStandaloneCommand); const activeGoal = activeThreadShell?.goal ?? null; - const goalBannerItem = (() => { - if (!activeThread || activeGoal === null) return null; - const presentation = presentProviderGoal(activeGoal, isWorking); - return { - id: `goal:${activeThread.id}`, - variant: "default", - priority: "activity", - icon: , - title: - presentation.usage === null - ? presentation.title - : `${presentation.title} · ${presentation.usage}`, - description: presentation.objective, - actions: isWorking ? undefined : ( - <> - {presentation.canResume ? ( - - ) : null} - - - ), - }; - })() as ComposerBannerStackItem | null; + const goalBanner = + activeThread && activeGoal !== null + ? goalBannerItem({ + threadId: activeThread.id, + goal: activeGoal, + isWorking, + onSendCommand: sendStandaloneCommandStable, + }) + : null; - const backgroundWorkBannerItem = (() => { - const presentation = presentPendingBackgroundWork(activeBackgroundTasks); - if (presentation === null || !activeThread) { - return null; - } - return { - id: `background-work:${activeThread.id}`, - variant: "default", - priority: "activity", - icon: ( - - } - description={ - isLoadingArchive - ? "Checking connected environments." - : (archiveError ?? "Archived threads will appear here.") - } - /> - - ) : ( - archivedGroups.map(({ key, project, title, threads: projectThreads }, index) => ( - } - > - {projectThreads.map((thread) => ( - { - event.preventDefault(); - void (async () => { - const result = await settlePromise(() => - handleArchivedThreadContextMenu( - scopeThreadRef(thread.environmentId, thread.id), - { - x: event.clientX, - y: event.clientY, - }, - ), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Archived thread action failed", - description: - error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }} - title={thread.title} - description={ - - {hasMultipleEnvironments ? `${thread.environmentLabel} · ` : null} - Archived - {" \u00b7 Created "} - - - } - control={ - - } - /> - ))} - - )) - )} - - ); -} diff --git a/apps/web/src/routes/settings.archived.tsx b/apps/web/src/routes/settings.archived.tsx index 3ad690afc027..b411f6440af7 100644 --- a/apps/web/src/routes/settings.archived.tsx +++ b/apps/web/src/routes/settings.archived.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ArchivedThreadsPanel } from "../components/settings/SettingsPanels"; +import { ArchivedThreadsPanel } from "../components/settings/ArchivedThreadsPanel"; export const Route = createFileRoute("/settings/archived")({ component: ArchivedThreadsPanel, From 568cbb42e389f4532693457c1956904e078413e1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 01:39:27 -0700 Subject: [PATCH 010/153] refactor(web): the typography settings section lives in its own file (#22667) Co-authored-by: Claude Opus 5.5 --- .../components/settings/SettingsPanels.tsx | 509 +---------------- .../settings/TypographySettings.tsx | 522 ++++++++++++++++++ 2 files changed, 525 insertions(+), 506 deletions(-) create mode 100644 apps/web/src/components/settings/TypographySettings.tsx diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f3defe2113cd..4d4cccab93d8 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -4,8 +4,8 @@ import { NotificationSettings } from "./NotificationSettings"; import { PRIVACY_POLICY_URL } from "../../legalLinks"; import { CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; -import type { CSSProperties, ReactNode } from "react"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import type { CSSProperties } from "react"; +import { useEffect, useRef, useState } from "react"; import { type BackgroundActivityProfile, type DesktopUpdateChannel, @@ -20,22 +20,14 @@ import { type DiffLayout, type EnvironmentIdentificationMode, MAX_APPEARANCE_CONTRAST, - MAX_CODE_FONT_SIZE, MAX_GLASS_OPACITY, - MAX_INTERFACE_FONT_SIZE, MAX_PANEL_ANIMATION_DURATION_MS, - MAX_PROMPT_FONT_SIZE, MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - MAX_TERMINAL_FONT_SIZE, - MIN_CODE_FONT_SIZE, MIN_APPEARANCE_CONTRAST, MIN_GLASS_OPACITY, - MIN_INTERFACE_FONT_SIZE, MIN_PANEL_ANIMATION_DURATION_MS, - MIN_PROMPT_FONT_SIZE, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, type ResponseStreamingMode, - MIN_TERMINAL_FONT_SIZE, type QuitConfirmationMode, SidebarProjectSortOrder, } from "@t3tools/contracts/settings"; @@ -70,7 +62,6 @@ import { readThemePreference, useTheme, } from "../../hooks/useTheme"; -import { useLocalStorage } from "../../hooks/useLocalStorage"; import { useScopedSettings, useScopedSettingsMixed, @@ -107,18 +98,6 @@ import { } from "../ui/dialog"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; -import { - DEFAULT_CODE_FONT_STACK, - DEFAULT_SANS_FONT_STACK, - isFontFamilyAvailable, - isMonospaceFamily, - resolveDefaultFamilyLabel, - resolveTerminalFontPreference, - resolveTerminalFontSizePreference, - TYPOGRAPHY_ADVANCED_STORAGE_KEY, -} from "../../appearanceFonts"; -import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; -import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; import { NumberField, NumberFieldDecrement, @@ -159,6 +138,7 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; +import { TypographySection } from "./TypographySettings"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", @@ -1450,489 +1430,6 @@ export function AppearanceSettingsPanel() { ); } -function useFontDefaultFamilies() { - const settings = useScopedSettings(); - const [defaults] = useState(() => ({ - sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default", - code: resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace", - })); - return { - sans: defaults.sans, - code: defaults.code, - interfaceFamily: settings.fontFamilySans.trim() || defaults.sans, - }; -} - -function InterfaceFontRow({ preview }: { preview?: ReactNode }) { - const settings = useScopedSettings(); - const updateSettings = useUpdateScopedSettings(); - const defaults = useFontDefaultFamilies(); - return ( - updateSettings({ fontFamilySans })} - onReset={() => - updateSettings({ - fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, - fontSizeInterface: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface, - }) - } - size={{ - label: "Interface font size", - min: MIN_INTERFACE_FONT_SIZE, - max: MAX_INTERFACE_FONT_SIZE, - value: settings.fontSizeInterface, - defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface, - onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }), - }} - {...(preview !== undefined ? { preview } : {})} - /> - ); -} - -function PromptFontRow() { - const settings = useScopedSettings(); - const updateSettings = useUpdateScopedSettings(); - const defaults = useFontDefaultFamilies(); - return ( - updateSettings({ fontFamilyComposer })} - onReset={() => - updateSettings({ - fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, - fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt, - }) - } - size={{ - label: "Prompt font size", - min: MIN_PROMPT_FONT_SIZE, - max: MAX_PROMPT_FONT_SIZE, - value: settings.fontSizePrompt, - defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt, - onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }), - }} - preview={} - /> - ); -} - -function CodeFontRow({ - title, - description = "Code blocks, diffs, and file previews.", - preview, -}: { - title?: string; - description?: string; - preview?: ReactNode; -}) { - const settings = useScopedSettings(); - const updateSettings = useUpdateScopedSettings(); - const defaults = useFontDefaultFamilies(); - return ( - updateSettings({ fontFamilyCode })} - onReset={() => - updateSettings({ - fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, - fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode, - }) - } - requireMonospace - size={{ - label: "Code font size", - min: MIN_CODE_FONT_SIZE, - max: MAX_CODE_FONT_SIZE, - value: settings.fontSizeCode, - defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeCode, - onChange: (fontSizeCode) => updateSettings({ fontSizeCode }), - }} - preview={preview ?? } - /> - ); -} - -function TerminalFontRow() { - const settings = useScopedSettings(); - const updateSettings = useUpdateScopedSettings(); - const defaults = useFontDefaultFamilies(); - return ( - updateSettings({ fontFamilyTerminal })} - onReset={() => - updateSettings({ - fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, - fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal, - }) - } - requireMonospace - size={{ - label: "Terminal font size", - min: MIN_TERMINAL_FONT_SIZE, - max: MAX_TERMINAL_FONT_SIZE, - value: settings.fontSizeTerminal, - defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal, - onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }), - }} - preview={ - - } - /> - ); -} - -function FontSmoothingRow() { - const settings = useScopedSettings(); - const updateSettings = useUpdateScopedSettings(); - if (!isMacPlatform(navigator.platform)) return null; - return ( - - updateSettings({ fontSmoothing: DEFAULT_UNIFIED_SETTINGS.fontSmoothing }) - } - /> - ) : null - } - control={ - updateSettings({ fontSmoothing: Boolean(checked) })} - aria-label="Font smoothing" - /> - } - /> - ); -} - -function WordWrapRow() { - const settings = useScopedSettings(); - const updateSettings = useUpdateScopedSettings(); - return ( - updateSettings({ wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap })} - /> - ) : null - } - control={ - updateSettings({ wordWrap: Boolean(checked) })} - aria-label="Wrap code, tables, diffs, and file previews by default" - /> - } - /> - ); -} - -function FontSettingsGroup() { - return ( - <> - - - - - - - ); -} - -function SimpleFontRows() { - const settings = useScopedSettings(); - return ( - <> - } /> - - - - - } - /> - - ); -} - -// Font smoothing only renders on macOS, so a search jump to it elsewhere -// must not flip the section - the target would never mount to be scrolled to. -const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet = new Set([ - "prompt-font", - "terminal-font", - ...(typeof navigator !== "undefined" && isMacPlatform(navigator.platform) - ? ["font-smoothing"] - : []), -]); - -function TypographySection() { - const [advanced, setAdvanced] = useLocalStorage( - TYPOGRAPHY_ADVANCED_STORAGE_KEY, - false, - Schema.Boolean, - ); - const searchTargetId = useSettingsSearchTargetId(); - const lastExpandedTargetRef = useRef(null); - useEffect(() => { - if (searchTargetId === null || !ADVANCED_TYPOGRAPHY_TARGET_IDS.has(searchTargetId)) return; - if (lastExpandedTargetRef.current === searchTargetId) return; - lastExpandedTargetRef.current = searchTargetId; - setAdvanced(true); - }, [searchTargetId, setAdvanced]); - return ( - - Advanced - setAdvanced(Boolean(checked))} - aria-label="Show advanced typography settings" - /> - - } - > - {advanced ? : } - - - ); -} - -function FontFamilySettingsRow({ - id, - title, - description, - defaultFamily, - defaultValue, - preview, - value, - onValueChange, - onReset, - requireMonospace = false, - size, -}: { - id?: string; - title: string; - description: string; - defaultFamily: string; - defaultValue: string; - preview?: ReactNode; - value: string; - onValueChange: (value: string) => void; - onReset: () => void; - requireMonospace?: boolean; - size: { - label: string; - min: number; - max: number; - value: number; - defaultValue: number; - onChange: (v: number) => void; - }; -}) { - const trimmed = value.trim(); - const [draft, setDraft] = useState(value); - const [draftSettled, setDraftSettled] = useState(true); - // Each pending commit remembers the value it was typed against, so one that outlives - // an external change (hydration, reset, picker selection) drops its stale draft. - const commitTimerRef = useRef<{ readonly id: number; readonly value: string } | null>(null); - const cancelPendingCommit = () => { - if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current.id); - commitTimerRef.current = null; - }; - // Bumped on every external value change, so a pending commit typed before one never lands, - // even if the value has since changed back to what it was typed against. - const [adopted, setAdopted] = useState({ value, generation: 0 }); - if (adopted.value !== value) { - setAdopted({ value, generation: adopted.generation + 1 }); - setDraft(value); - setDraftSettled(true); - } - const readAdoptedGeneration = useEffectEvent(() => adopted.generation); - useEffect( - () => () => { - if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current.id); - }, - [], - ); - const acceptsFamily = (candidate: string) => - isFontFamilyAvailable(candidate) && (!requireMonospace || isMonospaceFamily(candidate)); - const commitDraft = (next: string) => { - setDraftSettled(true); - if (next.trim().length === 0 || acceptsFamily(next)) { - onValueChange(next); - } - }; - const flushDraft = () => { - const pending = commitTimerRef.current; - cancelPendingCommit(); - if (pending !== null && pending.value === value) commitDraft(draft); - }; - const draftTrimmed = draft.trim(); - const draftPending = draftSettled && draftTrimmed.length > 0 && draftTrimmed !== trimmed; - const resetToDefault = () => { - cancelPendingCommit(); - setDraft(defaultValue); - setDraftSettled(true); - onReset(); - }; - const resetAction = - value !== defaultValue || size.value !== size.defaultValue ? ( - - ) : null; - const fontEnumeration = useFontEnumeration(); - const [inputFocused, setInputFocused] = useState(false); - const familyControl = - fontEnumeration.status === "granted" ? ( - - ) : ( - { - setInputFocused(true); - discoverInstalledFonts(); - }} - onBlur={() => { - setInputFocused(false); - flushDraft(); - }} - onChange={(event) => { - const next = event.currentTarget.value; - setDraft(next); - setDraftSettled(false); - cancelPendingCommit(); - const typedAgainst = value; - const typedInGeneration = adopted.generation; - commitTimerRef.current = { - id: window.setTimeout(() => { - commitTimerRef.current = null; - if (readAdoptedGeneration() === typedInGeneration) commitDraft(next); - }, 400), - value: typedAgainst, - }; - }} - onKeyDown={(event) => { - if (event.key === "Enter") flushDraft(); - if (event.key === "Escape") { - // Discard uncommitted typing without closing the settings page, - // which is what an unhandled Escape does. - event.preventDefault(); - event.stopPropagation(); - cancelPendingCommit(); - setDraft(value); - setDraftSettled(true); - } - }} - placeholder={defaultFamily} - spellCheck={false} - value={draft} - /> - ); - const control = ( -
-
{familyControl}
- -
- ); - return ( - - {preview} - - ); -} - const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3; function AutoSettleDaysInput({ diff --git a/apps/web/src/components/settings/TypographySettings.tsx b/apps/web/src/components/settings/TypographySettings.tsx new file mode 100644 index 000000000000..f991a49bbd43 --- /dev/null +++ b/apps/web/src/components/settings/TypographySettings.tsx @@ -0,0 +1,522 @@ +import type { ReactNode } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { + DEFAULT_UNIFIED_SETTINGS, + MAX_CODE_FONT_SIZE, + MAX_INTERFACE_FONT_SIZE, + MAX_PROMPT_FONT_SIZE, + MAX_TERMINAL_FONT_SIZE, + MIN_CODE_FONT_SIZE, + MIN_INTERFACE_FONT_SIZE, + MIN_PROMPT_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, +} from "@t3tools/contracts/settings"; +import * as Schema from "effect/Schema"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; +import { isMacPlatform } from "../../lib/utils"; +import { Input } from "../ui/input"; +import { + DEFAULT_CODE_FONT_STACK, + DEFAULT_SANS_FONT_STACK, + isFontFamilyAvailable, + isMonospaceFamily, + resolveDefaultFamilyLabel, + resolveTerminalFontPreference, + resolveTerminalFontSizePreference, + TYPOGRAPHY_ADVANCED_STORAGE_KEY, +} from "../../appearanceFonts"; +import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; +import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { + SettingResetButton, + SettingsRow, + SettingsSection, + useSettingsSearchTargetId, +} from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +function useFontDefaultFamilies() { + const settings = useScopedSettings(); + const [defaults] = useState(() => ({ + sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default", + code: resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace", + })); + return { + sans: defaults.sans, + code: defaults.code, + interfaceFamily: settings.fontFamilySans.trim() || defaults.sans, + }; +} + +function InterfaceFontRow({ preview }: { preview?: ReactNode }) { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const defaults = useFontDefaultFamilies(); + return ( + updateSettings({ fontFamilySans })} + onReset={() => + updateSettings({ + fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, + fontSizeInterface: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface, + }) + } + size={{ + label: "Interface font size", + min: MIN_INTERFACE_FONT_SIZE, + max: MAX_INTERFACE_FONT_SIZE, + value: settings.fontSizeInterface, + defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface, + onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }), + }} + {...(preview !== undefined ? { preview } : {})} + /> + ); +} + +function PromptFontRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const defaults = useFontDefaultFamilies(); + return ( + updateSettings({ fontFamilyComposer })} + onReset={() => + updateSettings({ + fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, + fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt, + }) + } + size={{ + label: "Prompt font size", + min: MIN_PROMPT_FONT_SIZE, + max: MAX_PROMPT_FONT_SIZE, + value: settings.fontSizePrompt, + defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt, + onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }), + }} + preview={} + /> + ); +} + +function CodeFontRow({ + title, + description = "Code blocks, diffs, and file previews.", + preview, +}: { + title?: string; + description?: string; + preview?: ReactNode; +}) { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const defaults = useFontDefaultFamilies(); + return ( + updateSettings({ fontFamilyCode })} + onReset={() => + updateSettings({ + fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, + fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode, + }) + } + requireMonospace + size={{ + label: "Code font size", + min: MIN_CODE_FONT_SIZE, + max: MAX_CODE_FONT_SIZE, + value: settings.fontSizeCode, + defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeCode, + onChange: (fontSizeCode) => updateSettings({ fontSizeCode }), + }} + preview={preview ?? } + /> + ); +} + +function TerminalFontRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const defaults = useFontDefaultFamilies(); + return ( + updateSettings({ fontFamilyTerminal })} + onReset={() => + updateSettings({ + fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, + fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal, + }) + } + requireMonospace + size={{ + label: "Terminal font size", + min: MIN_TERMINAL_FONT_SIZE, + max: MAX_TERMINAL_FONT_SIZE, + value: settings.fontSizeTerminal, + defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal, + onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }), + }} + preview={ + + } + /> + ); +} + +function FontSmoothingRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + if (!isMacPlatform(navigator.platform)) return null; + return ( + + updateSettings({ fontSmoothing: DEFAULT_UNIFIED_SETTINGS.fontSmoothing }) + } + /> + ) : null + } + control={ + updateSettings({ fontSmoothing: Boolean(checked) })} + aria-label="Font smoothing" + /> + } + /> + ); +} + +function WordWrapRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + updateSettings({ wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap })} + /> + ) : null + } + control={ + updateSettings({ wordWrap: Boolean(checked) })} + aria-label="Wrap code, tables, diffs, and file previews by default" + /> + } + /> + ); +} + +function FontSettingsGroup() { + return ( + <> + + + + + + + ); +} + +function SimpleFontRows() { + const settings = useScopedSettings(); + return ( + <> + } /> + + + + + } + /> + + ); +} + +// Font smoothing only renders on macOS, so a search jump to it elsewhere +// must not flip the section - the target would never mount to be scrolled to. +const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet = new Set([ + "prompt-font", + "terminal-font", + ...(typeof navigator !== "undefined" && isMacPlatform(navigator.platform) + ? ["font-smoothing"] + : []), +]); + +export function TypographySection() { + const [advanced, setAdvanced] = useLocalStorage( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); + const searchTargetId = useSettingsSearchTargetId(); + const lastExpandedTargetRef = useRef(null); + useEffect(() => { + if (searchTargetId === null || !ADVANCED_TYPOGRAPHY_TARGET_IDS.has(searchTargetId)) return; + if (lastExpandedTargetRef.current === searchTargetId) return; + lastExpandedTargetRef.current = searchTargetId; + setAdvanced(true); + }, [searchTargetId, setAdvanced]); + return ( + + Advanced + setAdvanced(Boolean(checked))} + aria-label="Show advanced typography settings" + /> + + } + > + {advanced ? : } + + + ); +} + +function FontFamilySettingsRow({ + id, + title, + description, + defaultFamily, + defaultValue, + preview, + value, + onValueChange, + onReset, + requireMonospace = false, + size, +}: { + id?: string; + title: string; + description: string; + defaultFamily: string; + defaultValue: string; + preview?: ReactNode; + value: string; + onValueChange: (value: string) => void; + onReset: () => void; + requireMonospace?: boolean; + size: { + label: string; + min: number; + max: number; + value: number; + defaultValue: number; + onChange: (v: number) => void; + }; +}) { + const trimmed = value.trim(); + const [draft, setDraft] = useState(value); + const [draftSettled, setDraftSettled] = useState(true); + // Each pending commit remembers the value it was typed against, so one that outlives + // an external change (hydration, reset, picker selection) drops its stale draft. + const commitTimerRef = useRef<{ readonly id: number; readonly value: string } | null>(null); + const cancelPendingCommit = () => { + if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current.id); + commitTimerRef.current = null; + }; + // Bumped on every external value change, so a pending commit typed before one never lands, + // even if the value has since changed back to what it was typed against. + const [adopted, setAdopted] = useState({ value, generation: 0 }); + if (adopted.value !== value) { + setAdopted({ value, generation: adopted.generation + 1 }); + setDraft(value); + setDraftSettled(true); + } + const readAdoptedGeneration = useEffectEvent(() => adopted.generation); + useEffect( + () => () => { + if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current.id); + }, + [], + ); + const acceptsFamily = (candidate: string) => + isFontFamilyAvailable(candidate) && (!requireMonospace || isMonospaceFamily(candidate)); + const commitDraft = (next: string) => { + setDraftSettled(true); + if (next.trim().length === 0 || acceptsFamily(next)) { + onValueChange(next); + } + }; + const flushDraft = () => { + const pending = commitTimerRef.current; + cancelPendingCommit(); + if (pending !== null && pending.value === value) commitDraft(draft); + }; + const draftTrimmed = draft.trim(); + const draftPending = draftSettled && draftTrimmed.length > 0 && draftTrimmed !== trimmed; + const resetToDefault = () => { + cancelPendingCommit(); + setDraft(defaultValue); + setDraftSettled(true); + onReset(); + }; + const resetAction = + value !== defaultValue || size.value !== size.defaultValue ? ( + + ) : null; + const fontEnumeration = useFontEnumeration(); + const [inputFocused, setInputFocused] = useState(false); + const familyControl = + fontEnumeration.status === "granted" ? ( + + ) : ( + { + setInputFocused(true); + discoverInstalledFonts(); + }} + onBlur={() => { + setInputFocused(false); + flushDraft(); + }} + onChange={(event) => { + const next = event.currentTarget.value; + setDraft(next); + setDraftSettled(false); + cancelPendingCommit(); + const typedAgainst = value; + const typedInGeneration = adopted.generation; + commitTimerRef.current = { + id: window.setTimeout(() => { + commitTimerRef.current = null; + if (readAdoptedGeneration() === typedInGeneration) commitDraft(next); + }, 400), + value: typedAgainst, + }; + }} + onKeyDown={(event) => { + if (event.key === "Enter") flushDraft(); + if (event.key === "Escape") { + // Discard uncommitted typing without closing the settings page, + // which is what an unhandled Escape does. + event.preventDefault(); + event.stopPropagation(); + cancelPendingCommit(); + setDraft(value); + setDraftSettled(true); + } + }} + placeholder={defaultFamily} + spellCheck={false} + value={draft} + /> + ); + const control = ( +
+
{familyControl}
+ +
+ ); + return ( + + {preview} + + ); +} From 501f5c5757c6e5b60e59b9756c125e37154909d0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 01:39:28 -0700 Subject: [PATCH 011/153] refactor(web): the appearance settings page lives in its own file (#22668) Co-authored-by: Claude Opus 5.5 --- .../settings/AppearanceSettingsPanel.tsx | 438 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 423 ----------------- apps/web/src/routes/settings.appearance.tsx | 2 +- 3 files changed, 439 insertions(+), 424 deletions(-) create mode 100644 apps/web/src/components/settings/AppearanceSettingsPanel.tsx diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx new file mode 100644 index 000000000000..a13bc93bb9ce --- /dev/null +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -0,0 +1,438 @@ +import type { CSSProperties } from "react"; +import { useState } from "react"; +import { + DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, + DEFAULT_UNIFIED_SETTINGS, + type ChatWidth, + type EnvironmentIdentificationMode, + MAX_APPEARANCE_CONTRAST, + MAX_GLASS_OPACITY, + MAX_PANEL_ANIMATION_DURATION_MS, + MIN_APPEARANCE_CONTRAST, + MIN_GLASS_OPACITY, + MIN_PANEL_ANIMATION_DURATION_MS, +} from "@t3tools/contracts/settings"; +import { + resolveEnvironmentIdentificationPillLabel, + useEnvironmentStageLabel, +} from "../SidebarStageBackdrop"; +import { useCustomThemes } from "../../hooks/useCustomThemes"; +import { useTheme } from "../../hooks/useTheme"; +import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { ThemeLibrary } from "./ThemeSettings"; +import { + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; +import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; +import { TypographySection } from "./TypographySettings"; + +const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { + artwork: "Artwork", + pill: "Version pill", + none: "None", +}; + +const CHAT_WIDTH_LABELS: Record = { + comfortable: "Comfortable", + wide: "Wide", + full: "Full", +}; + +export function AppearanceSettingsPanel() { + const { + appearanceMode, + refreshTheme, + resolvedTheme, + setAppearanceMode, + setTheme, + setThemeHalf, + theme, + themeHalves, + } = useTheme(); + const customThemes = useCustomThemes(); + const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const environmentStageLabel = useEnvironmentStageLabel(); + const showEnvironmentIdentification = + resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; + const glassOpacityRatio = + (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY); + const glassOpacitySliderStyle = { + "--settings-slider-progress": `${glassOpacityRatio * 100}%`, + "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`, + } as CSSProperties; + const appearanceContrastRatio = + (settings.appearanceContrast - MIN_APPEARANCE_CONTRAST) / + (MAX_APPEARANCE_CONTRAST - MIN_APPEARANCE_CONTRAST); + const appearanceContrastSliderStyle = { + "--settings-slider-progress": `${appearanceContrastRatio * 100}%`, + "--settings-slider-fill-offset": `${0.5 - appearanceContrastRatio}rem`, + } as CSSProperties; + const panelAnimationDurationRatio = + (settings.panelAnimationDurationMs - MIN_PANEL_ANIMATION_DURATION_MS) / + (MAX_PANEL_ANIMATION_DURATION_MS - MIN_PANEL_ANIMATION_DURATION_MS); + const panelAnimationDurationSliderStyle = { + "--settings-slider-progress": `${panelAnimationDurationRatio * 100}%`, + "--settings-slider-fill-offset": `${0.5 - panelAnimationDurationRatio}rem`, + } as CSSProperties; + + return ( + + +
+ +
+
+ + + + updateSettings({ + appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, + }) + } + /> + ) : null + } + control={ +
+ + {settings.appearanceContrast}% + + { + const appearanceContrast = Number(event.currentTarget.value); + if ( + Number.isInteger(appearanceContrast) && + appearanceContrast >= MIN_APPEARANCE_CONTRAST && + appearanceContrast <= MAX_APPEARANCE_CONTRAST + ) { + updateSettings({ appearanceContrast }); + } + }} + step={5} + style={appearanceContrastSliderStyle} + type="range" + value={settings.appearanceContrast} + /> +
+ } + /> + + + updateSettings({ glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity }) + } + /> + ) : null + } + control={ +
+ + {settings.glassOpacity}% + + { + const glassOpacity = Number(event.currentTarget.value); + if ( + Number.isInteger(glassOpacity) && + glassOpacity >= MIN_GLASS_OPACITY && + glassOpacity <= MAX_GLASS_OPACITY + ) { + updateSettings({ glassOpacity }); + } + }} + step={5} + style={glassOpacitySliderStyle} + type="range" + value={settings.glassOpacity} + /> +
+ } + /> + + {showEnvironmentIdentification ? ( + + updateSettings({ + environmentIdentificationMode: DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, + }) + } + /> + ) : null + } + control={ + + } + /> + ) : null} + + + updateSettings({ diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme }) + } + /> + ) : null + } + control={ +
+ +
+ } + /> + + + updateSettings({ + persistComposerContextStrip: + DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip, + }) + } + /> + ) : null + } + control={ + + updateSettings({ persistComposerContextStrip: Boolean(checked) }) + } + aria-label="Keep composer context visible in active threads" + /> + } + /> + + updateSettings({ chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth })} + /> + ) : null + } + control={ +
+ +
+ } + /> + + updateSettings({ + mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled, + }) + } + /> + ) : null + } + control={ + + updateSettings({ mathRenderingEnabled: Boolean(checked) }) + } + aria-label="Render math" + /> + } + /> +
+ + + + +
+ + {settings.panelAnimationDurationMs} ms + + { + const panelAnimationDurationMs = Number(event.currentTarget.value); + if ( + Number.isInteger(panelAnimationDurationMs) && + panelAnimationDurationMs >= MIN_PANEL_ANIMATION_DURATION_MS && + panelAnimationDurationMs <= MAX_PANEL_ANIMATION_DURATION_MS + ) { + updateSettings({ panelAnimationDurationMs }); + } + }} + step={25} + style={panelAnimationDurationSliderStyle} + type="range" + value={settings.panelAnimationDurationMs} + /> +
+ + } + resetAction={ + settings.panelAnimationDurationMs !== + DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs ? ( + + updateSettings({ + panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, + }) + } + /> + ) : null + } + /> +
+ + +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 4d4cccab93d8..fdae7b1853e1 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -4,7 +4,6 @@ import { NotificationSettings } from "./NotificationSettings"; import { PRIVACY_POLICY_URL } from "../../legalLinks"; import { CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; -import type { CSSProperties } from "react"; import { useEffect, useRef, useState } from "react"; import { type BackgroundActivityProfile, @@ -14,18 +13,9 @@ import { type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import { - DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, DEFAULT_UNIFIED_SETTINGS, - type ChatWidth, type DiffLayout, - type EnvironmentIdentificationMode, - MAX_APPEARANCE_CONTRAST, - MAX_GLASS_OPACITY, - MAX_PANEL_ANIMATION_DURATION_MS, MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - MIN_APPEARANCE_CONTRAST, - MIN_GLASS_OPACITY, - MIN_PANEL_ANIMATION_DURATION_MS, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, type ResponseStreamingMode, type QuitConfirmationMode, @@ -49,13 +39,8 @@ import { } from "../../components/desktopUpdate.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; -import { - resolveEnvironmentIdentificationPillLabel, - useEnvironmentStageLabel, -} from "../SidebarStageBackdrop"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; -import { useCustomThemes } from "../../hooks/useCustomThemes"; import { readAppearanceModePreference, readThemeHalves, @@ -110,7 +95,6 @@ import { Switch } from "../ui/switch"; import { ScopedSwitch } from "./ScopedSwitch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { ThemeLibrary } from "./ThemeSettings"; import { backgroundActivityOverrideSettings, backgroundActivitySharedPolicySettings, @@ -137,14 +121,6 @@ import { useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; -import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; -import { TypographySection } from "./TypographySettings"; - -const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { - artwork: "Artwork", - pill: "Version pill", - none: "None", -}; const RESPONSE_STREAMING_MODE_LABELS: Record = { turn: "Wait for the full response", @@ -169,12 +145,6 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; -const CHAT_WIDTH_LABELS: Record = { - comfortable: "Comfortable", - wide: "Wide", - full: "Full", -}; - const DIFF_LAYOUT_LABELS: Record = { stacked: "Stacked", split: "Split", @@ -1037,399 +1007,6 @@ function BackgroundActivityAdvancedDialog({ ); } -export function AppearanceSettingsPanel() { - const { - appearanceMode, - refreshTheme, - resolvedTheme, - setAppearanceMode, - setTheme, - setThemeHalf, - theme, - themeHalves, - } = useTheme(); - const customThemes = useCustomThemes(); - const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); - const settings = useScopedSettings(); - const updateSettings = useUpdateScopedSettings(); - const environmentStageLabel = useEnvironmentStageLabel(); - const showEnvironmentIdentification = - resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; - const glassOpacityRatio = - (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY); - const glassOpacitySliderStyle = { - "--settings-slider-progress": `${glassOpacityRatio * 100}%`, - "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`, - } as CSSProperties; - const appearanceContrastRatio = - (settings.appearanceContrast - MIN_APPEARANCE_CONTRAST) / - (MAX_APPEARANCE_CONTRAST - MIN_APPEARANCE_CONTRAST); - const appearanceContrastSliderStyle = { - "--settings-slider-progress": `${appearanceContrastRatio * 100}%`, - "--settings-slider-fill-offset": `${0.5 - appearanceContrastRatio}rem`, - } as CSSProperties; - const panelAnimationDurationRatio = - (settings.panelAnimationDurationMs - MIN_PANEL_ANIMATION_DURATION_MS) / - (MAX_PANEL_ANIMATION_DURATION_MS - MIN_PANEL_ANIMATION_DURATION_MS); - const panelAnimationDurationSliderStyle = { - "--settings-slider-progress": `${panelAnimationDurationRatio * 100}%`, - "--settings-slider-fill-offset": `${0.5 - panelAnimationDurationRatio}rem`, - } as CSSProperties; - - return ( - - -
- -
-
- - - - updateSettings({ - appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, - }) - } - /> - ) : null - } - control={ -
- - {settings.appearanceContrast}% - - { - const appearanceContrast = Number(event.currentTarget.value); - if ( - Number.isInteger(appearanceContrast) && - appearanceContrast >= MIN_APPEARANCE_CONTRAST && - appearanceContrast <= MAX_APPEARANCE_CONTRAST - ) { - updateSettings({ appearanceContrast }); - } - }} - step={5} - style={appearanceContrastSliderStyle} - type="range" - value={settings.appearanceContrast} - /> -
- } - /> - - - updateSettings({ glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity }) - } - /> - ) : null - } - control={ -
- - {settings.glassOpacity}% - - { - const glassOpacity = Number(event.currentTarget.value); - if ( - Number.isInteger(glassOpacity) && - glassOpacity >= MIN_GLASS_OPACITY && - glassOpacity <= MAX_GLASS_OPACITY - ) { - updateSettings({ glassOpacity }); - } - }} - step={5} - style={glassOpacitySliderStyle} - type="range" - value={settings.glassOpacity} - /> -
- } - /> - - {showEnvironmentIdentification ? ( - - updateSettings({ - environmentIdentificationMode: DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, - }) - } - /> - ) : null - } - control={ - - } - /> - ) : null} - - - updateSettings({ diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme }) - } - /> - ) : null - } - control={ -
- -
- } - /> - - - updateSettings({ - persistComposerContextStrip: - DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip, - }) - } - /> - ) : null - } - control={ - - updateSettings({ persistComposerContextStrip: Boolean(checked) }) - } - aria-label="Keep composer context visible in active threads" - /> - } - /> - - updateSettings({ chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth })} - /> - ) : null - } - control={ -
- -
- } - /> - - updateSettings({ - mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled, - }) - } - /> - ) : null - } - control={ - - updateSettings({ mathRenderingEnabled: Boolean(checked) }) - } - aria-label="Render math" - /> - } - /> -
- - - - -
- - {settings.panelAnimationDurationMs} ms - - { - const panelAnimationDurationMs = Number(event.currentTarget.value); - if ( - Number.isInteger(panelAnimationDurationMs) && - panelAnimationDurationMs >= MIN_PANEL_ANIMATION_DURATION_MS && - panelAnimationDurationMs <= MAX_PANEL_ANIMATION_DURATION_MS - ) { - updateSettings({ panelAnimationDurationMs }); - } - }} - step={25} - style={panelAnimationDurationSliderStyle} - type="range" - value={settings.panelAnimationDurationMs} - /> -
- - } - resetAction={ - settings.panelAnimationDurationMs !== - DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs ? ( - - updateSettings({ - panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, - }) - } - /> - ) : null - } - /> -
- - -
- ); -} - const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3; function AutoSettleDaysInput({ diff --git a/apps/web/src/routes/settings.appearance.tsx b/apps/web/src/routes/settings.appearance.tsx index 60c33fff307c..30e53d0b9bed 100644 --- a/apps/web/src/routes/settings.appearance.tsx +++ b/apps/web/src/routes/settings.appearance.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { AppearanceSettingsPanel } from "../components/settings/SettingsPanels"; +import { AppearanceSettingsPanel } from "../components/settings/AppearanceSettingsPanel"; function SettingsAppearanceRoute() { return ; From 381514e7af09293b35d11e21c21e5d7fe65ccb84 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 01:39:28 -0700 Subject: [PATCH 012/153] refactor(web): the background activity setting and its dialog live in their own file (#22669) Co-authored-by: Claude Opus 5.5 --- .../settings/BackgroundActivitySettings.tsx | 487 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 467 +---------------- 2 files changed, 490 insertions(+), 464 deletions(-) create mode 100644 apps/web/src/components/settings/BackgroundActivitySettings.tsx diff --git a/apps/web/src/components/settings/BackgroundActivitySettings.tsx b/apps/web/src/components/settings/BackgroundActivitySettings.tsx new file mode 100644 index 000000000000..9892f9ae753f --- /dev/null +++ b/apps/web/src/components/settings/BackgroundActivitySettings.tsx @@ -0,0 +1,487 @@ +import { useScopedSettingsWriteAllowed } from "./useScopedSettings"; +import { SettingsIcon } from "lucide-react"; +import { useState } from "react"; +import { type BackgroundActivityProfile } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import * as Duration from "effect/Duration"; +import * as Equal from "effect/Equal"; +import { + useScopedSettings, + useScopedSettingsMixed, + useUpdateScopedSettings, +} from "./useScopedSettings"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + backgroundActivityOverrideSettings, + backgroundActivitySharedPolicySettings, + durationToSeconds, + normalizeIntervalSeconds, + PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, + resolveBackgroundActivityProfileOption, +} from "./SettingsPanels.logic"; +import { PolicyTooltip, SettingResetButton, SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record = { + balanced: "Balanced", + performance: "Performance", + "battery-saver": "Battery saver", +}; + +type BackgroundActivityProfileOption = BackgroundActivityProfile | "advanced"; + +const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record = { + ...BACKGROUND_ACTIVITY_PROFILE_LABELS, + advanced: "Advanced", +}; + +const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record = { + balanced: "Pauses probes for idle clients, locked hosts, or low power mode.", + performance: "Allows scoped background probes while any subscribed client remains connected.", + "battery-saver": "Also pauses background probes when the host or client is on battery.", +}; + +const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = "Uses custom intervals."; + +const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{ + readonly key: + | "pauseWhenHostLocked" + | "pauseWhenHostLowPower" + | "pauseWhenClientLowPower" + | "pauseWhenOnBattery"; + readonly label: string; +}> = [ + { key: "pauseWhenHostLocked", label: "Pause when host is locked" }, + { key: "pauseWhenHostLowPower", label: "Pause on host low power" }, + { key: "pauseWhenClientLowPower", label: "Pause on client low power" }, + { key: "pauseWhenOnBattery", label: "Pause on battery" }, +]; + +function resetBackgroundActivitySettings() { + return { + backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, + }; +} + +function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) { + return { + backgroundActivity: { + schemaVersion: 1 as const, + profile, + overrides: {}, + }, + }; +} + +function BackgroundActivityAdvancedDialog({ + open, + onOpenChange, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +}) { + const canWriteSettings = useScopedSettingsWriteAllowed(); + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); + const activeProfile = resolvedBackgroundActivity.profile; + const automaticGitFetchIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.automaticGitFetchInterval, + ); + const providerHealthRefreshIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.providerHealthRefreshInterval, + ); + const hostPowerMonitorActiveIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.hostPowerMonitorActiveInterval, + ); + const hostPowerMonitorIdleIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.hostPowerMonitorIdleInterval, + ); + + return ( + + + + Background Activity + + Tune the shared power policy and the background intervals that feed it. + + + +
+
+
+
Shared policy
+

+ Controls whether background work may run after a subscribed interval fires. +

+
+ +
+ +
+
+
+ {searchableSetting("git-fetch-interval").title} +
+

+ Refresh remote branch status in the background. +

+
+
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + automaticGitFetchInterval: Duration.seconds( + normalizeIntervalSeconds(value), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+
+ +
+
+
Provider health interval
+

+ Refresh provider availability, versions, and auth state. Model catalog updates + follow Provider update checks. +

+
+
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: Duration.seconds( + normalizeIntervalSeconds(value), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+
+ +
+
+
Host power monitor
+

+ Poll host power state while clients are active. +

+
+
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + hostPowerMonitorActiveInterval: Duration.seconds( + normalizeIntervalSeconds(value, 5), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+
+ +
+
+
Idle host monitor
+

+ Poll host power state when no foreground client is active. +

+
+
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + hostPowerMonitorIdleInterval: Duration.seconds( + normalizeIntervalSeconds(value, 5), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+
+ +
+ {BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES.map(({ key, label }) => ( + + ))} +
+
+
+ + + + +
+
+ ); +} + +export function BackgroundActivitySettingsRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const { scope, environment } = useSettingsScope(); + const environmentId = environment?.environmentId ?? null; + const isEnvironmentScope = scope.environmentIds.length === 1 && environmentId !== null; + const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); + const mixedBackgroundActivity = useScopedSettingsMixed(["backgroundActivity"]); + const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); + const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile; + const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); + const backgroundActivityDescription = + backgroundActivityProfileOption === "advanced" + ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Shared policy: ${ + BACKGROUND_ACTIVITY_PROFILE_LABELS[activeBackgroundActivityProfile] + }.` + : BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS[resolvedBackgroundActivity.profile]; + const canResetBackgroundActivity = !Equal.equals( + settings.backgroundActivity, + DEFAULT_UNIFIED_SETTINGS.backgroundActivity, + ); + + return ( + + {searchableSetting("background-activity").title} + + This shared policy gates background work such as Git refreshes and provider health + probes after their individual intervals elapse. + + + } + description={backgroundActivityDescription} + resetAction={ + canResetBackgroundActivity ? ( + updateSettings(resetBackgroundActivitySettings())} + /> + ) : null + } + control={ + <> + + {backgroundActivityProfileOption === "advanced" && isEnvironmentScope ? ( + + setBackgroundActivityDialogOpen(true)} + > + + + } + /> + Configure background activity + + ) : null} + + + } + /> + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fdae7b1853e1..cdf7d6183905 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,12 +1,10 @@ import { SettingsGroup } from "./SettingsGroup"; -import { useScopedSettingsWriteAllowed } from "./useScopedSettings"; import { NotificationSettings } from "./NotificationSettings"; import { PRIVACY_POLICY_URL } from "../../legalLinks"; -import { CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; +import { CheckIcon, ChevronRightIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { - type BackgroundActivityProfile, type DesktopUpdateChannel, ProviderDriverKind, type ProviderInstanceId, @@ -21,9 +19,7 @@ import { type QuitConfirmationMode, SidebarProjectSortOrder, } from "@t3tools/contracts/settings"; -import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; -import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; @@ -72,46 +68,23 @@ import { useEnvironmentProviders } from "../../state/environments"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; -import { - NumberField, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, -} from "../ui/number-field"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { ScopedSwitch } from "./ScopedSwitch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { - backgroundActivityOverrideSettings, - backgroundActivitySharedPolicySettings, - durationToSeconds, getChangedBrowserSettingLabels, getChangedTypographySettingLabels, - normalizeIntervalSeconds, - PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, projectGroupingModeFromToggle, readLastEnabledProjectGroupingMode, rememberEnabledProjectGroupingMode, - resolveBackgroundActivityProfileOption, } from "./SettingsPanels.logic"; import { - PolicyTooltip, SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, SettingsPageContainer, @@ -121,6 +94,7 @@ import { useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; +import { BackgroundActivitySettingsRow } from "./BackgroundActivitySettings"; const RESPONSE_STREAMING_MODE_LABELS: Record = { turn: "Wait for the full response", @@ -156,58 +130,7 @@ const QUIT_CONFIRMATION_MODE_LABELS: Record = { "double-click": "Double press", }; -const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record = { - balanced: "Balanced", - performance: "Performance", - "battery-saver": "Battery saver", -}; - -type BackgroundActivityProfileOption = BackgroundActivityProfile | "advanced"; - -const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record = { - ...BACKGROUND_ACTIVITY_PROFILE_LABELS, - advanced: "Advanced", -}; - -const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record = { - balanced: "Pauses probes for idle clients, locked hosts, or low power mode.", - performance: "Allows scoped background probes while any subscribed client remains connected.", - "battery-saver": "Also pauses background probes when the host or client is on battery.", -}; - -const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = "Uses custom intervals."; - const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); -const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{ - readonly key: - | "pauseWhenHostLocked" - | "pauseWhenHostLowPower" - | "pauseWhenClientLowPower" - | "pauseWhenOnBattery"; - readonly label: string; -}> = [ - { key: "pauseWhenHostLocked", label: "Pause when host is locked" }, - { key: "pauseWhenHostLowPower", label: "Pause on host low power" }, - { key: "pauseWhenClientLowPower", label: "Pause on client low power" }, - { key: "pauseWhenOnBattery", label: "Pause on battery" }, -]; - -function resetBackgroundActivitySettings() { - return { - backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, - }; -} - -function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) { - return { - backgroundActivity: { - schemaVersion: 1 as const, - profile, - overrides: {}, - }, - }; -} - function AboutVersionTitle() { return ( @@ -729,284 +652,6 @@ export function useSettingsRestore(onRestored?: () => void) { }; } -function BackgroundActivityAdvancedDialog({ - open, - onOpenChange, -}: { - readonly open: boolean; - readonly onOpenChange: (open: boolean) => void; -}) { - const canWriteSettings = useScopedSettingsWriteAllowed(); - const settings = useScopedSettings(); - const updateSettings = useUpdateScopedSettings(); - const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); - const activeProfile = resolvedBackgroundActivity.profile; - const automaticGitFetchIntervalSeconds = durationToSeconds( - resolvedBackgroundActivity.automaticGitFetchInterval, - ); - const providerHealthRefreshIntervalSeconds = durationToSeconds( - resolvedBackgroundActivity.providerHealthRefreshInterval, - ); - const hostPowerMonitorActiveIntervalSeconds = durationToSeconds( - resolvedBackgroundActivity.hostPowerMonitorActiveInterval, - ); - const hostPowerMonitorIdleIntervalSeconds = durationToSeconds( - resolvedBackgroundActivity.hostPowerMonitorIdleInterval, - ); - - return ( - - - - Background Activity - - Tune the shared power policy and the background intervals that feed it. - - - -
-
-
-
Shared policy
-

- Controls whether background work may run after a subscribed interval fires. -

-
- -
- -
-
-
- {searchableSetting("git-fetch-interval").title} -
-

- Refresh remote branch status in the background. -

-
-
- - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - automaticGitFetchInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, - ), - ) - } - > - - - - - - - seconds -
-
- -
-
-
Provider health interval
-

- Refresh provider availability, versions, and auth state. Model catalog updates - follow Provider update checks. -

-
-
- - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, - ), - ) - } - > - - - - - - - seconds -
-
- -
-
-
Host power monitor
-

- Poll host power state while clients are active. -

-
-
- - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - hostPowerMonitorActiveInterval: Duration.seconds( - normalizeIntervalSeconds(value, 5), - ), - }, - ), - ) - } - > - - - - - - - seconds -
-
- -
-
-
Idle host monitor
-

- Poll host power state when no foreground client is active. -

-
-
- - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - hostPowerMonitorIdleInterval: Duration.seconds( - normalizeIntervalSeconds(value, 5), - ), - }, - ), - ) - } - > - - - - - - - seconds -
-
- -
- {BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES.map(({ key, label }) => ( - - ))} -
-
-
- - - - -
-
- ); -} - const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3; function AutoSettleDaysInput({ @@ -1143,7 +788,6 @@ export function GeneralSettingsPanel() { const environmentId = environment?.environmentId ?? null; const isEnvironmentScope = scope.environmentIds.length === 1 && environmentId !== null; const hasServerTargets = connectedEnvironments.length > 0; - const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); const mixedResponseStreamingMode = useScopedSettingsMixed(["responseStreamingMode"]); const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), @@ -1206,22 +850,8 @@ export function GeneralSettingsPanel() { settings, textGenerationModelInstanceEntries, ); - const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); - const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile; - const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); - const mixedBackgroundActivity = useScopedSettingsMixed(["backgroundActivity"]); const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]); const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]); - const backgroundActivityDescription = - backgroundActivityProfileOption === "advanced" - ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Shared policy: ${ - BACKGROUND_ACTIVITY_PROFILE_LABELS[activeBackgroundActivityProfile] - }.` - : BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS[resolvedBackgroundActivity.profile]; - const canResetBackgroundActivity = !Equal.equals( - settings.backgroundActivity, - DEFAULT_UNIFIED_SETTINGS.backgroundActivity, - ); return ( @@ -1959,98 +1589,7 @@ export function GeneralSettingsPanel() { } /> - - {searchableSetting("background-activity").title} - - This shared policy gates background work such as Git refreshes and provider health - probes after their individual intervals elapse. - -
- } - description={backgroundActivityDescription} - resetAction={ - canResetBackgroundActivity ? ( - updateSettings(resetBackgroundActivitySettings())} - /> - ) : null - } - control={ - <> - - {backgroundActivityProfileOption === "advanced" && isEnvironmentScope ? ( - - setBackgroundActivityDialogOpen(true)} - > - - - } - /> - Configure background activity - - ) : null} - - - } - /> + From 9dc28a125a845cd98a7984c72169c4cc34694119 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 01:39:29 -0700 Subject: [PATCH 013/153] refactor(web): the connections page's pure helpers live in ConnectionsSettings.logic (#22683) Co-authored-by: Claude Opus 5.5 --- .../settings/ConnectionsSettings.logic.ts | 291 +++++++++++++++++ .../settings/ConnectionsSettings.tsx | 296 ++---------------- 2 files changed, 310 insertions(+), 277 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index 9e9f5be1b91e..db5ba42ae7c6 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -1,10 +1,22 @@ +import * as DateTime from "effect/DateTime"; + +import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; +import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; +import { readHostedPairingRequest } from "../../hostedPairing"; +import { + type ServerClientSessionRecord, + type ServerPairingLinkRecord, +} from "~/environments/primary"; import { type AdvertisedEndpoint, + type AuthClientSession, type AuthGrantScope, + type AuthPairingLink, AuthTerminalOperateScope, AuthTerminalReadScope, type DesktopBridge, type DesktopServerExposureState, + type DesktopSshEnvironmentTarget, type DesktopUpdateState, type DesktopWslState, } from "@t3tools/contracts"; @@ -150,3 +162,282 @@ export async function applyWslEnableSelection(input: { } return await bridge.setWslBackendEnabled(true); } + +const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +export function formatAccessTimestamp(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value; + } + return accessTimestampFormatter.format(parsed); +} + +export function parseManualDesktopSshTarget(input: { + readonly host: string; + readonly username: string; + readonly port: string; +}): DesktopSshEnvironmentTarget { + const rawHost = input.host.trim(); + if (rawHost.length === 0) { + throw new Error("SSH host or alias is required."); + } + + let hostname = rawHost; + let username = input.username.trim() || null; + let port: number | null = null; + + const atIndex = hostname.lastIndexOf("@"); + if (atIndex > 0) { + const inlineUsername = hostname.slice(0, atIndex).trim(); + hostname = hostname.slice(atIndex + 1).trim(); + if (!username && inlineUsername.length > 0) { + username = inlineUsername; + } + } + + const bracketedHostMatch = /^\[([^\]]+)\](?::(\d+))?$/u.exec(hostname); + if (bracketedHostMatch) { + hostname = bracketedHostMatch[1]!.trim(); + if (bracketedHostMatch[2]) { + port = Number.parseInt(bracketedHostMatch[2], 10); + } + } else { + const colonSegments = hostname.split(":"); + if (colonSegments.length === 2 && /^\d+$/u.test(colonSegments[1] ?? "")) { + hostname = colonSegments[0]!.trim(); + port = Number.parseInt(colonSegments[1]!, 10); + } + } + + const rawPort = input.port.trim(); + if (rawPort.length > 0) { + port = Number.parseInt(rawPort, 10); + } + + if (hostname.length === 0) { + throw new Error("SSH host or alias is required."); + } + + if (port !== null && (!Number.isInteger(port) || port <= 0 || port > 65_535)) { + throw new Error("SSH port must be between 1 and 65535."); + } + + return { + alias: hostname, + hostname, + username, + port, + }; +} + +export function parsePairingUrlFields( + input: string, +): { readonly host: string; readonly pairingCode: string } | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + try { + const urlLikeInput = + /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//u.test(trimmed) || trimmed.startsWith("//") + ? trimmed + : `https://${trimmed}`; + const url = new URL(urlLikeInput, window.location.origin); + const hostedPairingRequest = readHostedPairingRequest(url); + if (hostedPairingRequest) { + return { + host: hostedPairingRequest.host, + pairingCode: hostedPairingRequest.token, + }; + } + + const pairingCode = getPairingTokenFromUrl(url); + if (!pairingCode) return null; + return { + host: url.origin, + pairingCode, + }; + } catch { + return null; + } +} + +export function parseRemotePairingFields(input: { + readonly host: string; + readonly pairingCode: string; +}): { + readonly host: string; + readonly pairingCode: string; +} { + const parsedPairingUrl = parsePairingUrlFields(input.host); + if (parsedPairingUrl) return parsedPairingUrl; + + const host = input.host.trim(); + const pairingCode = input.pairingCode.trim(); + if (!host) { + throw new Error("Enter a backend host."); + } + if (!pairingCode) { + throw new Error("Enter a pairing code."); + } + return { host, pairingCode }; +} + +export function formatDesktopSshConnectionError(error: unknown): string { + const fallback = "Failed to connect SSH host."; + const rawMessage = error instanceof Error ? error.message : fallback; + const withoutIpcPrefix = rawMessage.replace( + /^Error invoking remote method 'desktop:ensure-ssh-environment':\s*/u, + "", + ); + const withoutTaggedErrorPrefix = withoutIpcPrefix.replace(/^Ssh[A-Za-z]+Error:\s*/u, ""); + return withoutTaggedErrorPrefix.trim() || fallback; +} + +export function sortDesktopPairingLinks(links: ReadonlyArray) { + return [...links].toSorted( + (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), + ); +} + +export function summarizeAuthorizedClients( + sessions: ReadonlyArray, + links: ReadonlyArray, +): string { + const parts = [ + `${sessions.length} ${sessions.length === 1 ? "client" : "clients"}`, + links.length > 0 + ? `${links.length} ${links.length === 1 ? "pairing link" : "pairing links"}` + : null, + ]; + return parts.filter((part): part is string => part !== null).join(" · "); +} + +export function sortDesktopClientSessions(sessions: ReadonlyArray) { + return [...sessions].toSorted((left, right) => { + if (left.current !== right.current) { + return left.current ? -1 : 1; + } + if (left.connected !== right.connected) { + return left.connected ? -1 : 1; + } + return new Date(right.issuedAt).getTime() - new Date(left.issuedAt).getTime(); + }); +} + +export function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord { + return { + ...pairingLink, + scopes: pairingLink.permissions ?? pairingLink.scopes, + createdAt: DateTime.formatIso(pairingLink.createdAt), + expiresAt: DateTime.formatIso(pairingLink.expiresAt), + }; +} + +export function toDesktopClientSessionRecord( + clientSession: AuthClientSession, +): ServerClientSessionRecord { + return { + ...clientSession, + scopes: clientSession.permissions ?? clientSession.scopes, + issuedAt: DateTime.formatIso(clientSession.issuedAt), + expiresAt: DateTime.formatIso(clientSession.expiresAt), + lastConnectedAt: + clientSession.lastConnectedAt === null + ? null + : DateTime.formatIso(clientSession.lastConnectedAt), + }; +} + +export function selectPairingEndpoint( + endpoints: ReadonlyArray, + defaultEndpointKey?: string | null, +): AdvertisedEndpoint | null { + const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable"); + if (defaultEndpointKey) { + const selectedEndpoint = availableEndpoints.find( + (endpoint) => endpointDefaultPreferenceKey(endpoint) === defaultEndpointKey, + ); + if (selectedEndpoint) { + return selectedEndpoint; + } + } + return ( + availableEndpoints.find((endpoint) => endpoint.isDefault) ?? + availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ?? + availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ?? + null + ); +} + +export function isTailscaleHttpsEndpoint(endpoint: AdvertisedEndpoint): boolean { + return endpoint.id.startsWith("tailscale-magicdns:"); +} + +export function endpointDefaultPreferenceKey(endpoint: AdvertisedEndpoint): string { + if (endpoint.id.startsWith("desktop-loopback:")) { + return "desktop-core:loopback:http"; + } + if (endpoint.id.startsWith("desktop-lan:")) { + return "desktop-core:lan:http"; + } + if (endpoint.id.startsWith("tailscale-ip:")) { + return "tailscale:ip:http"; + } + if (isTailscaleHttpsEndpoint(endpoint)) { + return "tailscale:magicdns:https"; + } + + let scheme = "unknown"; + try { + scheme = new URL(endpoint.httpBaseUrl).protocol.replace(/:$/u, ""); + } catch {} + + return `${endpoint.provider.id}:${endpoint.reachability}:${scheme}:${endpoint.label}`; +} + +export function resolveAdvertisedEndpointPairingUrl( + endpoint: AdvertisedEndpoint, + credential: string, +): string { + if (endpoint.compatibility.hostedHttpsApp === "compatible") { + return ( + resolveHostedPairingUrl(endpoint.httpBaseUrl, credential) ?? + resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential) + ); + } + return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential); +} + +export function resolveCurrentOriginPairingUrl(credential: string): string { + const url = new URL("/pair", window.location.href); + return setPairingTokenOnUrl(url, credential).toString(); +} + +export function isHostedAppPairingUrl(value: string): boolean { + try { + const url = new URL(value); + return url.pathname === "/pair" && url.searchParams.has("host"); + } catch { + return false; + } +} + +export function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string { + if (isHostedAppPairingUrl(url)) { + return "Opens the hosted app, no install needed"; + } + switch (endpoint.reachability) { + case "lan": + return "Devices on the same network"; + case "private-network": + return "Devices on your private network"; + case "public": + return "Reachable from anywhere"; + case "loopback": + return "Clients on this machine"; + } +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index bda517c4fe1c..1156b5689778 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -60,7 +60,6 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; @@ -148,8 +147,6 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from ".. import { AnimatedHeight } from "../AnimatedHeight"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { Textarea } from "../ui/textarea"; -import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; -import { readHostedPairingRequest } from "../../hostedPairing"; import { createServerPairingCredential, revokeOtherServerClientSessions, @@ -218,6 +215,25 @@ import { threadJumpCommandForIndex, threadJumpIndexFromCommand, } from "../../keybindings"; +import { + formatAccessTimestamp, + parseManualDesktopSshTarget, + parsePairingUrlFields, + parseRemotePairingFields, + formatDesktopSshConnectionError, + sortDesktopPairingLinks, + summarizeAuthorizedClients, + sortDesktopClientSessions, + toDesktopPairingLinkRecord, + toDesktopClientSessionRecord, + selectPairingEndpoint, + isTailscaleHttpsEndpoint, + endpointDefaultPreferenceKey, + resolveAdvertisedEndpointPairingUrl, + resolveCurrentOriginPairingUrl, + isHostedAppPairingUrl, + endpointShareHint, +} from "./ConnectionsSettings.logic"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray = []; @@ -229,19 +245,6 @@ const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray = []; const BACKEND_VALUE_DEFAULT_WSL = "backend:default-wsl"; const BACKEND_VALUE_WSL_OFF = "backend:wsl-off"; -const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", -}); - -function formatAccessTimestamp(value: string): string { - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return value; - } - return accessTimestampFormatter.format(parsed); -} - function AccessScopeSummary({ scopes, label, @@ -286,124 +289,6 @@ function AccessScopeSummary({ ); } -function parseManualDesktopSshTarget(input: { - readonly host: string; - readonly username: string; - readonly port: string; -}): DesktopSshEnvironmentTarget { - const rawHost = input.host.trim(); - if (rawHost.length === 0) { - throw new Error("SSH host or alias is required."); - } - - let hostname = rawHost; - let username = input.username.trim() || null; - let port: number | null = null; - - const atIndex = hostname.lastIndexOf("@"); - if (atIndex > 0) { - const inlineUsername = hostname.slice(0, atIndex).trim(); - hostname = hostname.slice(atIndex + 1).trim(); - if (!username && inlineUsername.length > 0) { - username = inlineUsername; - } - } - - const bracketedHostMatch = /^\[([^\]]+)\](?::(\d+))?$/u.exec(hostname); - if (bracketedHostMatch) { - hostname = bracketedHostMatch[1]!.trim(); - if (bracketedHostMatch[2]) { - port = Number.parseInt(bracketedHostMatch[2], 10); - } - } else { - const colonSegments = hostname.split(":"); - if (colonSegments.length === 2 && /^\d+$/u.test(colonSegments[1] ?? "")) { - hostname = colonSegments[0]!.trim(); - port = Number.parseInt(colonSegments[1]!, 10); - } - } - - const rawPort = input.port.trim(); - if (rawPort.length > 0) { - port = Number.parseInt(rawPort, 10); - } - - if (hostname.length === 0) { - throw new Error("SSH host or alias is required."); - } - - if (port !== null && (!Number.isInteger(port) || port <= 0 || port > 65_535)) { - throw new Error("SSH port must be between 1 and 65535."); - } - - return { - alias: hostname, - hostname, - username, - port, - }; -} - -function parsePairingUrlFields( - input: string, -): { readonly host: string; readonly pairingCode: string } | null { - const trimmed = input.trim(); - if (!trimmed) return null; - - try { - const urlLikeInput = - /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//u.test(trimmed) || trimmed.startsWith("//") - ? trimmed - : `https://${trimmed}`; - const url = new URL(urlLikeInput, window.location.origin); - const hostedPairingRequest = readHostedPairingRequest(url); - if (hostedPairingRequest) { - return { - host: hostedPairingRequest.host, - pairingCode: hostedPairingRequest.token, - }; - } - - const pairingCode = getPairingTokenFromUrl(url); - if (!pairingCode) return null; - return { - host: url.origin, - pairingCode, - }; - } catch { - return null; - } -} - -function parseRemotePairingFields(input: { readonly host: string; readonly pairingCode: string }): { - readonly host: string; - readonly pairingCode: string; -} { - const parsedPairingUrl = parsePairingUrlFields(input.host); - if (parsedPairingUrl) return parsedPairingUrl; - - const host = input.host.trim(); - const pairingCode = input.pairingCode.trim(); - if (!host) { - throw new Error("Enter a backend host."); - } - if (!pairingCode) { - throw new Error("Enter a pairing code."); - } - return { host, pairingCode }; -} - -function formatDesktopSshConnectionError(error: unknown): string { - const fallback = "Failed to connect SSH host."; - const rawMessage = error instanceof Error ? error.message : fallback; - const withoutIpcPrefix = rawMessage.replace( - /^Error invoking remote method 'desktop:ensure-ssh-environment':\s*/u, - "", - ); - const withoutTaggedErrorPrefix = withoutIpcPrefix.replace(/^Ssh[A-Za-z]+Error:\s*/u, ""); - return withoutTaggedErrorPrefix.trim() || fallback; -} - const ENDPOINT_ROW_CLASSNAME = "first:rounded-t-xl last:rounded-b-xl px-3 py-2.5 sm:px-4"; type AccessSectionPresentation = "current" | "endpoint-rail"; @@ -423,149 +308,6 @@ function endpointRowClassName(presentation: AccessSectionPresentation, isAvailab return cn(ENDPOINT_ROW_CLASSNAME, !isAvailable && "bg-muted/24"); } -function sortDesktopPairingLinks(links: ReadonlyArray) { - return [...links].toSorted( - (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), - ); -} - -function summarizeAuthorizedClients( - sessions: ReadonlyArray, - links: ReadonlyArray, -): string { - const parts = [ - `${sessions.length} ${sessions.length === 1 ? "client" : "clients"}`, - links.length > 0 - ? `${links.length} ${links.length === 1 ? "pairing link" : "pairing links"}` - : null, - ]; - return parts.filter((part): part is string => part !== null).join(" · "); -} - -function sortDesktopClientSessions(sessions: ReadonlyArray) { - return [...sessions].toSorted((left, right) => { - if (left.current !== right.current) { - return left.current ? -1 : 1; - } - if (left.connected !== right.connected) { - return left.connected ? -1 : 1; - } - return new Date(right.issuedAt).getTime() - new Date(left.issuedAt).getTime(); - }); -} - -function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord { - return { - ...pairingLink, - scopes: pairingLink.permissions ?? pairingLink.scopes, - createdAt: DateTime.formatIso(pairingLink.createdAt), - expiresAt: DateTime.formatIso(pairingLink.expiresAt), - }; -} - -function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerClientSessionRecord { - return { - ...clientSession, - scopes: clientSession.permissions ?? clientSession.scopes, - issuedAt: DateTime.formatIso(clientSession.issuedAt), - expiresAt: DateTime.formatIso(clientSession.expiresAt), - lastConnectedAt: - clientSession.lastConnectedAt === null - ? null - : DateTime.formatIso(clientSession.lastConnectedAt), - }; -} - -function selectPairingEndpoint( - endpoints: ReadonlyArray, - defaultEndpointKey?: string | null, -): AdvertisedEndpoint | null { - const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable"); - if (defaultEndpointKey) { - const selectedEndpoint = availableEndpoints.find( - (endpoint) => endpointDefaultPreferenceKey(endpoint) === defaultEndpointKey, - ); - if (selectedEndpoint) { - return selectedEndpoint; - } - } - return ( - availableEndpoints.find((endpoint) => endpoint.isDefault) ?? - availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ?? - availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ?? - null - ); -} - -function isTailscaleHttpsEndpoint(endpoint: AdvertisedEndpoint): boolean { - return endpoint.id.startsWith("tailscale-magicdns:"); -} - -function endpointDefaultPreferenceKey(endpoint: AdvertisedEndpoint): string { - if (endpoint.id.startsWith("desktop-loopback:")) { - return "desktop-core:loopback:http"; - } - if (endpoint.id.startsWith("desktop-lan:")) { - return "desktop-core:lan:http"; - } - if (endpoint.id.startsWith("tailscale-ip:")) { - return "tailscale:ip:http"; - } - if (isTailscaleHttpsEndpoint(endpoint)) { - return "tailscale:magicdns:https"; - } - - let scheme = "unknown"; - try { - scheme = new URL(endpoint.httpBaseUrl).protocol.replace(/:$/u, ""); - } catch {} - - return `${endpoint.provider.id}:${endpoint.reachability}:${scheme}:${endpoint.label}`; -} - -function resolveAdvertisedEndpointPairingUrl( - endpoint: AdvertisedEndpoint, - credential: string, -): string { - if (endpoint.compatibility.hostedHttpsApp === "compatible") { - return ( - resolveHostedPairingUrl(endpoint.httpBaseUrl, credential) ?? - resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential) - ); - } - return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential); -} - -function resolveCurrentOriginPairingUrl(credential: string): string { - const url = new URL("/pair", window.location.href); - return setPairingTokenOnUrl(url, credential).toString(); -} - -function isHostedAppPairingUrl(value: string): boolean { - try { - const url = new URL(value); - return url.pathname === "/pair" && url.searchParams.has("host"); - } catch { - return false; - } -} - -function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string { - if (isHostedAppPairingUrl(url)) { - return "Opens the hosted app, no install needed"; - } - switch (endpoint.reachability) { - case "lan": - return "Devices on the same network"; - case "private-network": - return "Devices on your private network"; - case "public": - return "Reachable from anywhere"; - case "loopback": - return "Clients on this machine"; - } -} - type PairingLinkListRowProps = { pairingLink: ServerPairingLinkRecord; credential: string | undefined; From 263eef215de4df504235609e1daaacd637fa42c7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 01:39:29 -0700 Subject: [PATCH 014/153] refactor(web): authorized clients and endpoint rows live in their own file (#22684) Co-authored-by: Claude Opus 5.5 --- .../settings/AuthorizedClientsSettings.tsx | 992 ++++++++++++++++++ .../settings/ConnectionsSettings.tsx | 971 +---------------- vite.config.ts | 1 + 3 files changed, 1002 insertions(+), 962 deletions(-) create mode 100644 apps/web/src/components/settings/AuthorizedClientsSettings.tsx diff --git a/apps/web/src/components/settings/AuthorizedClientsSettings.tsx b/apps/web/src/components/settings/AuthorizedClientsSettings.tsx new file mode 100644 index 000000000000..9b63e35b2c95 --- /dev/null +++ b/apps/web/src/components/settings/AuthorizedClientsSettings.tsx @@ -0,0 +1,992 @@ +import { AUTH_SCOPE_OPTIONS as PAIRING_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; +import { PlusIcon, QrCodeIcon } from "lucide-react"; +import { type ReactNode, memo, useId, useState } from "react"; +import { + AuthAccessWriteScope, + AuthDiagnosticsReadScope, + AuthOrchestrationReadScope, + AuthFilesystemReadScope, + AuthStandardClientScopes, + AuthTerminalReadScope, + type AuthEnvironmentScope, + type AuthGrantScope, + type AuthPairingCredentialResult, + type AdvertisedEndpoint, +} from "@t3tools/contracts"; +import { settlePromise } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; + +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { cn } from "../../lib/utils"; +import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; +import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; +import { + canRevokeOtherClients, + isQrShareableEndpoint, + selectQrEndpointOption, + togglePairingScopeSelection, +} from "./ConnectionsSettings.logic"; +import { useRelativeTimeTick } from "./settingsLayout"; +import { Input } from "../ui/input"; +import { Checkbox } from "../ui/checkbox"; +import { + Dialog, + DialogFooter, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { QRCodeSvg } from "../ui/qr-code"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; +import { + createServerPairingCredential, + isLoopbackHostname, + type ServerClientSessionRecord, + type ServerPairingLinkRecord, +} from "~/environments/primary"; +import { readEnvironmentScope } from "~/state/session"; +import { usePrimaryEnvironmentId } from "~/state/environments"; +import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; +import { + formatAccessTimestamp, + selectPairingEndpoint, + isTailscaleHttpsEndpoint, + endpointDefaultPreferenceKey, + resolveAdvertisedEndpointPairingUrl, + resolveCurrentOriginPairingUrl, + isHostedAppPairingUrl, + endpointShareHint, +} from "./ConnectionsSettings.logic"; + +function AccessScopeSummary({ + scopes, + label, +}: { + readonly scopes: ReadonlyArray; + readonly label: string; +}) { + const scopeCountLabel = `${scopes.length} ${scopes.length === 1 ? "scope" : "scopes"}`; + + return ( + + + } + > + {scopeCountLabel} + + +

Granted scopes

+
+ {scopes.map((scope) => ( + + {scope} + + ))} +
+
+
+ ); +} + +const ENDPOINT_ROW_CLASSNAME = "first:rounded-t-xl last:rounded-b-xl px-3 py-2.5 sm:px-4"; + +export type AccessSectionPresentation = "current" | "endpoint-rail"; + +export function accessRowClassName(_presentation: AccessSectionPresentation) { + return ITEM_ROW_CLASSNAME; +} + +function endpointRowClassName(presentation: AccessSectionPresentation, isAvailable: boolean) { + if (presentation === "endpoint-rail") { + return cn( + "relative first:rounded-t-xl last:rounded-b-xl px-3 py-3 sm:px-4", + !isAvailable && "bg-muted/15", + ); + } + + return cn(ENDPOINT_ROW_CLASSNAME, !isAvailable && "bg-muted/24"); +} + +type PairingLinkListRowProps = { + pairingLink: ServerPairingLinkRecord; + credential: string | undefined; + endpointUrl: string | null | undefined; + endpoints: ReadonlyArray; + defaultEndpointKey: string | null; + presentation?: AccessSectionPresentation; + revokingPairingLinkId: string | null; + onRevoke: (id: string) => void; + canRevoke: boolean; +}; + +const PairingLinkListRow = memo(function PairingLinkListRow({ + pairingLink, + credential, + endpointUrl, + endpoints, + defaultEndpointKey, + presentation = "current", + revokingPairingLinkId, + onRevoke, + canRevoke, +}: PairingLinkListRowProps) { + const nowMs = useRelativeTimeTick(1_000); + const expiresAtMs = new Date(pairingLink.expiresAt).getTime(); + const [isRevealDialogOpen, setIsRevealDialogOpen] = useState(false); + const [isQrPanelOpen, setIsQrPanelOpen] = useState(false); + const [qrEndpointId, setQrEndpointId] = useState(null); + const qrPanelId = useId(); + + const currentOriginPairingUrl = credential ? resolveCurrentOriginPairingUrl(credential) : null; + const hostedPairingUrl = + credential && endpointUrl != null && endpointUrl !== "" + ? resolveHostedPairingUrl(endpointUrl, credential) + : null; + const endpointPairingUrl = (() => { + const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey); + return endpoint && credential + ? resolveAdvertisedEndpointPairingUrl(endpoint, credential) + : null; + })(); + const endpointCopyOptions = (() => { + const options: Array<{ + readonly id: string; + readonly preferenceKey: string; + readonly label: string; + readonly url: string; + readonly detail: string; + readonly qrShareable: boolean; + }> = []; + if (!credential) return options; + for (const endpoint of endpoints) { + if (endpoint.status === "unavailable") { + continue; + } + const url = resolveAdvertisedEndpointPairingUrl(endpoint, credential); + options.push({ + id: endpoint.id, + preferenceKey: endpointDefaultPreferenceKey(endpoint), + label: endpoint.label, + url, + detail: endpointShareHint(endpoint, url), + qrShareable: isQrShareableEndpoint(endpoint), + }); + } + return options; + })(); + const shareablePairingUrl = + endpointPairingUrl ?? + (credential && endpointUrl != null && endpointUrl !== "" + ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, credential)) + : isLoopbackHostname(window.location.hostname) + ? null + : currentOriginPairingUrl); + const [failedCopyValue, setFailedCopyValue] = useState(null); + const revealValue = failedCopyValue ?? shareablePairingUrl ?? credential ?? ""; + const isRevealValueUrl = revealValue !== credential; + const isRevealValueHostedAppPairingUrl = isRevealValueUrl && isHostedAppPairingUrl(revealValue); + const isRevealValueQrShareable = + endpointCopyOptions.find((option) => option.url === revealValue)?.qrShareable ?? true; + const canCopyToClipboard = + typeof window !== "undefined" && + window.isSecureContext && + navigator.clipboard?.writeText != null; + + const { copyToClipboard } = useCopyToClipboard<{ + value: string; + kind: "code" | "hosted-link" | "link"; + }>({ + onCopy: ({ kind }) => { + toastManager.add({ + type: "success", + title: + kind === "hosted-link" + ? "Hosted app link copied" + : kind === "link" + ? "Pairing URL copied" + : "Pairing code copied", + description: + kind === "hosted-link" + ? "Open it in the browser on the device you want to connect." + : kind === "link" + ? "Open it in the client you want to pair to this environment." + : "Paste it into another client to finish pairing.", + }); + }, + onError: (error, { value, kind }) => { + // Captured per attempt so concurrent copies cannot make the dialog + // reveal a different value than the one that failed. + setFailedCopyValue(value); + setIsRevealDialogOpen(true); + toastManager.add( + stackedThreadToast({ + type: "error", + title: canCopyToClipboard + ? kind === "hosted-link" + ? "Could not copy hosted app link" + : kind === "link" + ? "Could not copy pairing URL" + : "Could not copy pairing code" + : "Clipboard copy unavailable", + description: canCopyToClipboard ? error.message : "Showing the full value instead.", + }), + ); + }, + }); + + const copyPairingValue = (value: string, kind: "code" | "hosted-link" | "link") => { + copyToClipboard(value, { value, kind }); + }; + + const copyKindForUrl = (url: string): "hosted-link" | "link" => + isHostedAppPairingUrl(url) ? "hosted-link" : "link"; + + const handleCopyCode = () => { + if (credential) copyPairingValue(credential, "code"); + }; + + const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); + + const primaryLabel = pairingLink.label ?? "Pairing link"; + const selectedQrOption = selectQrEndpointOption( + endpointCopyOptions, + qrEndpointId, + defaultEndpointKey, + ); + const qrPairingUrl = selectedQrOption?.url ?? shareablePairingUrl; + const canRenderQrForSelection = selectedQrOption?.qrShareable ?? true; + if (expiresAtMs <= nowMs) { + return null; + } + + return ( +
+
+
+
+ +

{primaryLabel}

+
+

+ + }> + {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} + + {expiresAbsolute} + + · + +

+ {!credential ? ( +

+ Create a new link to share from this client. +

+ ) : shareablePairingUrl === null ? ( +

+ Copy the token and pair from another client using this backend's reachable host. +

+ ) : null} +
+
+ {shareablePairingUrl && canCopyToClipboard ? ( + + ) : null} + { + setIsRevealDialogOpen(open); + if (!open) setFailedCopyValue(null); + }} + > + {!credential ? null : canCopyToClipboard ? ( + shareablePairingUrl ? null : ( + + ) + ) : ( + }> + {shareablePairingUrl ? "Show link" : "Show code"} + + )} + + + + {isRevealValueUrl + ? isRevealValueHostedAppPairingUrl + ? "Hosted app pairing link" + : "Pairing link" + : "Pairing code"} + + + {isRevealValueUrl + ? isRevealValueHostedAppPairingUrl + ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect." + : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." + : "Clipboard copy is unavailable here. Manually copy this code into another client."} + + + +