diff --git a/README.md b/README.md index f2e8cdd1cc..957313766d 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,17 @@ Extend the runtime **Resident Services** **Plugin Message Bus** +Chat messages show their local date and time. A task's process expands while it +runs and folds after completion, leaving the final answer visible; expand the +elapsed-time row to revisit the work. Supplemental input sent with Alt+Enter +(steering) stays inside the same process. The queue's **Send now** action instead +ends the active turn at its next boundary and starts a new one; those turns stay +separate. Recorded workspace Write/Edit and captured shell changes appear below +the answer with a separate count/totals header and a spaced file list. +Click a file to review its expanded diffs and guarded rollback in the right +sidebar. Counts remain cumulative edits from loaded parent-session evidence, +not a net Git diff. + diff --git a/apps/desktop/src/components/Composer.tsx b/apps/desktop/src/components/Composer.tsx index 68325433c5..5fc08ebbb9 100644 --- a/apps/desktop/src/components/Composer.tsx +++ b/apps/desktop/src/components/Composer.tsx @@ -57,6 +57,7 @@ import { } from "../features/chat/composer/editor"; import { useComposerAttachments } from "../features/chat/composer/hooks/useComposerAttachments"; import { useComposerDraft } from "../features/chat/composer/hooks/useComposerDraft"; +import { useComposerDockHeight } from "../features/chat/composer/hooks/useComposerDockHeight"; import { useComposerSubmit } from "../features/chat/composer/hooks/useComposerSubmit"; import { ComposerImageAttachments } from "../features/chat/composer/ComposerImageAttachments"; import { ComposerInput } from "../features/chat/composer/ComposerInput"; @@ -146,8 +147,6 @@ export function Composer({ const [permissionOpen, setPermissionOpen] = useState(false); const enhancementInvalidateRef = useRef<() => void>(() => {}); const composerShellRef = useRef(null); - const dockRef = useRef(null); - const publishedDockHeightRef = useRef(-1); const invalidatePromptEnhancement = () => { enhancementInvalidateRef.current(); @@ -485,29 +484,7 @@ export function Composer({ ); }; - // Keep the transcript's bottom reserve in sync with the composer's real - // height (it grows with multi-line input) so the last message sits just - // above the box instead of far below it. - useEffect(() => { - const el = dockRef.current; - if (!el) return; - // Setting a custom property on documentElement invalidates style for the - // whole document, so an unchanged dock height must not be republished. - const publish = () => { - const h = Math.round(el.getBoundingClientRect().height); - if (h === publishedDockHeightRef.current) return; - publishedDockHeightRef.current = h; - document.documentElement.style.setProperty( - "--composer-dock-height", - `${h}px`, - ); - }; - publish(); - if (typeof ResizeObserver === "undefined") return; - const ro = new ResizeObserver(publish); - ro.observe(el); - return () => ro.disconnect(); - }, [variant]); + const dockRef = useComposerDockHeight(variant); return (
= { deleted: "D", }; -function DiffBody({ message, compact }: { message: UiMessage; compact: boolean }) { +function changeForSnapshot( + message: UiMessage, + snapshotId?: string, +): ReviewChange | null { + const changes = reviewChangesFromMessage(message); + return snapshotId + ? changes.find((change) => change.snapshotId === snapshotId) ?? null + : changes[0] ?? null; +} + +function DiffBody({ + message, + change, + compact, +}: { + message: UiMessage; + change: ReviewChange; + compact: boolean; +}) { const { t } = useTranslation(); - const change = reviewChangeFromMessage(message); const rollback = useAppStore((state) => state.rollbackWorkspaceChange); const [rollingBack, setRollingBack] = useState(false); const [rollbackStatus, setRollbackStatus] = useState( null, ); - if (!change) return null; - const runRollback = async () => { if (!change.reversible || change.state === "rolledBack" || rollingBack) return; setRollingBack(true); @@ -105,15 +121,22 @@ function DiffBody({ message, compact }: { message: UiMessage; compact: boolean } export const ReviewChangeCard = memo(function ReviewChangeCard({ message, + snapshotId, compact = false, + revealToken, }: { message: UiMessage; + snapshotId?: string; compact?: boolean; + revealToken?: number; }) { const { t } = useTranslation(); - const change = reviewChangeFromMessage(message); + const change = changeForSnapshot(message, snapshotId); const detailsId = useId(); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(revealToken !== undefined); + useEffect(() => { + if (revealToken !== undefined) setOpen(true); + }, [revealToken]); if (!change) return null; @@ -172,9 +195,26 @@ export const ReviewChangeCard = memo(function ReviewChangeCard({ {open ? (
- +
) : null} ); }); + +export const ReviewChangeCards = memo(function ReviewChangeCards({ + message, + compact = false, +}: { + message: UiMessage; + compact?: boolean; +}) { + return reviewChangesFromMessage(message).map((change) => ( + + )); +}); diff --git a/apps/desktop/src/components/workpanel/ReviewTab.tsx b/apps/desktop/src/components/workpanel/ReviewTab.tsx index 866c6b53e8..0f47c70168 100644 --- a/apps/desktop/src/components/workpanel/ReviewTab.tsx +++ b/apps/desktop/src/components/workpanel/ReviewTab.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { reviewChangesFromMessages, summarizeReviewChanges } from "../../lib/workspace-review"; +import { transcriptViewMessages } from "../../lib/transcript-reading"; import { useAppStore } from "../../stores/app-store"; import { IconDiff } from "../icons"; import { ReviewChangeCard } from "../ReviewChangeCard"; @@ -8,8 +9,33 @@ import { WorkTabEmpty } from "./WorkTabEmpty"; export function ReviewTab() { const { t } = useTranslation(); - const messages = useAppStore((state) => state.messages); - const entries = useMemo(() => reviewChangesFromMessages(messages), [messages]); + const liveMessages = useAppStore((state) => state.messages); + const activeSessionId = useAppStore((state) => state.activeSessionId); + const selection = useAppStore((state) => + state.activeSessionId + ? state.workPanelContexts[state.activeSessionId]?.reviewSelection + : undefined, + ); + const view = useAppStore((state) => + state.activeSessionId ? state.transcriptViews[state.activeSessionId] : undefined, + ); + const messages = useMemo( + () => selection ? transcriptViewMessages(liveMessages, view) : liveMessages, + [liveMessages, selection, view], + ); + const allEntries = useMemo( + () => reviewChangesFromMessages(messages), + [messages], + ); + const entries = useMemo(() => { + if (!selection || selection.sessionId !== activeSessionId) return allEntries; + const snapshotIds = new Set(selection.snapshotIds); + return allEntries.filter( + ({ change }) => + change.path === selection.selectedPath && + snapshotIds.has(change.snapshotId), + ); + }, [activeSessionId, allEntries, selection]); const summary = useMemo(() => summarizeReviewChanges(entries), [entries]); if (entries.length === 0) { @@ -37,7 +63,9 @@ export function ReviewTab() { ))}
diff --git a/apps/desktop/src/features/chat/composer/hooks/useComposerDockHeight.ts b/apps/desktop/src/features/chat/composer/hooks/useComposerDockHeight.ts new file mode 100644 index 0000000000..c8b6d13c47 --- /dev/null +++ b/apps/desktop/src/features/chat/composer/hooks/useComposerDockHeight.ts @@ -0,0 +1,33 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; + +/** Publish after the editor's layout effects so the reserve is ready for paint. */ +export function useComposerDockHeight(variant: "home" | "docked") { + const dockRef = useRef(null); + const publishedHeightRef = useRef(-1); + const publish = useCallback(() => { + const element = dockRef.current; + if (!element) return; + const height = Math.round(element.getBoundingClientRect().height); + // Settings retains this component beneath a hidden ancestor. Keep the last + // real reserve until it is visible again; zero is not a measured dock size. + if (height <= 0) return; + // Root custom properties invalidate document-wide styles; skip equal pixels. + if (height === publishedHeightRef.current) return; + publishedHeightRef.current = height; + document.documentElement.style.setProperty("--composer-dock-height", `${height}px`); + }, []); + + // Drafts, attachments and status rows can all resize the dock in a commit. + // Measure once per layout phase, without waiting for ResizeObserver delivery. + useLayoutEffect(publish); + + useLayoutEffect(() => { + const element = dockRef.current; + if (!element || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(publish); + observer.observe(element); + return () => observer.disconnect(); + }, [publish, variant]); + + return dockRef; +} diff --git a/apps/desktop/src/features/chat/composer/hooks/useComposerDraft.ts b/apps/desktop/src/features/chat/composer/hooks/useComposerDraft.ts index eb50e19883..dd5d079742 100644 --- a/apps/desktop/src/features/chat/composer/hooks/useComposerDraft.ts +++ b/apps/desktop/src/features/chat/composer/hooks/useComposerDraft.ts @@ -380,7 +380,9 @@ export function useComposerDraft({ commitEditorDom(); }; - useEffect(() => { + // Restore the destination before paint so editor sizing and the dock reserve + // settle in the same layout phase instead of correcting the transcript later. + useLayoutEffect(() => { const previousKey = draftKeyRef.current; if (previousKey !== draftKey) { invalidatePromptEnhancement(); diff --git a/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx b/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx index 18c1718c19..300591c0ff 100644 --- a/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx +++ b/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx @@ -45,7 +45,7 @@ import { getToolAction, getToolSummary, } from "../../../lib/tool-display"; -import { ReviewChangeCard } from "../../../components/ReviewChangeCard"; +import { ReviewChangeCards } from "../../../components/ReviewChangeCard"; import { IconChevronRight, IconCircleAlert, IconSparkles, IconWorkflow } from "../../../components/icons"; import { DisclosureCollapseRail, @@ -365,7 +365,7 @@ export const ActivityGroup = memo(function ActivityGroup({ onUserInteraction={claimDisclosure} {...(item.delegate ? { delegate: item.delegate } : {})} /> - + ); } diff --git a/apps/desktop/src/features/chat/transcript/AssistantTurn.tsx b/apps/desktop/src/features/chat/transcript/AssistantTurn.tsx index 5a474c7013..b14c6db353 100644 --- a/apps/desktop/src/features/chat/transcript/AssistantTurn.tsx +++ b/apps/desktop/src/features/chat/transcript/AssistantTurn.tsx @@ -16,6 +16,7 @@ import { assistantTurnResponseDuration, assistantTurnResponseOutputTokens, assistantTurnUsage, + assistantTurnOwnedToolsEqual, reuseReadonlyMap, subagentRunsEqual, type AssistantTurnEntry, @@ -31,7 +32,9 @@ import { projectTurnProcess, resolveThinkingDisplayMode, shouldGroupTurnProcess, + turnProcessTiming, } from "../../../lib/turn-process"; +import { formatMessageTimestamp } from "../../../lib/message-timestamp"; import { useAppStore } from "../../../stores/app-store"; import { Markdown } from "../../../components/Markdown"; import { IconBranch, IconReview } from "../../../components/icons"; @@ -49,10 +52,12 @@ import { useChatTextActions, useTranscriptMenu, } from "./TranscriptMenu"; +import { TurnFileSummary } from "./TurnFileSummary"; import { TurnProcess } from "./TurnProcess"; type AssistantTurnProps = { entry: AssistantTurnEntry; + sessionId: string | undefined; isActive: boolean; runtimeActivity?: AgentActivity; }; @@ -62,9 +67,15 @@ function assistantTurnPropsEqual( next: AssistantTurnProps, ) { if ( + previous.sessionId !== next.sessionId || previous.isActive !== next.isActive || previous.runtimeActivity !== next.runtimeActivity || + previous.entry.startedAt !== next.entry.startedAt || previous.entry.anchorId !== next.entry.anchorId || + !assistantTurnOwnedToolsEqual( + previous.entry.ownedToolMessages, + next.entry.ownedToolMessages, + ) || previous.entry.parts.length !== next.entry.parts.length ) { return false; @@ -75,6 +86,9 @@ function assistantTurnPropsEqual( if (part.kind === "message" && nextPart.kind === "message") { return part.message === nextPart.message; } + if (part.kind === "steering" && nextPart.kind === "steering") { + return part.message === nextPart.message; + } if (part.kind === "activity" && nextPart.kind === "activity") { return ( part.endedAt === nextPart.endedAt && @@ -117,8 +131,8 @@ export function transcriptEntryEqual( } if (previous.kind === "assistant-turn" && next.kind === "assistant-turn") { return assistantTurnPropsEqual( - { entry: previous, isActive: false }, - { entry: next, isActive: false }, + { entry: previous, sessionId: undefined, isActive: false }, + { entry: next, sessionId: undefined, isActive: false }, ); } return false; @@ -126,11 +140,13 @@ export function transcriptEntryEqual( export function TranscriptEntryView({ entry, + sessionId, isRunning, isActive, runtimeActivity, }: { entry: TranscriptEntry; + sessionId: string | undefined; isRunning: boolean; isActive: boolean; runtimeActivity?: AgentActivity; @@ -139,6 +155,7 @@ export function TranscriptEntryView({ return ( @@ -158,6 +175,7 @@ function transcriptEntryKey(entry: TranscriptEntry): string { type TranscriptHistoryProps = { entries: TranscriptEntry[]; + sessionId: string | undefined; isRunning: boolean; }; @@ -168,6 +186,7 @@ type TranscriptHistoryProps = { */ export const TranscriptHistory = memo(function TranscriptHistory({ entries, + sessionId, isRunning, }: TranscriptHistoryProps) { return ( @@ -175,6 +194,7 @@ export const TranscriptHistory = memo(function TranscriptHistory({ {entries.map((entry) => ( { if ( + previous.sessionId !== next.sessionId || previous.isRunning !== next.isRunning || previous.entries.length !== next.entries.length ) { @@ -196,17 +217,20 @@ export const TranscriptHistory = memo(function TranscriptHistory({ export const TranscriptTail = memo(function TranscriptTail({ entry, + sessionId, isRunning, isActive, runtimeActivity, }: { entry: TranscriptEntry; + sessionId: string | undefined; isRunning: boolean; isActive: boolean; runtimeActivity?: AgentActivity; }) { return ( ); }, (previous, next) => + previous.sessionId === next.sessionId && previous.isRunning === next.isRunning && previous.isActive === next.isActive && previous.runtimeActivity === next.runtimeActivity && @@ -222,10 +247,11 @@ export const TranscriptTail = memo(function TranscriptTail({ export const AssistantTurn = memo(function AssistantTurn({ entry, + sessionId, isActive, runtimeActivity, }: AssistantTurnProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const openTranscriptMenu = useTranscriptMenu(); const { copyText, selectText } = useChatTextActions(); const retryAssistantMessage = useAppStore((s) => s.retryAssistantMessage); @@ -256,6 +282,16 @@ export const AssistantTurn = memo(function AssistantTurn({ !isActive && !hasError && Boolean(content) && Boolean(actionMessage); const streaming = isActive && messages.some((message) => message.status === "streaming"); + const processTiming = useMemo( + () => turnProcessTiming(entry.parts, entry.startedAt), + [entry.parts, entry.startedAt], + ); + const completedTimestamp = isActive + ? undefined + : formatMessageTimestamp( + processTiming.endedAt, + i18n.resolvedLanguage ?? i18n.language, + ); /* The turn owns the menu for its whole subtree, the answer rows it renders included: Regenerate and Branch act on the turn's answer message, so a menu @@ -325,25 +361,36 @@ export const AssistantTurn = memo(function AssistantTurn({ const { process, responses } = projectTurnProcess(entry); const activePart = isActive ? entry.parts.at(-1) : undefined; - const renderPart = (part: AssistantTurnPart) => - part.kind === "activity" ? ( - - ) : ( + const renderPart = (part: AssistantTurnPart) => { + if (part.kind === "activity") { + return ( + + ); + } + if (part.kind === "steering") { + return ( + + ); + } + return (
); + }; return (
{groupProcess ? ( <> - + {process.map(renderPart)} {responses.map(renderPart)} @@ -379,9 +434,15 @@ export const AssistantTurn = memo(function AssistantTurn({ ) : ( entry.parts.map(renderPart) )} - {turnAllActivityItems.filter((item) => item.kind === "tool" && item.message.toolName === "GenerateImages").map((item) => ( - - ))} + {turnAllActivityItems + .filter( + (item) => + item.kind === "tool" && item.message.toolName === "GenerateImages", + ) + .map((item) => ( + + ))} + {!isActive ? : null} {!isActive && metaMessage ? ( + {completedTimestamp ? ( + + ) : null} - {loadingOlder ? t("chat.loadingEarlierMessages") : null} + {t("chat.loadingEarlierMessages")}
{hydrationBounded ? ( // One viewport of slack, not a per-entry estimate. The spacer exists @@ -267,10 +269,15 @@ function TranscriptBody({ // view, which is exactly the jitter this avoids.
) : null} - + {tailEntry ? ( s.editUserMessage); @@ -47,6 +50,16 @@ export const MessageRow = memo(function MessageRow({ const isSessionMessage = Boolean(message.sessionMessage); const editableUserMessage = isUser && !isSessionMessage; const workspaceRoot = useAppStore((s) => s.workspace?.path); + const userTimestamp = useMemo( + () => + isUser && !isSessionMessage + ? formatMessageTimestamp( + message.createdAt, + i18n.resolvedLanguage ?? i18n.language, + ) + : undefined, + [i18n.language, i18n.resolvedLanguage, isSessionMessage, isUser, message.createdAt], + ); const openFileRef = useOpenChatFileRef(); // Slash prompts are stored expanded; editing works on the typed form so the // resent turn re-expands the template (D123). @@ -95,6 +108,7 @@ export const MessageRow = memo(function MessageRow({ */ const onContextMenu = (event: ReactMouseEvent) => { if (!isUser) return; + if (embedded) event.stopPropagation(); openTranscriptMenu(event, { label: t("chat.messageMenu"), items: userMessageMenuItems({ @@ -121,8 +135,8 @@ export const MessageRow = memo(function MessageRow({ }; return (
) : null} - {!editing && (hasAnswer || showRevisionPager) ? ( + {!editing && (hasAnswer || showRevisionPager || userTimestamp) ? (
+ {userTimestamp ? ( + + ) : null} {showRevisionPager ? (
- ) : item.kind === "thinking" ? ( summarizeTurnFileChanges(entry), [entry]); + const [showAll, setShowAll] = useState(false); + const openTurnFileReview = useAppStore((state) => state.openTurnFileReview); + + if (summary.fileCount === 0) return null; + + const collapsible = summary.fileCount > COLLAPSE_THRESHOLD; + const visibleFiles = showAll || !collapsible + ? summary.files + : summary.files.slice(0, DEFAULT_VISIBLE_FILES); + const hiddenCount = summary.fileCount - visibleFiles.length; + + return ( +
+
+ + + +
+ {t("chat.turnFilesEdited", { count: summary.fileCount })} +
+ +{summary.additions} + −{summary.deletions} +
+
+
+ +
+ {visibleFiles.map((file) => { + const fileLabel = [ + file.path, + t("chat.reviewChangeCounts", file), + file.rolledBackOperationCount > 0 + ? `${t("panel.review.rolledBack")}: ${file.rolledBackOperationCount}` + : "", + ].filter(Boolean).join(" · "); + return ( + + ); + })} +
+ + {collapsible ? ( + + ) : null} +
+ ); +} diff --git a/apps/desktop/src/features/chat/transcript/TurnProcess.tsx b/apps/desktop/src/features/chat/transcript/TurnProcess.tsx index 7721d7a723..a507ec206e 100644 --- a/apps/desktop/src/features/chat/transcript/TurnProcess.tsx +++ b/apps/desktop/src/features/chat/transcript/TurnProcess.tsx @@ -1,5 +1,5 @@ import type { SubagentOutcome } from "../../../lib/subagent-topology"; -import { useContext, useEffect, useId, useState, type ReactNode } from "react"; +import { useContext, useEffect, useId, useMemo, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { AssistantTurnPart } from "../../../lib/assistant-turns"; import { formatToolDuration } from "../../../lib/tool-display"; @@ -10,8 +10,8 @@ import { processContainsMessage, resolveThinkingDisplayMode, shouldAutoOpenTurnProcess, - turnProcessTiming, visibleProcessSteps, + type TurnProcessTiming, } from "../../../lib/turn-process"; import { useAppStore } from "../../../stores/app-store"; import { @@ -26,42 +26,103 @@ export function TurnProcess({ turnId, processParts, turnParts, - delegationStatuses, + timing, isActive, + delegationStatuses, children, }: { turnId: string; processParts: readonly AssistantTurnPart[]; turnParts: readonly AssistantTurnPart[]; + timing: TurnProcessTiming; isActive: boolean; delegationStatuses?: ReadonlyMap; children: ReactNode; }) { - const { t } = useTranslation(); - const mode = useAppStore((state) => resolveThinkingDisplayMode(state.settings?.thinkingDisplayMode)); + const mode = useAppStore((state) => + resolveThinkingDisplayMode(state.settings?.thinkingDisplayMode), + ); const search = useContext(TranscriptSearchContext); - const revealRequest = search && processContainsMessage(processParts, search.messageId) - ? search.requestId : undefined; - const summary = activitySummary(processParts.flatMap((part) => part.kind === "activity" ? part.items : []), delegationStatuses); - const thinkingNow = isTurnThinking(turnParts, isActive); - const disclosure = useAutomaticDisclosure( - shouldAutoOpenTurnProcess(mode, isActive, summary.issues > 0), - revealRequest, - disclosureKey("turn", turnId), + const revealRequest = useMemo( + () => + search && processContainsMessage(processParts, search.messageId) + ? search.requestId + : undefined, + [processParts, search], + ); + const processItems = useMemo( + () => processParts.flatMap((part) => (part.kind === "activity" ? part.items : [])), + [processParts], + ); + const summary = useMemo( + () => activitySummary(processItems, delegationStatuses), + [processItems, delegationStatuses], + ); + const thinkingNow = useMemo( + () => isTurnThinking(turnParts, isActive), + [turnParts, isActive], + ); + const stepCount = useMemo( + () => visibleProcessSteps(processParts, mode, isActive), + [processParts, mode, isActive], ); - const detailsId = useId(); const [now, setNow] = useState(Date.now); - const { startedAt, endedAt } = turnProcessTiming(turnParts); + const { startedAt: timingStart, endedAt } = timing; useEffect(() => { if (!isActive) return; setNow(Date.now()); const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, [isActive]); - if (visibleProcessSteps(processParts, mode, isActive) === 0) return null; - const seconds = startedAt === undefined ? 0 : Math.max( - 0, Math.floor(((isActive ? now : (endedAt ?? startedAt)) - startedAt) / 1000), + if (stepCount === 0) return null; + const seconds = + timingStart === undefined + ? 0 + : Math.max( + 0, + Math.floor(((isActive ? now : (endedAt ?? timingStart)) - timingStart) / 1000), + ); + const phase = isActive ? "active" : "settled"; + return ( + 0)} + revealRequest={revealRequest} + issueCount={summary.issues} + toolCount={summary.tools} + thinkingNow={thinkingNow} + isActive={isActive} + seconds={seconds} + > + {children} + ); +} + +function TurnProcessDisclosure({ + identity, + automaticOpen, + revealRequest, + issueCount, + toolCount, + thinkingNow, + isActive, + seconds, + children, +}: { + identity: string; + automaticOpen: boolean; + revealRequest?: number; + issueCount: number; + toolCount: number; + thinkingNow: boolean; + isActive: boolean; + seconds: number; + children: ReactNode; +}) { + const { t } = useTranslation(); + const disclosure = useAutomaticDisclosure(automaticOpen, revealRequest, identity); + const detailsId = useId(); return (
{ - // Completion must not hide keyboard focus or an active text selection. - if (previousOpen.current && !open && !choice && ownsReadingPosition(bodyRef.current)) { + // Same-identity updates preserve reading ownership. A new process phase + // still closes, but returns nested keyboard focus to its visible header. + const identityChanged = previousKey.current !== key; + previousKey.current = key; + if (identityChanged && previousOpen.current && !open && bodyRef.current?.contains(document.activeElement)) { + titleRef.current?.focus({ preventScroll: true }); + } + if (previousOpen.current && !open && !choice && ownsReadingPosition(bodyRef.current) && !identityChanged) { choices.set(key, { open: true }); parent.claim(); } diff --git a/apps/desktop/src/features/chat/transcript/hooks/useTranscriptPrepend.ts b/apps/desktop/src/features/chat/transcript/hooks/useTranscriptPrepend.ts new file mode 100644 index 0000000000..da925e42af --- /dev/null +++ b/apps/desktop/src/features/chat/transcript/hooks/useTranscriptPrepend.ts @@ -0,0 +1,75 @@ +import { useCallback, useLayoutEffect, useReducer, useState, type RefObject } from "react"; +import { + createTranscriptPrependController, + type TranscriptPrependFrame, +} from "../../../../lib/transcript-scroll"; +import { growTranscriptWindow, TRANSCRIPT_WINDOW_MIN } from "../../../../lib/transcript-window"; + +/** Own the capture/request/commit lifecycle, not the store's page data. */ +export function useTranscriptPrepend({ + scrollRef, paneVisible, sessionId, searchRequestId, readingWindow, + hasMoreBefore, onLoadOlder, onPosition, +}: { + scrollRef: RefObject; + paneVisible: boolean; + sessionId: string | undefined; + searchRequestId: number | undefined; + readingWindow: boolean; + hasMoreBefore: boolean; + onLoadOlder?: () => Promise; + onPosition: (top: number) => void; +}) { + const [controller] = useState(createTranscriptPrependController); + const [windowSize, setWindowSize] = useState(TRANSCRIPT_WINDOW_MIN); + // Also advances for an immediately completed/no-op read: batching true/false + // loading state must not skip the layout that retires its unused capture. + const [revision, refresh] = useReducer((value: number) => value + 1, 0); + const invalidate = useCallback(() => { + if (controller.cancel()) refresh(); + }, [controller]); + + // A retained pane still owns its in-flight read while hidden. Navigation + // replaces that owner; visibility only suspends its geometry restoration. + useLayoutEffect(invalidate, [invalidate, readingWindow, searchRequestId, sessionId]); + useLayoutEffect(() => () => { controller.cancel(); }, [controller]); + + const reachTop = useCallback((retry = false) => { + const el = scrollRef.current; + const frame = controller.frame; + if (!paneVisible || !el || !frame) return; + const grown = readingWindow ? windowSize : growTranscriptWindow(windowSize, frame.historyLength); + if (grown !== windowSize) { + if (controller.begin("window", el)) setWindowSize(grown); + return; + } + if (!hasMoreBefore || !onLoadOlder) return; + const token = controller.begin("page", el, retry); + if (!token) return; + refresh(); + void (async () => { + let failed = false; + try { + await onLoadOlder(); + } catch (error) { + failed = true; + // The store normally reports read errors; unexpected rejecting callers + // must still be observable without leaving an unhandled rejection. + console.error("Failed to load older transcript page", error); + } finally { + if (controller.settle(token, failed)) refresh(); + } + })(); + }, [controller, hasMoreBefore, onLoadOlder, paneVisible, readingWindow, scrollRef, windowSize]); + + const commit = useCallback((frame: TranscriptPrependFrame, pinned: boolean) => { + const top = controller.commit(frame, paneVisible ? scrollRef.current : null, pinned); + if (top !== null) onPosition(top); + }, [controller, onPosition, paneVisible, scrollRef]); + + const reanchor = useCallback(() => { + const el = scrollRef.current; + if (paneVisible && el) controller.reanchor(el); + }, [controller, paneVisible, scrollRef]); + + return { windowSize, loadingOlder: controller.loading, revision, reachTop, invalidate, commit, reanchor }; +} diff --git a/apps/desktop/src/features/chat/transcript/hooks/useTranscriptProjection.ts b/apps/desktop/src/features/chat/transcript/hooks/useTranscriptProjection.ts new file mode 100644 index 0000000000..3293c8a736 --- /dev/null +++ b/apps/desktop/src/features/chat/transcript/hooks/useTranscriptProjection.ts @@ -0,0 +1,24 @@ +import { useDeferredValue, useLayoutEffect, useMemo, useRef } from "react"; +import type { ContextCompactionMark, UiMessage } from "@pi-desktop/shared"; + +/** Only streaming may lag; navigation must never regress to an older snapshot. */ +export function useTranscriptProjection( + messages: UiMessage[], + compactions: ContextCompactionMark[] | undefined, + immediate: boolean, +) { + const snapshot = useMemo( + () => [messages, compactions] as const, + [messages, compactions], + ); + const deferred = useDeferredValue(snapshot); + const awaitingDeferredRef = useRef(false); + const current = immediate || awaitingDeferredRef.current; + useLayoutEffect(() => { + // Keep a committed immediate projection until its deferred snapshot catches + // up. An urgent follow-up must not shrink the DOM and clamp scrollTop. + awaitingDeferredRef.current = current && snapshot !== deferred; + }, [current, deferred, snapshot]); + const [renderedMessages, renderedCompactions] = current ? snapshot : deferred; + return { renderedMessages, renderedCompactions }; +} diff --git a/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts b/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts index ab17de47f7..bda6daf217 100644 --- a/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts +++ b/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts @@ -1,6 +1,6 @@ import { useCallback, - useDeferredValue, + useReducer, useEffect, useLayoutEffect, useMemo, @@ -25,10 +25,8 @@ import { TRANSCRIPT_VEIL_FADE_MS, } from "../../../../lib/transcript-settle"; import { - growTranscriptWindow, reduceTranscriptWindow, TRANSCRIPT_INITIAL_MOUNT, - TRANSCRIPT_WINDOW_MIN, } from "../../../../lib/transcript-window"; import { HISTORY_REVEAL_THRESHOLD_PX, @@ -36,7 +34,6 @@ import { isRecentScrollGesture, isScrollGestureInput, reduceTranscriptScroll, - SCROLL_OWNER_ATTRIBUTE, transcriptHasLayout, TRANSCRIPT_SCROLL_ROUNDING_TOLERANCE_PX, type ScrollInputType, @@ -46,7 +43,8 @@ import { useDisclosureAnchor } from "../../../../hooks/use-disclosure-anchor"; import type { TranscriptSearchTarget } from "../../../../lib/transcript-reading"; import { useTranscriptSearchFocus } from "../../../../hooks/use-transcript-search-focus"; -import { useAppStore } from "../../../../stores/app-store"; +import { useTranscriptPrepend } from "./useTranscriptPrepend"; +import { useTranscriptProjection } from "./useTranscriptProjection"; type UseTranscriptScrollOptions = { sessionId: string | undefined; @@ -92,16 +90,14 @@ export function useTranscriptScroll({ const lastScrollGestureAtRef = useRef(-Infinity); const wasRunningRef = useRef(isRunning); const followFrameRef = useRef(0); - const prependHeightRef = useRef(null); - const [loadingOlder, setLoadingOlder] = useState(false); + const pendingLatestRef = useRef(false); + const [latestRevision, requestLatestCommit] = useReducer((revision: number) => revision + 1, 0); const [showJump, setShowJump] = useState(false); + const setPinned = useCallback((pinned: boolean) => { + pinnedRef.current = pinned; + scrollRef.current?.classList.toggle("transcript-pinned", pinned); + }, []); - // Steady-state cap on mounted history rows (D261). Grows when the user - // reaches the top of the window; reset per session below. - const [windowSize, setWindowSize] = useState(TRANSCRIPT_WINDOW_MIN); - // Read by `reachTop`, which must stay referentially stable for the scroll - // listener; the projection it describes is only known later in this render. - const historyLengthRef = useRef(0); const previousEntriesRef = useRef([]); const previousSessionIdRef = useRef(sessionId); @@ -121,11 +117,39 @@ export function useTranscriptScroll({ } }, []); + const positionAtFoldedBottom = useCallback(() => { + const el = scrollRef.current; + if (!el || !transcriptHasLayout(el)) return; + el.classList.add("transcript-measure-folded"); + try { + el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight); + } finally { + el.classList.remove("transcript-measure-folded"); + } + const settled = Math.max(0, el.scrollHeight - el.clientHeight); + if (el.scrollTop !== settled) el.scrollTop = settled; + lastScrollTopRef.current = el.scrollTop; + lastLaidOutScrollTopRef.current = el.scrollTop; + }, []); + const cancelFollowScroll = useCallback(() => { cancelAnimationFrame(followFrameRef.current); followFrameRef.current = 0; }, []); + const recordScrollPosition = useCallback((top: number) => { + lastScrollTopRef.current = top; + lastLaidOutScrollTopRef.current = top; + }, []); + const { + windowSize, loadingOlder, revision: prependRevision, reachTop, + invalidate: invalidatePrepend, commit: commitPrepend, + reanchor: reanchorPrepend, + } = useTranscriptPrepend({ + scrollRef, paneVisible, sessionId, searchRequestId: searchTarget?.requestId, + readingWindow, hasMoreBefore, onLoadOlder, onPosition: recordScrollPosition, + }); + // A manual disclosure (a tool, thinking or activity title; #324) hands this // scroller the very title it was toggled from, before the expansion state // changes. Follow mode is left first — re-bottoming the expansion is exactly @@ -133,13 +157,9 @@ export function useTranscriptScroll({ // restored from the observer below for every frame of the height transition. const enterDisclosureReading = useCallback(() => { cancelFollowScroll(); - pinnedRef.current = false; + setPinned(false); setShowJump(true); - }, [cancelFollowScroll]); - const recordScrollPosition = useCallback((top: number) => { - lastScrollTopRef.current = top; - lastLaidOutScrollTopRef.current = top; - }, []); + }, [cancelFollowScroll, setPinned]); const { notifier: disclosureAnchorNotifier, restore: restoreDisclosureAnchor, @@ -150,6 +170,13 @@ export function useTranscriptScroll({ enterDisclosureReading, recordScrollPosition, ); + const restoreHeldDisclosure = useCallback(() => { + if (!restoreDisclosureAnchor()) return false; + // The held title owns this geometry. A pending read keeps its request but + // adopts the achieved position instead of later undoing the disclosure. + reanchorPrepend(); + return true; + }, [reanchorPrepend, restoreDisclosureAnchor]); // A user scroll-up gesture always emits input before its scroll events; // programmatic follow scrolling and layout clamps (composer collapse on @@ -161,6 +188,9 @@ export function useTranscriptScroll({ // consumes belongs to that dock. const markScrollGesture = useCallback( (event: Event) => { + // The settle veil and minimap are siblings of the scroll container. + // Their wheel input cannot scroll it and must not release bottom follow. + if (!(event.target instanceof Node) || !scrollRef.current?.contains(event.target)) return; const input = readScrollInputContext( event, scrollRef.current, @@ -168,26 +198,39 @@ export function useTranscriptScroll({ ); if (!isScrollGestureInput(event.type as ScrollInputType, input)) return; lastScrollGestureAtRef.current = performance.now(); - // Real input takes the viewport back from a held disclosure position. + if (isDisclosureAnchorHeld()) reanchorPrepend(); releaseDisclosureAnchor(); + const scrollingUp = + (event instanceof WheelEvent && event.deltaY < 0) || + (event instanceof KeyboardEvent && + (event.key === "ArrowUp" || event.key === "PageUp" || event.key === "Home")) || + (event.type === "pointerdown" && event.target === scrollRef.current); + // Cancel a pending bottom pin once upward intent belongs to this scroller. + if (scrollingUp) { + cancelFollowScroll(); + setPinned(false); + } }, - [releaseDisclosureAnchor], + [cancelFollowScroll, isDisclosureAnchorHeld, reanchorPrepend, releaseDisclosureAnchor, setPinned], ); useEffect(() => { const el = wrapRef.current; if (!el) return; - el.addEventListener("wheel", markScrollGesture, { passive: true }); - el.addEventListener("touchstart", markScrollGesture, { passive: true }); - el.addEventListener("touchmove", markScrollGesture, { passive: true }); - el.addEventListener("pointerdown", markScrollGesture, { passive: true }); - el.addEventListener("keydown", markScrollGesture, { passive: true }); + // Capture phase runs before the browser applies the wheel, so follow is + // already released when that first upward gesture scrolls. + const options = { passive: true, capture: true } as const; + el.addEventListener("wheel", markScrollGesture, options); + el.addEventListener("touchstart", markScrollGesture, options); + el.addEventListener("touchmove", markScrollGesture, options); + el.addEventListener("pointerdown", markScrollGesture, options); + el.addEventListener("keydown", markScrollGesture, options); return () => { - el.removeEventListener("wheel", markScrollGesture); - el.removeEventListener("touchstart", markScrollGesture); - el.removeEventListener("touchmove", markScrollGesture); - el.removeEventListener("pointerdown", markScrollGesture); - el.removeEventListener("keydown", markScrollGesture); + el.removeEventListener("wheel", markScrollGesture, options); + el.removeEventListener("touchstart", markScrollGesture, options); + el.removeEventListener("touchmove", markScrollGesture, options); + el.removeEventListener("pointerdown", markScrollGesture, options); + el.removeEventListener("keydown", markScrollGesture, options); }; }, [markScrollGesture]); @@ -197,10 +240,10 @@ export function useTranscriptScroll({ useLayoutEffect(() => { releaseDisclosureAnchor(); cancelFollowScroll(); - pinnedRef.current = true; + setPinned(true); setShowJump(false); - scrollToBottom(); - }, [cancelFollowScroll, releaseDisclosureAnchor, scrollToBottom]); + positionAtFoldedBottom(); + }, [cancelFollowScroll, positionAtFoldedBottom, releaseDisclosureAnchor]); // Revisits restore this pane's own position. A hidden scroller can be clamped // while its content grows off screen, so the offset is captured on the way out @@ -228,15 +271,14 @@ export function useTranscriptScroll({ } if (!becameVisible) return; if (pinnedRef.current) { - scrollToBottom(); + positionAtFoldedBottom(); return; } const retained = retainedScrollTopRef.current; if (retained === null) return; el.scrollTop = retained; - lastScrollTopRef.current = retained; - lastLaidOutScrollTopRef.current = retained; - }, [cancelFollowScroll, paneVisible, releaseDisclosureAnchor, scrollToBottom]); + recordScrollPosition(el.scrollTop); + }, [cancelFollowScroll, paneVisible, positionAtFoldedBottom, recordScrollPosition, releaseDisclosureAnchor]); // A hidden pane must not chase its stream: its scroller has no visible // viewport, and the measurements a follow scroll depends on are unreliable @@ -268,71 +310,20 @@ export function useTranscriptScroll({ // restored first: its height can keep changing for several frames, and // re-pinning on any one of them is what dragged that title out of view. const followScrollNow = useCallback(() => { - if (paneVisibleRef.current && restoreDisclosureAnchor()) return; + if (paneVisibleRef.current && restoreHeldDisclosure()) return; if (!paneVisibleRef.current || !pinnedRef.current) return; cancelFollowScroll(); scrollToBottom(); - }, [cancelFollowScroll, restoreDisclosureAnchor, scrollToBottom]); + }, [cancelFollowScroll, restoreHeldDisclosure, scrollToBottom]); useEffect(() => cancelFollowScroll, [cancelFollowScroll]); - const loadOlder = useCallback(() => { - const el = scrollRef.current; - // Paging is a reading gesture, so a hidden pane never initiates one. - if (!paneVisibleRef.current) return; - if (!el || !hasMoreBefore || loadingOlder || !onLoadOlder) { - return; - } - // Prepending rows changes scrollHeight. Capture the old height so the - // user's viewport stays anchored to the same message after the page lands. - prependHeightRef.current = el.scrollHeight; - setLoadingOlder(true); - void onLoadOlder().finally(() => setLoadingOlder(false)); - }, [hasMoreBefore, loadingOlder, onLoadOlder]); - - /** - * Reaching the top escalates in two stages (D261): mount more of what is - * already loaded, and only fetch an older page once the window covers all of - * it. Both stages anchor the viewport the same way, because both change - * scrollHeight above the rows the user is reading. - */ - const reachTop = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - const grown = growTranscriptWindow(windowSize, historyLengthRef.current); - if (grown !== windowSize) { - // Mounting rows above the viewport changes scrollHeight exactly the way a - // fetched page does, so it takes the same anchor. - prependHeightRef.current = el.scrollHeight; - setWindowSize(grown); - return; - } - loadOlder(); - }, [loadOlder, windowSize]); - - // Anchors the viewport whenever rows appear above it — a fetched older page - // (`messages.length`) or a grown mounted window (`windowSize`, D261). Both add - // height above the reading position, so both are corrected here before paint. - useLayoutEffect(() => { - const previousHeight = prependHeightRef.current; - if (previousHeight === null) return; - const el = scrollRef.current; - prependHeightRef.current = null; - if (!el) return; - const delta = el.scrollHeight - previousHeight; - if (delta <= 0) return; - el.scrollTop += delta; - lastScrollTopRef.current = el.scrollTop; - if (transcriptHasLayout(el)) lastLaidOutScrollTopRef.current = el.scrollTop; - }, [messages.length, windowSize]); - - - // Follow the stream only while the user is pinned to the bottom; a manual // scroll up pauses following and surfaces the jump-to-latest pill. const handleScroll = useCallback(() => { const el = scrollRef.current; - if (!el) return; + if (!el || !paneVisibleRef.current || !transcriptHasLayout(el)) return; + lastLaidOutScrollTopRef.current = el.scrollTop; // A real gesture is never stale noise: the reader's own input took the // scroller to the near-top band, and this event is the last one that // position produces. Suppressing history continuation here would strand an @@ -348,10 +339,10 @@ export function useTranscriptScroll({ paneVisibleRef.current && isHistoryRevealPosition(el, pinnedRef.current && !gesturing) ) { - reachTop(); + reachTop(gesturing); } if (readingWindow) { - pinnedRef.current = false; + setPinned(false); lastScrollTopRef.current = el.scrollTop; setShowJump(true); return; @@ -379,18 +370,18 @@ export function useTranscriptScroll({ lastScrollTopRef.current = el.scrollTop; if (transition.releasedFollow) cancelFollowScroll(); if (gesturing && transition.releasedFollow) { - pinnedRef.current = false; + setPinned(false); setShowJump(true); } else if (transition.releasedFollow) { // Programmatic / layout noise: re-baseline the observed position and // keep the follow state unchanged instead of treating it as a user // gesture. A pinned transcript re-asserts the bottom; an unpinned one // stays unpinned. - pinnedRef.current = wasPinned; + setPinned(wasPinned); setShowJump(!wasPinned); scheduleFollowScroll(); } else { - pinnedRef.current = transition.pinned; + setPinned(transition.pinned); setShowJump(transition.showJump); } }, [cancelFollowScroll, reachTop, readingWindow, scheduleFollowScroll]); @@ -403,15 +394,17 @@ export function useTranscriptScroll({ useLayoutEffect(() => { const turnStarted = isRunning && !wasRunningRef.current; wasRunningRef.current = isRunning; + if (turnStarted) invalidatePrepend(); if (!turnStarted || !paneVisible) return; releaseDisclosureAnchor(); cancelFollowScroll(); - pinnedRef.current = true; + setPinned(true); setShowJump(false); scrollToBottom(); scheduleFollowScroll(); }, [ cancelFollowScroll, + invalidatePrepend, isRunning, paneVisible, releaseDisclosureAnchor, @@ -459,13 +452,11 @@ export function useTranscriptScroll({ // not let useDeferredValue reveal its previous frame first; the reveal itself // is a navigation boundary and must paint the snapshot selected for it. const paneRevealed = paneVisible && !wasPaneVisibleRef.current; - const deferredMessages = useDeferredValue(messages); - const deferredCompactions = useDeferredValue(compactions); - const renderedMessages = - readingWindow || firstCommit || paneRevealed ? messages : deferredMessages; - const renderedCompactions = - firstCommit || paneRevealed ? compactions : deferredCompactions; - const { entries, visible } = useMemo(() => { + const { renderedMessages, renderedCompactions } = useTranscriptProjection( + messages, compactions, + !isRunning || readingWindow || firstCommit || paneRevealed || pendingLatestRef.current, + ); + const entries = useMemo(() => { if (previousSessionIdRef.current !== sessionId) { previousSessionIdRef.current = sessionId; previousEntriesRef.current = []; @@ -473,16 +464,13 @@ export function useTranscriptScroll({ const built = buildTranscriptEntries(renderedMessages, renderedCompactions); const entries = reuseTranscriptEntries(previousEntriesRef.current, built.entries); previousEntriesRef.current = entries; - return { entries, visible: built.visible }; + return entries; }, [renderedMessages, renderedCompactions, sessionId]); // Memoized so a re-render that changed no message (jump pill, loading row, // window growth) hands `TranscriptHistory` the same array, letting its // comparator bail on identity instead of walking every mounted row. const allHistoryEntries = useMemo(() => entries.slice(0, -1), [entries]); const tailEntry = entries.at(-1); - // Published for `reachTop`, which is declared above this projection but only - // runs from a scroll event, long after this render committed. - historyLengthRef.current = allHistoryEntries.length; // Progressive hydration, now scoped to this pane's own first commit // (ADR 0137): mount only the bottom portion of the transcript when the pane @@ -562,12 +550,18 @@ export function useTranscriptScroll({ ); const releaseSearchFollow = useCallback((fresh: boolean) => { - if (fresh) prependHeightRef.current = null; + if (fresh) invalidatePrepend(); releaseDisclosureAnchor(); cancelFollowScroll(); - pinnedRef.current = false; + setPinned(false); setShowJump(true); - }, [cancelFollowScroll, releaseDisclosureAnchor]); + }, [cancelFollowScroll, invalidatePrepend, releaseDisclosureAnchor, setPinned]); + const recordSearchPosition = useCallback((top: number) => { + // While search actively aligns its target, it owns the correction. Once a + // reading gesture ends that alignment, ordinary prepend anchoring resumes. + invalidatePrepend(); + recordScrollPosition(top); + }, [invalidatePrepend, recordScrollPosition]); useTranscriptSearchFocus({ target: searchTarget, source: messages.find((message) => message.id === searchTarget?.messageId)?.content ?? "", @@ -576,9 +570,20 @@ export function useTranscriptScroll({ contentRef, contentVersion: historyEntries, onNavigate: releaseSearchFollow, - onPosition: recordScrollPosition, + onPosition: recordSearchPosition, }); + // The raw page may lead its deferred DOM by a commit. Only the rendered + // prefix/window can consume its capture, after search/navigation has priority. + useLayoutEffect(() => { + if (paneVisibleRef.current) restoreHeldDisclosure(); + commitPrepend({ + messages, renderedMessages, windowSize, + historyLength: allHistoryEntries.length, + mountedCount: historyEntries.length, + }, pinnedRef.current); + }, [allHistoryEntries.length, commitPrepend, historyEntries, messages, prependRevision, renderedMessages, restoreHeldDisclosure, windowSize]); + // Runs in the same layout phase the expansion commits in, before the browser // paints it, so mounting the remaining history cannot move the rows the user // is already looking at. A user who scrolled up during the bounded frame keeps @@ -588,8 +593,8 @@ export function useTranscriptScroll({ boundedFirstCommitRef.current = false; if (!pinnedRef.current) return; cancelFollowScroll(); - scrollToBottom(); - }, [cancelFollowScroll, hydrationBounded, hydrationTick, scrollToBottom]); + positionAtFoldedBottom(); + }, [cancelFollowScroll, hydrationBounded, hydrationTick, positionAtFoldedBottom]); // Sample the scroller once per frame from the expansion commit onward and // lift the veil once the geometry has stopped moving. The bounded commit @@ -631,31 +636,35 @@ export function useTranscriptScroll({ return () => window.clearTimeout(timer); }, [veilPhase]); - // The minimap must describe the mounted rows, not every loaded message: it - // resolves a click by looking up the marker's node in the scroller, so a dash - // for a withheld row would jump nowhere (D261). + // Both bounded and fully mounted transcripts use the projected entries so + // every marker has a visible anchor. Embedded steering stays inside its turn + // process and is therefore omitted, while a standalone leading steering is + // still a top-level message entry. const minimapMessages = useMemo( () => - transcriptWindow.bounded - ? transcriptEntryMessages( - tailEntry ? [...historyEntries, tailEntry] : historyEntries, - ) - : visible, - [historyEntries, tailEntry, transcriptWindow.bounded, visible], + transcriptEntryMessages( + tailEntry ? [...historyEntries, tailEntry] : historyEntries, + ), + [historyEntries, tailEntry], ); const hasEarlierHistory = transcriptWindow.hiddenAbove > 0 || hasMoreBefore; + // Only an unread older page needs tail alignment. `hiddenAbove` is already + // loaded content withheld by the bounded mount window; it must not make a + // completed short conversation look like a partial server history tail. + const alignHistoryTail = !readingWindow && hasMoreBefore; const revealEarlierHistory = useCallback(() => { const el = scrollRef.current; if (!el) return; if (isHistoryRevealPosition(el)) { - reachTop(); + reachTop(true); return; } + invalidatePrepend(); releaseDisclosureAnchor(); cancelFollowScroll(); - pinnedRef.current = false; + setPinned(false); setShowJump(true); const reduceMotion = window.matchMedia( "(prefers-reduced-motion: reduce)", @@ -664,18 +673,29 @@ export function useTranscriptScroll({ top: 0, behavior: reduceMotion ? "auto" : "smooth", }); - }, [cancelFollowScroll, reachTop, releaseDisclosureAnchor]); - + }, [cancelFollowScroll, invalidatePrepend, reachTop, releaseDisclosureAnchor, setPinned]); const jumpToLatest = useCallback(() => { + invalidatePrepend(); releaseDisclosureAnchor(); - pinnedRef.current = true; + setPinned(true); + cancelFollowScroll(); + lastScrollGestureAtRef.current = -Infinity; + pendingLatestRef.current = true; + requestLatestCommit(); setShowJump(false); - scrollToBottom( - window.matchMedia("(prefers-reduced-motion: reduce)").matches - ? "auto" - : "smooth", - ); - }, [releaseDisclosureAnchor, scrollToBottom]); + positionAtFoldedBottom(); + }, [cancelFollowScroll, invalidatePrepend, positionAtFoldedBottom, releaseDisclosureAnchor, setPinned]); + + // Pin the DOM projection that actually committed, including asynchronous + // session hydration/revalidation. Raw messages can lead deferred rows by a + // commit; a queued follow frame against that old DOM is not navigation. + useLayoutEffect(() => { + if (renderedMessages === messages && !readingWindow) pendingLatestRef.current = false; + if (!paneVisible || !pinnedRef.current || readingWindow || isDisclosureAnchorHeld()) return; + cancelFollowScroll(); + positionAtFoldedBottom(); + }, [cancelFollowScroll, isDisclosureAnchorHeld, latestRevision, messages, paneVisible, + positionAtFoldedBottom, readingWindow, renderedCompactions, renderedMessages]); // D269: history progression follows the visible top boundary, not only a // native scroll event. A tail page can collapse to less than one viewport, @@ -740,7 +760,7 @@ export function useTranscriptScroll({ hasEarlierHistory, hydrationTick, loadingOlder, - messages.length, + renderedMessages, paneVisible, reachTop, sessionId, @@ -757,6 +777,7 @@ export function useTranscriptScroll({ tailEntry, minimapMessages, hasEarlierHistory, + alignHistoryTail, hydrationBounded, veilCovering, veilPhase, diff --git a/apps/desktop/src/lib/assistant-turns.ts b/apps/desktop/src/lib/assistant-turns.ts index 60cd553740..ab3240d68a 100644 --- a/apps/desktop/src/lib/assistant-turns.ts +++ b/apps/desktop/src/lib/assistant-turns.ts @@ -44,6 +44,7 @@ export type SubagentRun = { export type AssistantTurnPart = | { kind: "message"; message: UiMessage } + | { kind: "steering"; message: UiMessage } | { kind: "activity"; items: AssistantActivityItem[]; @@ -55,8 +56,11 @@ export type AssistantTurnEntry = { id: string; anchorId?: string; parts: AssistantTurnPart[]; + /** Timestamp of the user message that initiated this turn, when loaded. */ + startedAt?: string; + /** Raw tool rows owned by this turn, including original Task delegate children. */ + ownedToolMessages: UiMessage[]; }; - export type TranscriptEntry = | { kind: "message"; message: UiMessage } | { kind: "compaction"; mark: ContextCompactionMark } @@ -200,14 +204,18 @@ export function buildTranscriptEntries( } const entries: TranscriptEntry[] = []; let turn: AssistantTurnEntry | undefined; - + let turnStartedAt: string | undefined; + let canStartTurnFromUser = false; const ensureTurn = (message: UiMessage) => { if (turn) return turn; turn = { kind: "assistant-turn", id: message.id, parts: [], + ...(turnStartedAt ? { startedAt: turnStartedAt } : {}), + ownedToolMessages: [], }; + canStartTurnFromUser = false; entries.push(turn); return turn; }; @@ -229,8 +237,23 @@ export function buildTranscriptEntries( }; const appendMessage = (message: UiMessage) => { + if (message.role === "user" && message.steering === true) { + if (turn || canStartTurnFromUser) { + ensureTurn(message).parts.push({ kind: "steering", message }); + return; + } + // A paged transcript can begin after the active turn's earlier rows. Do + // not infer that missing parent; keep the supplemental row visible as a + // normal user boundary and let later output form a new loaded turn. + turnStartedAt = message.createdAt; + canStartTurnFromUser = true; + entries.push({ kind: "message", message }); + return; + } if (message.role === "user" || message.role === "system") { turn = undefined; + turnStartedAt = message.role === "user" ? message.createdAt : undefined; + canStartTurnFromUser = message.role === "user"; entries.push({ kind: "message", message }); return; } @@ -268,9 +291,40 @@ export function buildTranscriptEntries( // The row is a divider, so whatever turn it lands inside ends there and the // next assistant fragment opens a new one. turn = undefined; + turnStartedAt = undefined; + canStartTurnFromUser = false; for (const mark of marks) entries.push({ kind: "compaction", mark }); } + // File evidence follows the original Task call, independently of the visual + // resume-chain projection above. Iterating raw messages preserves persisted + // session order even when parent and delegate tools were interleaved. + const directToolOwner = new Map(); + const taskOwner = new Map(); + for (const entry of entries) { + if (entry.kind !== "assistant-turn") continue; + for (const part of entry.parts) { + if (part.kind !== "activity") continue; + for (const item of part.items) { + if (item.kind !== "tool") continue; + directToolOwner.set(item.message.id, entry); + if ( + item.message.toolCallId && + isDelegationStartTool(item.message.toolName) + ) { + taskOwner.set(item.message.toolCallId, entry); + } + } + } + } + for (const message of messages) { + if (message.role !== "tool") continue; + const owner = message.parentToolCallId + ? taskOwner.get(message.parentToolCallId) + : directToolOwner.get(message.id); + owner?.ownedToolMessages.push(message); + } + for (const entry of entries) { if (entry.kind !== "assistant-turn") continue; for (let index = 0; index < entry.parts.length; index += 1) { @@ -278,7 +332,7 @@ export function buildTranscriptEntries( const next = entry.parts[index + 1]; if ( part.kind === "activity" && - next?.kind === "message" && + (next?.kind === "message" || next?.kind === "steering") && !part.items.some((item) => item.message.id === next.message.id) ) { part.endedAt = next.message.createdAt; @@ -356,6 +410,24 @@ function reuseActivityItem( return previous; } +export function assistantTurnOwnedToolsEqual( + previous: readonly UiMessage[], + next: readonly UiMessage[], +): boolean { + return ( + previous.length === next.length && + next.every((message, index) => message === previous[index]) + ); +} + +function reuseMessageList( + previous: readonly UiMessage[], + next: UiMessage[], +): UiMessage[] { + return assistantTurnOwnedToolsEqual(previous, next) + ? (previous as UiMessage[]) + : next; +} function reuseTurnPart( previous: AssistantTurnPart | undefined, next: AssistantTurnPart, @@ -364,6 +436,9 @@ function reuseTurnPart( if (previous.kind === "message" && next.kind === "message") { return previous.message === next.message ? previous : next; } + if (previous.kind === "steering" && next.kind === "steering") { + return previous.message === next.message ? previous : next; + } if (previous.kind === "activity" && next.kind === "activity") { if (previous.endedAt !== next.endedAt) { const items = next.items.map((item, index) => @@ -409,14 +484,20 @@ function reuseTranscriptEntry( const parts = next.parts.map((part, index) => reuseTurnPart(previous.parts[index], part), ); + const ownedToolMessages = reuseMessageList( + previous.ownedToolMessages, + next.ownedToolMessages, + ); if ( previous.anchorId === next.anchorId && + previous.startedAt === next.startedAt && parts.length === previous.parts.length && - parts.every((part, index) => part === previous.parts[index]) + parts.every((part, index) => part === previous.parts[index]) && + ownedToolMessages === previous.ownedToolMessages ) { return previous; } - return { ...next, parts }; + return { ...next, parts, ownedToolMessages }; } return next; } @@ -451,20 +532,24 @@ export function assistantTurnMessages( } /** - * The rows these entries actually render, in transcript order. + * The messages these entries expose to the minimap, in transcript order. * * The minimap resolves a click by finding the marker's `data-minimap-id` node in * the scroller, so it must be built from the mounted entries rather than from - * every loaded message (D261). Fed the full set while the transcript window - * withholds older rows, it would draw dashes whose click target does not exist - * and jump nowhere. + * every loaded message (D261). Embedded steering renders inside the turn process + * without its own anchor; a standalone leading steering remains a top-level + * message entry and is included. */ export function transcriptEntryMessages( entries: readonly TranscriptEntry[], ): UiMessage[] { return entries.flatMap((entry) => { if (entry.kind === "message") return [entry.message]; - if (entry.kind === "assistant-turn") return assistantTurnMessages(entry); + if (entry.kind === "assistant-turn") { + return entry.parts.flatMap((part) => + part.kind === "message" ? [part.message] : [], + ); + } return []; }); } diff --git a/apps/desktop/src/lib/message-timestamp.ts b/apps/desktop/src/lib/message-timestamp.ts new file mode 100644 index 0000000000..754944b838 --- /dev/null +++ b/apps/desktop/src/lib/message-timestamp.ts @@ -0,0 +1,18 @@ +export type MessageTimestamp = { + dateTime: string; + label: string; +}; + +export function formatMessageTimestamp( + value: string | number | undefined, + locale?: string, +): MessageTimestamp | undefined { + if (value === undefined || value === "") return undefined; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return undefined; + const label = new Intl.DateTimeFormat(locale || undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); + return { dateTime: date.toISOString(), label }; +} diff --git a/apps/desktop/src/lib/pointer-outside.ts b/apps/desktop/src/lib/pointer-outside.ts new file mode 100644 index 0000000000..92ed0a3553 --- /dev/null +++ b/apps/desktop/src/lib/pointer-outside.ts @@ -0,0 +1,84 @@ +/** + * Chromium can keep `:hover` on a message after the pointer leaves the + * window, so hover-only chrome (timestamps, copy chips) stays painted. + * Mark `` when the pointer is outside; CSS then hides that chrome. + * + * `mouseleave` on `` is not enough on Electron/Windows: the event + * is often skipped while `:hover` remains. `mouseout` with a null + * `relatedTarget` is the signal that the pointer left the document. + */ + +export const POINTER_OUTSIDE_CLASS = "pointer-outside"; + +interface ClassListLike { + contains(name: string): boolean; + add(name: string): void; + remove(name: string): void; +} + +type PointerOutsideListener = (event?: Event) => void; + +interface ViewLike { + addEventListener(type: string, listener: PointerOutsideListener): void; + removeEventListener(type: string, listener: PointerOutsideListener): void; +} + +export interface PointerOutsideRoot { + hidden?: boolean; + documentElement: { + classList: ClassListLike; + }; + defaultView?: ViewLike | null; + addEventListener(type: string, listener: PointerOutsideListener): void; + removeEventListener(type: string, listener: PointerOutsideListener): void; +} + +function leftTheWindow(event?: Event): boolean { + if (event == null) return true; + const mouse = event as Event & { + relatedTarget?: EventTarget | null; + toElement?: EventTarget | null; + }; + return mouse.relatedTarget == null && mouse.toElement == null; +} + +export function installPointerOutside(root: PointerOutsideRoot): () => void { + const html = root.documentElement; + const leave = () => { + if (!html.classList.contains(POINTER_OUTSIDE_CLASS)) { + html.classList.add(POINTER_OUTSIDE_CLASS); + } + }; + const enter = () => { + if (html.classList.contains(POINTER_OUTSIDE_CLASS)) { + html.classList.remove(POINTER_OUTSIDE_CLASS); + } + }; + const onVisibility = () => { + if (root.hidden) leave(); + }; + const onMouseOut = (event?: Event) => { + if (!leftTheWindow(event)) return; + leave(); + }; + const view = root.defaultView; + root.addEventListener("mouseout", onMouseOut); + root.addEventListener("mouseover", enter); + root.addEventListener("mouseleave", leave); + root.addEventListener("mouseenter", enter); + root.addEventListener("pointerleave", leave); + root.addEventListener("pointerenter", enter); + view?.addEventListener("blur", leave); + root.addEventListener("visibilitychange", onVisibility); + return () => { + root.removeEventListener("mouseout", onMouseOut); + root.removeEventListener("mouseover", enter); + root.removeEventListener("mouseleave", leave); + root.removeEventListener("mouseenter", enter); + root.removeEventListener("pointerleave", leave); + root.removeEventListener("pointerenter", enter); + view?.removeEventListener("blur", leave); + root.removeEventListener("visibilitychange", onVisibility); + enter(); + }; +} diff --git a/apps/desktop/src/lib/tool-presentation.ts b/apps/desktop/src/lib/tool-presentation.ts index b7ce974fcf..e7ceb1818e 100644 --- a/apps/desktop/src/lib/tool-presentation.ts +++ b/apps/desktop/src/lib/tool-presentation.ts @@ -30,8 +30,8 @@ const MAX_DIFF_LINES = 400; const DIFF_CONTEXT_LINES = 2; /** Longer single-line strings become their own block instead of a field row. */ const MAX_FIELD_VALUE = 120; -/** Internal review snapshots are rendered by ReviewChangeCard, not as fields. */ -const HIDDEN_KEYS = new Set(["review"]); +/** Internal review evidence is rendered by ReviewChangeCard, not as fields. */ +const HIDDEN_KEYS = new Set(["review", "reviews", "reviewCapture"]); export type ToolPresentationMessage = { role?: string; diff --git a/apps/desktop/src/lib/transcript-scroll.ts b/apps/desktop/src/lib/transcript-scroll.ts index dc8db730f8..3e60cd509d 100644 --- a/apps/desktop/src/lib/transcript-scroll.ts +++ b/apps/desktop/src/lib/transcript-scroll.ts @@ -201,3 +201,157 @@ export function isScrollGestureInput( } return true; } + +export type PrependAnchor = { + node: Element; + messageId: string | null; + /** Row offset inside the viewport, not the browser window. */ + top: number; + scrollTop: number; +}; + +export function capturePrependAnchor(scroller: HTMLElement): PrependAnchor | null { + const viewportTop = scroller.getBoundingClientRect().top; + const viewportBottom = viewportTop + scroller.clientHeight; + // Prefer message content over a grouped turn wrapper: loading the earlier + // half of a turn can re-key that wrapper while retaining the message identity. + for (const selector of ["[data-message-id]", ".message-row, .tool-activity-group"]) { + for (const row of scroller.querySelectorAll(selector)) { + const owner = row.closest(`[${SCROLL_OWNER_ATTRIBUTE}]`); + if (owner && owner !== scroller) continue; + const rect = row.getBoundingClientRect(); + if (rect.bottom > viewportTop + 1 && rect.top < viewportBottom) { + return { + node: row, messageId: row.getAttribute("data-message-id"), + top: rect.top - viewportTop, scrollTop: scroller.scrollTop, + }; + } + } + } + return null; +} + +/** Correct insertion in content coordinates, preserving scrolling during the read. */ +export function prependAnchorShift( + anchor: Pick, + actualTop: number, + scrollTop: number, +): number { + return actualTop + scrollTop - (anchor.top + anchor.scrollTop); +} + +type MessageIdentity = { id: string }; +export type TranscriptPrependFrame = { + messages: readonly MessageIdentity[]; + renderedMessages: readonly MessageIdentity[]; + windowSize: number; + historyLength: number; + mountedCount: number; +}; +type PrependRequest = { + kind: "page" | "window"; + before: TranscriptPrependFrame; + anchor: PrependAnchor | null; + applied: boolean; + settled: boolean; +}; + +function hasPrepended(before: readonly MessageIdentity[], after: readonly MessageIdentity[]) { + if (after.length <= before.length) return false; + return before.length === 0 || after.findIndex((message) => message.id === before[0].id) > 0; +} + +/** One prepend owns one capture, its async completion, and its actual DOM commit. */ +export function createTranscriptPrependController() { + let frame: TranscriptPrependFrame | null = null; + let request: PrependRequest | null = null; + // Avoid an observer retry loop after an empty/failed read at the same edge. + let blockedBefore: string | null | undefined; + return { + get frame() { return frame; }, + get loading() { return request?.kind === "page" && !request.settled; }, + begin(kind: PrependRequest["kind"], scroller: HTMLElement, retry = false): PrependRequest | null { + if (request || !frame || !transcriptHasLayout(scroller)) return null; + // Do not attribute a previously loaded, still-deferred prefix to a new read. + if (frame.messages[0]?.id !== frame.renderedMessages[0]?.id) return null; + if (kind === "page" && !retry && blockedBefore === (frame.messages[0]?.id ?? null)) return null; + request = { + kind, before: frame, anchor: capturePrependAnchor(scroller), + applied: false, settled: kind === "window", + }; + return request; + }, + settle(token: PrependRequest, failed = false): boolean { + if (request !== token) return false; + token.settled = true; + if (failed) blockedBefore = token.before.messages[0]?.id ?? null; + if (failed || token.applied) request = null; + return true; + }, + cancel() { + const pending = request !== null; + request = null; + blockedBefore = undefined; + return pending; + }, + /** Adopt another reading owner's achieved geometry without replacing its RPC. */ + reanchor(scroller: HTMLElement) { + if (!request || request.applied || !transcriptHasLayout(scroller)) return; + const anchor = request.anchor; + // ResizeObserver handoffs normally update one existing node, not every + // mounted row. Only a removed anchor needs a new visible-row search. + request.anchor = anchor?.node.isConnected && scroller.contains(anchor.node) + ? { + ...anchor, + top: anchor.node.getBoundingClientRect().top - scroller.getBoundingClientRect().top, + scrollTop: scroller.scrollTop, + } + : capturePrependAnchor(scroller); + }, + /** Called only in layout, with the projection that just mounted. */ + commit(next: TranscriptPrependFrame, scroller: HTMLElement | null, pinned: boolean): number | null { + frame = next; + const pending = request; + if (!pending || pending.applied) return null; + const ready = pending.kind === "window" + ? next.windowSize !== pending.before.windowSize + : hasPrepended(pending.before.messages, next.renderedMessages); + if (!ready) { + // A completed no-op (including a store-handled failure) cannot leave a + // capture for an unrelated tail append or a later read to consume. + if (pending.settled && pending.kind === "page" && !hasPrepended(pending.before.messages, next.messages)) { + request = null; + blockedBefore = pending.before.messages[0]?.id ?? null; + } + return null; + } + // Hidden panes may commit the loaded page without a measurable viewport. + // Keep ownership until reveal restores the last visible scroll offset. + if (!scroller || !transcriptHasLayout(scroller)) return null; + pending.applied = true; + if (pending.settled) request = null; + const anchor = pending.anchor; + pending.anchor = null; + if (pending.kind === "window" && next.mountedCount <= pending.before.mountedCount) return null; + if (pinned) { + // An underfilled entry may page automatically without surrendering follow. + scroller.scrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + } else if (anchor) { + const node = anchor.node.isConnected && scroller.contains(anchor.node) + ? anchor.node + : anchor.messageId === null ? undefined + : [...scroller.querySelectorAll("[data-message-id]")].find( + (row) => row.getAttribute("data-message-id") === anchor.messageId, + ); + if (node) { + const top = node.getBoundingClientRect().top - scroller.getBoundingClientRect().top; + const shift = prependAnchorShift(anchor, top, scroller.scrollTop); + if (shift !== 0) scroller.scrollTop += shift; + } + } + // Never infer insertion from total scrollHeight: a concurrent tail can grow + // while the loaded page is still outside the mounted window. + return scroller.scrollTop; + }, + }; +} diff --git a/apps/desktop/src/lib/turn-file-summary.ts b/apps/desktop/src/lib/turn-file-summary.ts new file mode 100644 index 0000000000..7c16ae3cb6 --- /dev/null +++ b/apps/desktop/src/lib/turn-file-summary.ts @@ -0,0 +1,110 @@ +import type { AssistantTurnEntry } from "./assistant-turns"; +import { + reviewCaptureFromMessage, + reviewChangesMetadataFromMessage, + type ReviewChangeMetadataEntry, +} from "./workspace-review"; + +export type TurnFileGroup = { + path: string; + entries: ReviewChangeMetadataEntry[]; + activeOperationCount: number; + rolledBackOperationCount: number; + additions: number; + deletions: number; +}; + +export type TurnFileSummaryData = { + files: TurnFileGroup[]; + fileCount: number; + operationCount: number; + activeOperationCount: number; + rolledBackOperationCount: number; + additions: number; + deletions: number; + hasBashTool: boolean; + hasCompleteCapture: boolean; + hasPartialCapture: boolean; + hasUnavailableCapture: boolean; +}; + +type IndexedReviewChange = ReviewChangeMetadataEntry & { index: number }; + + +/** Project durable workspace evidence owned by one visual assistant turn. */ +export function summarizeTurnFileChanges( + entry: AssistantTurnEntry, +): TurnFileSummaryData { + const tools = entry.ownedToolMessages; + const latestBySnapshot = new Map(); + let sequence = 0; + + for (const message of tools) { + for (const change of reviewChangesMetadataFromMessage(message)) { + latestBySnapshot.set(change.snapshotId, { + message, + change, + index: sequence, + }); + sequence += 1; + } + } + + const operations = [...latestBySnapshot.values()].sort( + (left, right) => right.index - left.index, + ); + const filesByPath = new Map(); + + for (const operation of operations) { + let file = filesByPath.get(operation.change.path); + if (!file) { + file = { + path: operation.change.path, + entries: [], + activeOperationCount: 0, + rolledBackOperationCount: 0, + additions: 0, + deletions: 0, + }; + filesByPath.set(operation.change.path, file); + } + file.entries.push({ message: operation.message, change: operation.change }); + if (operation.change.state === "rolledBack") { + file.rolledBackOperationCount += 1; + } else { + file.activeOperationCount += 1; + file.additions += operation.change.additions; + file.deletions += operation.change.deletions; + } + } + + const captures = tools + .map(reviewCaptureFromMessage) + .filter((status) => status !== null); + const files = [...filesByPath.values()]; + return files.reduce( + (summary, file) => { + summary.files.push(file); + summary.fileCount += 1; + summary.operationCount += file.entries.length; + summary.activeOperationCount += file.activeOperationCount; + summary.rolledBackOperationCount += file.rolledBackOperationCount; + summary.additions += file.additions; + summary.deletions += file.deletions; + return summary; + }, + { + files: [], + fileCount: 0, + operationCount: 0, + activeOperationCount: 0, + rolledBackOperationCount: 0, + additions: 0, + deletions: 0, + hasBashTool: captures.length > 0, + hasCompleteCapture: captures.includes("complete"), + hasPartialCapture: captures.includes("partial"), + hasUnavailableCapture: captures.includes("unavailable"), + }, + ); +} diff --git a/apps/desktop/src/lib/turn-process.ts b/apps/desktop/src/lib/turn-process.ts index d37b1066bd..d60b7ca5a7 100644 --- a/apps/desktop/src/lib/turn-process.ts +++ b/apps/desktop/src/lib/turn-process.ts @@ -30,7 +30,9 @@ export function processContainsMessage( messageId: string, ): boolean { return parts.some((part) => { - if (part.kind === "message") return part.message.id === messageId; + if (part.kind === "message" || part.kind === "steering") { + return part.message.id === messageId; + } return part.items.some((item) => { if (item.message.id === messageId) return true; return ( @@ -49,9 +51,9 @@ export function hasFailedProcessTool(parts: readonly AssistantTurnPart[]): boole ); } -/** Both presentation modes expose the same process hierarchy. */ -export function shouldGroupTurnProcess(mode: ThinkingDisplayMode): boolean { - return mode === "detailed" || mode === "compact"; +/** Every turn uses one process disclosure; the mode only filters its steps. */ +export function shouldGroupTurnProcess(_mode: ThinkingDisplayMode): boolean { + return true; } /** The last activity chunk of a turn owns detailed-mode's default-open tool. */ @@ -66,13 +68,13 @@ export function isLastActivityPart( return false; } -/** Detailed keeps narration visible; compact reveals active failures only. */ +/** Active work stays visible; every completed process defaults to collapsed. */ export function shouldAutoOpenTurnProcess( - mode: ThinkingDisplayMode, + _mode: ThinkingDisplayMode, isActive: boolean, - hasToolFailure: boolean, + _hasToolFailure: boolean, ): boolean { - return mode === "detailed" || (isActive && hasToolFailure); + return isActive; } /** @@ -90,9 +92,9 @@ export function projectTurnProcess(entry: AssistantTurnEntry) { for (const part of entry.parts) { if (part.kind === "message" && (part === answer || part.message.error)) { responses.push(part); - } else { - process.push(part); + continue; } + process.push(part); } return { process, responses }; } @@ -108,6 +110,10 @@ export function visibleProcessSteps( if (part.message.content.trim()) count += 1; continue; } + if (part.kind === "steering") { + count += 1; + continue; + } for (const item of part.items) { if ( item.kind !== "thinking" || @@ -121,16 +127,27 @@ export function visibleProcessSteps( return count; } -/** Use recorded message/tool timing for history; elapsed live time is UI-only. */ -export function turnProcessTiming(parts: readonly AssistantTurnPart[]) { - const messages = parts.flatMap((part) => - part.kind === "message" ? [part.message] : part.items.map((item) => item.message), - ); - const starts = messages +/** Use the initiating user time when loaded, otherwise the first process row. */ +export function turnProcessTiming( + parts: readonly AssistantTurnPart[], + turnStartedAt?: string, +) { + const messages = parts.flatMap((part) => { + if (part.kind === "message" || part.kind === "steering") { + return [part.message]; + } + return part.items.map((item) => item.message); + }); + const messageStarts = messages .map((message) => Date.parse(message.createdAt)) .filter(Number.isFinite); - if (starts.length === 0) return { startedAt: undefined, endedAt: undefined }; - const startedAt = Math.min(...starts); + const userStart = Date.parse(turnStartedAt ?? ""); + const startedAt = Number.isFinite(userStart) + ? userStart + : messageStarts.length > 0 + ? Math.min(...messageStarts) + : undefined; + if (startedAt === undefined) return { startedAt: undefined, endedAt: undefined }; const endedAt = Math.max( startedAt, ...messages.map((message) => { @@ -149,3 +166,5 @@ export function turnProcessTiming(parts: readonly AssistantTurnPart[]) { ); return { startedAt, endedAt }; } + +export type TurnProcessTiming = ReturnType; diff --git a/apps/desktop/src/lib/work-panel-tabs.ts b/apps/desktop/src/lib/work-panel-tabs.ts index d38e56d26e..7c891e2370 100644 --- a/apps/desktop/src/lib/work-panel-tabs.ts +++ b/apps/desktop/src/lib/work-panel-tabs.ts @@ -13,6 +13,13 @@ export type WorkPanelTab = { /** Stored attachment mimeType for extension-less `attachments/` images. */ mimeType?: string; }; +export type WorkPanelReviewSelection = { + sessionId: string; + turnId: string; + selectedPath: string; + snapshotIds: string[]; + revision: number; +}; export type WorkPanelTabsState = { tabs: WorkPanelTab[]; @@ -22,6 +29,7 @@ export type WorkPanelTabsState = { export type WorkPanelContext = WorkPanelTabsState & { open: boolean; fileRequest: { path: string; seq: number; mimeType?: string } | null; + reviewSelection?: WorkPanelReviewSelection; }; let newWorkPanelTabSequence = 0; diff --git a/apps/desktop/src/lib/workspace-review.ts b/apps/desktop/src/lib/workspace-review.ts index 60ea4beb9e..ba3a23c17e 100644 --- a/apps/desktop/src/lib/workspace-review.ts +++ b/apps/desktop/src/lib/workspace-review.ts @@ -6,7 +6,7 @@ import type { UiMessage, } from "@pi-desktop/shared"; -const REVIEW_CHANGE_TOOLS = new Set(["Write", "Edit"]); +const LEGACY_REVIEW_CHANGE_TOOLS = new Set(["Write", "Edit"]); const REVIEW_CHANGE_STATUSES = new Set([ "added", "modified", @@ -17,12 +17,26 @@ const REVIEW_CHANGE_STATES = new Set([ "active", "rolledBack", ]); +const REVIEW_CAPTURE_STATUSES = new Set([ + "complete", + "partial", + "unavailable", +]); + +export type ReviewCaptureStatus = "complete" | "partial" | "unavailable"; + +export type ReviewChangeMetadata = Omit; export type ReviewChangeEntry = { message: UiMessage; change: ReviewChange; }; +export type ReviewChangeMetadataEntry = { + message: UiMessage; + change: ReviewChangeMetadata; +}; + export type ReviewChangesSummary = { changeCount: number; activeCount: number; @@ -51,6 +65,27 @@ function toolResultDetails(message: UiMessage): Record | null { return recordValue(recordValue(message.toolResult)?.details); } +function isExecutedReviewTool(message: UiMessage): boolean { + if (message.role !== "tool") return false; + if (LEGACY_REVIEW_CHANGE_TOOLS.has(message.toolName || "")) { + return message.toolStatus === "success"; + } + return ( + message.toolName === "Bash" && + (message.toolStatus === "success" || message.toolStatus === "error") + ); +} + +/** Shell build caches are incidental evidence, not user-authored workspace edits. */ +export function isIncidentalShellCacheReview( + message: UiMessage, + path: string, +): boolean { + if (message.toolName !== "Bash") return false; + const segments = path.split(/[\\/]+/).filter(Boolean); + return segments.slice(0, -1).some((segment) => segment === ".gradle"); +} + function parseHunks(value: unknown): DiffHunk[] { if (!Array.isArray(value)) return []; return value.flatMap((hunk) => { @@ -63,34 +98,14 @@ function parseHunks(value: unknown): DiffHunk[] { const text = typeof lineRecord?.text === "string" ? lineRecord.text : null; const lineType = type === "add" || type === "del" || type === "context" ? type : null; - if ( - text === null || - lineType === null - ) { - return []; - } - return [{ type: lineType, text }]; + return text === null || lineType === null ? [] : [{ type: lineType, text }]; }); return [{ header, lines }]; }); } -/** - * Read the durable change record embedded in one successful tool message. - * The message owns this evidence; no current Git state is consulted. - */ -export function reviewChangeFromMessage(message: UiMessage): ReviewChange | null { - if ( - message.role !== "tool" || - message.toolStatus !== "success" || - !REVIEW_CHANGE_TOOLS.has(message.toolName || "") - ) { - return null; - } - - const details = toolResultDetails(message); - if (details?.root !== "workspace") return null; - const review = recordValue(details.review); +function parseReviewChangeMetadata(value: unknown): ReviewChangeMetadata | null { + const review = recordValue(value); if (!review || review.version !== 1) return null; const snapshotId = nonEmptyString(review.snapshotId); @@ -127,20 +142,112 @@ export function reviewChangeFromMessage(message: UiMessage): ReviewChange | null state: state as ReviewChangeState, additions, deletions, - hunks: parseHunks(review.hunks), ...(review.binary === true ? { binary: true } : {}), ...(review.truncated === true ? { truncated: true } : {}), reversible: review.reversible === true, }; } +function parseReviewChange(value: unknown): ReviewChange | null { + const metadata = parseReviewChangeMetadata(value); + if (!metadata) return null; + return { + ...metadata, + hunks: parseHunks(recordValue(value)?.hunks), + }; +} + +function reviewRecordsFromMessage( + message: UiMessage, + parse: (value: unknown) => T | null, + snapshotIdOf: (item: T) => string, +): T[] { + if (!isExecutedReviewTool(message)) return []; + const details = toolResultDetails(message); + if (details?.root !== "workspace") return []; + + const candidates = [ + ...(details.review === undefined ? [] : [details.review]), + ...(Array.isArray(details.reviews) ? details.reviews : []), + ]; + const changes = new Map(); + for (const candidate of candidates) { + const change = parse(candidate); + if (change && !isIncidentalShellCacheReview(message, change.path)) { + changes.set(snapshotIdOf(change), change); + } + } + return [...changes.values()]; +} + +/** Read every independently valid durable change record owned by one message. */ +export function reviewChangesFromMessage(message: UiMessage): ReviewChange[] { + return reviewRecordsFromMessage( + message, + parseReviewChange, + (change) => change.snapshotId, + ); +} + +/** Same admission as full review parse, without walking diff hunks. */ +export function reviewChangesMetadataFromMessage( + message: UiMessage, +): ReviewChangeMetadata[] { + return reviewRecordsFromMessage( + message, + parseReviewChangeMetadata, + (change) => change.snapshotId, + ); +} + +/** Compatibility wrapper for callers that still expect one review per message. */ +export function reviewChangeFromMessage(message: UiMessage): ReviewChange | null { + return reviewChangesFromMessage(message)[0] ?? null; +} + +/** + * Shell capture scope is separate from its change list: complete with no + * reviews means a tracked read-only/no-op command, while missing legacy + * metadata cannot make that claim. + */ +export function reviewCaptureFromMessage( + message: UiMessage, +): ReviewCaptureStatus | null { + if ( + message.role !== "tool" || + message.toolName !== "Bash" || + (message.toolStatus !== "success" && message.toolStatus !== "error") + ) { + return null; + } + const details = toolResultDetails(message); + if (details?.root === "scratch") return null; + if (details?.root !== undefined && details.root !== "workspace") return null; + const capture = recordValue(details?.reviewCapture); + const status = capture?.status; + return typeof status === "string" && + REVIEW_CAPTURE_STATUSES.has(status as ReviewCaptureStatus) + ? (status as ReviewCaptureStatus) + : "unavailable"; +} + export function reviewChangesFromMessages( messages: UiMessage[], ): ReviewChangeEntry[] { - return messages.flatMap((message) => { - const change = reviewChangeFromMessage(message); - return change ? [{ message, change }] : []; - }); + const latestBySnapshot = new Map< + string, + ReviewChangeEntry & { sequence: number } + >(); + let sequence = 0; + for (const message of messages) { + for (const change of reviewChangesFromMessage(message)) { + latestBySnapshot.set(change.snapshotId, { message, change, sequence }); + sequence += 1; + } + } + return [...latestBySnapshot.values()] + .sort((left, right) => left.sequence - right.sequence) + .map(({ message, change }) => ({ message, change })); } export function summarizeReviewChanges( @@ -166,25 +273,39 @@ export function summarizeReviewChanges( ); } -/** Update only the persisted message-local review state after a rollback. */ +/** Update only the matching persisted message-local review after a rollback. */ export function withReviewChangeState( message: UiMessage, state: ReviewChangeState, + snapshotId?: string, ): UiMessage { const toolResult = recordValue(message.toolResult); const details = recordValue(toolResult?.details); - const review = recordValue(details?.review); - if (!toolResult || !details || !review) return message; + if (!toolResult || !details) return message; + const targetSnapshotId = snapshotId ?? reviewChangeFromMessage(message)?.snapshotId; + if (!targetSnapshotId) return message; + + let changed = false; + const update = (value: unknown): unknown => { + const review = recordValue(value); + if (review?.snapshotId !== targetSnapshotId) return value; + changed = true; + return { ...review, state }; + }; + const review = update(details.review); + const reviews = Array.isArray(details.reviews) + ? details.reviews.map(update) + : details.reviews; + if (!changed) return message; + return { ...message, toolResult: { ...toolResult, details: { ...details, - review: { - ...review, - state, - }, + ...(details.review !== undefined ? { review } : {}), + ...(details.reviews !== undefined ? { reviews } : {}), }, }, }; diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 035eb55998..b48bb34ac9 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -7,6 +7,7 @@ import { MAC_TRAFFIC_LIGHT_EDGE_DIP } from "@pi-desktop/shared"; import App from "./App"; import { PluginLauncher } from "./components/PluginLauncher"; import { initLanguageSync, resolveOsLocale } from "./lib/app-language"; +import { installPointerOutside } from "./lib/pointer-outside"; import { installScrollbarReveal } from "./lib/scrollbar-reveal"; import "./styles/globals.css"; @@ -30,6 +31,7 @@ if (document.documentElement.dataset.platform === "darwin") { // Scrollbars are transparent at rest (base.css); this marks the scrolling // element so the thumb shows while it moves, not only under the pointer. installScrollbarReveal(document); +installPointerOutside(document); const locale = resolveLocale(resolveOsLocale()); const resources = Object.fromEntries( diff --git a/apps/desktop/src/stores/app-state.ts b/apps/desktop/src/stores/app-state.ts index 3cf9e08e0f..4b086251bf 100644 --- a/apps/desktop/src/stores/app-state.ts +++ b/apps/desktop/src/stores/app-state.ts @@ -46,7 +46,11 @@ import type { ComposerPrefill, } from "../lib/composer-smart-stop"; import type { SidebarSessionOutcome } from "../lib/sidebar-session-status"; -import type { WorkPanelContext, WorkPanelTab } from "../lib/work-panel-tabs"; +import type { + WorkPanelContext, + WorkPanelReviewSelection, + WorkPanelTab, +} from "../lib/work-panel-tabs"; export type { WorkPanelTab } from "../lib/work-panel-tabs"; @@ -375,6 +379,9 @@ export type AppState = { setWorkPanelWidth: (width: number) => void; openFileInWorkPanel: (path: string, mimeType?: string) => void; openUrlInWorkPanel: (url: string) => void; + openTurnFileReview: ( + selection: Omit, + ) => void; }; export type AppStateData = { diff --git a/apps/desktop/src/stores/slices/transcript-slice.ts b/apps/desktop/src/stores/slices/transcript-slice.ts index b8c091744c..f49373e155 100644 --- a/apps/desktop/src/stores/slices/transcript-slice.ts +++ b/apps/desktop/src/stores/slices/transcript-slice.ts @@ -390,21 +390,41 @@ export function createTranscriptSlice({ rollbackWorkspaceChange: async (messageId, snapshotId) => { const state = get(); const sessionId = state.activeSessionId; - if (!sessionId || state.isRunning) return null; + if (!sessionId || state.isRunning || runtime.isSessionSelectionPending(sessionId)) return null; try { const result = await api.workspaceReviewRollback({ sessionId, snapshotId }); if (result.status === "rolledBack" || result.status === "alreadyRolledBack") { - set((current) => - current.activeSessionId === sessionId - ? { - messages: current.messages.map((message) => - message.id === messageId - ? withReviewChangeState(message, "rolledBack") - : message, - ), - } - : {}, - ); + set((current) => { + if (current.activeSessionId !== sessionId || runtime.isSessionSelectionPending(sessionId)) return {}; + const updateMessage = (message: UiMessage) => message.id === messageId + ? withReviewChangeState(message, "rolledBack", result.snapshotId) + : message; + const view = current.transcriptViews[sessionId]; + const viewMessages = view?.messages.map(updateMessage) ?? []; + const parentMessage = view?.parentMessage ? updateMessage(view.parentMessage) : undefined; + const viewChanged = view && ( + viewMessages.some((message, index) => message !== view.messages[index]) || + parentMessage !== view.parentMessage + ); + return { + messages: current.messages.map(updateMessage), + // Historical summary navigation reads this range, not the live + // tail. Publish the host-confirmed state to both projections. + ...(viewChanged ? { + transcriptViews: { + ...current.transcriptViews, + [sessionId]: { + ...view, + // Replacing the view supersedes any pending history/search + // read via its identity guard; release loading atomically. + loading: null, + messages: viewMessages, + ...(parentMessage ? { parentMessage } : {}), + }, + }, + } : {}), + }; + }); } else if (result.status === "conflict") { get().showToast(i18n.t("panel.review.rollbackConflict"), { variant: "warning", diff --git a/apps/desktop/src/stores/slices/work-panel-slice.ts b/apps/desktop/src/stores/slices/work-panel-slice.ts index 2fc5ae354a..a43d6a685a 100644 --- a/apps/desktop/src/stores/slices/work-panel-slice.ts +++ b/apps/desktop/src/stores/slices/work-panel-slice.ts @@ -1,3 +1,6 @@ +import { buildTranscriptEntries } from "../../lib/assistant-turns"; +import { summarizeTurnFileChanges } from "../../lib/turn-file-summary"; +import { transcriptViewMessages } from "../../lib/transcript-reading"; import { api } from "../../lib/api"; import { activateWorkPanelTabState, @@ -10,7 +13,9 @@ import { replaceWorkPanelTabState, sanitizeWorkPanelTabsState, switchWorkPanelContextState, + toolWorkPanelTab, type WorkPanelContext, + type WorkPanelReviewSelection, type WorkPanelTab, } from "../../lib/work-panel-tabs"; import { @@ -52,11 +57,15 @@ export function currentWorkPanelContext(state: AppState): WorkPanelContext { tabs: state.workPanelTabs, activeTabId: state.activeWorkPanelTabId, }); + const reviewSelection = state.activeSessionId + ? state.workPanelContexts[state.activeSessionId]?.reviewSelection + : undefined; return { open: state.workPanelOpen, tabs: tabs.tabs, activeTabId: tabs.activeTabId, fileRequest: state.workPanelFileRequest, + ...(reviewSelection ? { reviewSelection } : {}), }; } @@ -104,13 +113,14 @@ export function createWorkPanelSlice({ | "openNewWorkPanelTab" | "replaceWorkPanelTab" | "openWorkPanelTabForSession" + | "resetWorkPanelContext" | "activateWorkPanelTab" | "closeWorkPanelTab" | "collapseWorkPanel" - | "resetWorkPanelContext" | "setWorkPanelWidth" | "openFileInWorkPanel" | "openUrlInWorkPanel" + | "openTurnFileReview" > { let workPanelFileRequestSeq = 0; @@ -184,6 +194,7 @@ export function createWorkPanelSlice({ } : context.fileRequest; const nextContext: WorkPanelContext = { + ...context, open: true, tabs: next.tabs, activeTabId: next.activeTabId, @@ -237,6 +248,10 @@ export function createWorkPanelSlice({ } : state.workPanelFileRequest; const nextContext: WorkPanelContext = { + ...currentWorkPanelContext(state), + reviewSelection: next.tabs.some((item) => item.kind === "review") + ? state.workPanelContexts[sessionId]?.reviewSelection + : undefined, open: true, tabs: next.tabs, activeTabId: next.activeTabId, @@ -275,6 +290,7 @@ export function createWorkPanelSlice({ } : state.workPanelFileRequest; const nextContext: WorkPanelContext = { + ...currentWorkPanelContext(state), open: state.workPanelOpen, tabs: next.tabs, activeTabId: next.activeTabId, @@ -311,6 +327,10 @@ export function createWorkPanelSlice({ } : state.workPanelFileRequest; const nextContext: WorkPanelContext = { + ...currentWorkPanelContext(state), + reviewSelection: next.tabs.some((item) => item.kind === "review") + ? state.workPanelContexts[sessionId]?.reviewSelection + : undefined, // Closing the final tab leaves the panel open so the user can choose // another tool from the new-tab launcher instead of losing the dock. open: state.workPanelOpen, @@ -356,6 +376,71 @@ export function createWorkPanelSlice({ saveWorkPanelWidth(get().workPanelWidth); }, + openTurnFileReview: (selection) => { + const state = get(); + if ( + !selection.sessionId || + state.activeSessionId !== selection.sessionId || + isSessionSelectionPending(selection.sessionId) || + !selection.selectedPath || + selection.snapshotIds.length === 0 + ) { + return; + } + // Validate against the same loaded reading range and visual boundaries as + // ChatTranscript; the live tail alone can omit history or merge checkpoints. + const messages = transcriptViewMessages( + state.messages, + state.transcriptViews[selection.sessionId], + ); + const turn = buildTranscriptEntries( + messages, + state.sessionCompactions[selection.sessionId], + ).entries.find( + (entry) => entry.kind === "assistant-turn" && entry.id === selection.turnId, + ); + if (turn?.kind !== "assistant-turn") return; + const file = summarizeTurnFileChanges(turn).files.find( + (candidate) => candidate.path === selection.selectedPath, + ); + if (!file) return; + const requestedIds = new Set(selection.snapshotIds); + const availableIds = new Set( + file.entries + .map(({ change }) => change.snapshotId) + .filter((snapshotId) => requestedIds.has(snapshotId)), + ); + if (availableIds.size !== requestedIds.size) return; + const context = currentWorkPanelContext(state); + const next = openWorkPanelTabState( + { tabs: context.tabs, activeTabId: context.activeTabId }, + toolWorkPanelTab("review"), + ); + const previousRevision = context.reviewSelection?.revision ?? 0; + const reviewSelection: WorkPanelReviewSelection = { + ...selection, + snapshotIds: [...selection.snapshotIds], + revision: previousRevision + 1, + }; + const nextContext: WorkPanelContext = { + ...context, + open: true, + tabs: next.tabs, + activeTabId: next.activeTabId, + reviewSelection, + }; + set({ + subagentPanel: null, + workPanelOpen: true, + workPanelTabs: next.tabs, + activeWorkPanelTabId: next.activeTabId, + workPanelContexts: { + ...state.workPanelContexts, + [selection.sessionId]: nextContext, + }, + }); + }, + openFileInWorkPanel: (path, mimeType) => { get().openWorkPanelTab(fileWorkPanelTab(path, mimeType)); }, diff --git a/apps/desktop/src/styles/chat-shell.css b/apps/desktop/src/styles/chat-shell.css index c0d02af6a2..e4e828672c 100644 --- a/apps/desktop/src/styles/chat-shell.css +++ b/apps/desktop/src/styles/chat-shell.css @@ -203,10 +203,42 @@ /* Keep the content axis stable when the scrollbar appears, but do not reserve * a second empty gutter on the left of the conversation. */ scrollbar-gutter: stable; - /* Pinned-follow drives the scroll position; native anchoring fights it. */ + /* Explicit follow and reading anchors own this scroll offset. Opting this + container out also excludes its descendants as native anchor candidates. */ overflow-anchor: none; } +/* A partial latest page may fold to less than one viewport. Its scrollTop is + * already zero/max, but block layout would leave the answer above empty space. + * Auto margin absorbs only the unused space; overflowing history has no spacer + * and remains reachable from its first row. Keep full short chats top-aligned. */ +.thread-scroll[data-tail-aligned="true"] { + display: flex; + flex-direction: column; +} + +.thread-scroll[data-tail-aligned="true"] > .thread-content { + flex: 0 0 auto; + margin-top: auto; +} + +.thread-scroll.transcript-measure-folded { + overflow-anchor: none; +} + +.thread-scroll.transcript-measure-folded .message-row, +.thread-scroll.transcript-measure-folded .tool-activity-group { + content-visibility: visible; +} + +/* The mounted window already bounds how many rows exist. Skipping their + layout leaves the 140px estimate, which collapses when a folded row reaches + the viewport and shifts entry, jump-to-latest, and a long upward scroll. */ +.thread-scroll .message-row, +.thread-scroll .tool-activity-group { + content-visibility: visible; +} + /* Floats above the docked composer fade when the user scrolls away from latest */ .jump-latest-btn { position: absolute; @@ -359,6 +391,12 @@ padding: 2px 0 12px; } +/* Keep translated/wrapped status text in layout, including while idle. Toggling + an empty 26px box to a taller text line shifts every reading row below it. */ +.transcript-history-loading[aria-hidden="true"] { + visibility: hidden; +} + /* The tail runtime status (working / planning / run activity) mounts and clears * with the turn's phase, and each of those rows is its own box: without a * reserve, an indicator appearing between two tool rows grows `.thread-content` diff --git a/apps/desktop/src/styles/messages.css b/apps/desktop/src/styles/messages.css index 237664b2a4..2bd61756c4 100644 --- a/apps/desktop/src/styles/messages.css +++ b/apps/desktop/src/styles/messages.css @@ -42,6 +42,17 @@ align-items: flex-end; } +.message-timestamp { + color: var(--ds-text-faint); + font-size: var(--text-xs); + line-height: var(--leading-compact); + white-space: nowrap; +} + +.message-actions .message-timestamp { + padding-inline: 6px; +} + /* Composer chips use `max-width: min(100%, 240px)` so they shrink in a definite composer. Inside this shrink-to-fit plate the percentage containing block is the available thread width, which stretches the @@ -1001,11 +1012,15 @@ } .message-row:hover .message-actions, -.message-row:focus-within .message-actions, .message-actions:focus-within { opacity: 1; } +html.pointer-outside .message-row:hover .message-actions:not(:focus-within), +html.pointer-outside .message-actions:not(:focus-within) { + opacity: 0; +} + .icon-btn:active:not(:disabled), .title-nav-btn:active:not(:disabled), .nav-item:active:not(:disabled), @@ -3014,6 +3029,12 @@ min-width: 0; } +/* Turn headers stay content-sized in both states; details retain the band. */ +.turn-process > .tool-activity-header { + width: fit-content; + max-width: 100%; +} + .turn-process.open > .tool-activity-header > .tool-activity-caret, .process-activity-group.open > .tool-activity-header > .tool-activity-caret { transform: rotate(90deg); diff --git a/apps/desktop/test/assistant-turns.test.mjs b/apps/desktop/test/assistant-turns.test.mjs index 46453fba16..1ce857ea41 100644 --- a/apps/desktop/test/assistant-turns.test.mjs +++ b/apps/desktop/test/assistant-turns.test.mjs @@ -8,12 +8,15 @@ const here = dirname(fileURLToPath(import.meta.url)); register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); const { assistantTurnContent, + assistantTurnMessages, + assistantTurnTools, assistantTurnResponseOutputTokens, assistantTurnResponseOutputIsEstimated, assistantTurnUsage, buildTranscriptEntries, reuseTranscriptEntries, subagentRunsEqual, + transcriptEntryMessages, } = await import("../src/lib/assistant-turns.ts"); function message(id, role, content, extra = {}) { @@ -50,6 +53,7 @@ test("groups assistant fragments and tools into one conversational turn", () => ["message", "activity", "message", "activity", "message"], ); assert.equal(entries[1].anchorId, "intro"); + assert.equal(entries[1].startedAt, entries[0].message.createdAt); assert.equal( assistantTurnContent(entries[1]), "I will inspect the code.\n\nThe problem is in the renderer.\n\nFixed and verified.", @@ -116,6 +120,194 @@ test("starts a new assistant turn only after the next user message", () => { ); }); +test("marked steering stays ordered inside one assistant turn", () => { + const attachment = { kind: "file", name: "notes.txt", ref: "notes.txt" }; + const root = message("root", "user", "Fix it", { + createdAt: "2026-07-28T00:00:00.000Z", + }); + const steering = message("steer", "user", "Also cover attachments", { + steering: true, + attachments: [attachment], + createdAt: "2026-07-28T00:00:03.000Z", + usage: { inputTokens: 99, outputTokens: 99, totalTokens: 198 }, + }); + const { entries } = buildTranscriptEntries([ + root, + message("intro", "assistant", "Inspecting", { + usage: { inputTokens: 2, outputTokens: 1, totalTokens: 3 }, + }), + message("read", "tool", "result", { toolName: "Read" }), + steering, + message("continue", "assistant", "Continuing with the new constraint"), + message("edit", "tool", "done", { toolName: "Edit" }), + message("final", "assistant", "Fixed", { + usage: { inputTokens: 3, outputTokens: 2, totalTokens: 5 }, + }), + ]); + + assert.equal(entries.length, 2); + const turn = entries[1]; + assert.equal(turn.kind, "assistant-turn"); + assert.equal(turn.id, "intro"); + assert.equal(turn.startedAt, root.createdAt); + assert.deepEqual( + turn.parts.map((part) => part.kind), + ["message", "activity", "steering", "message", "activity", "message"], + ); + assert.equal(turn.parts[2].message, steering); + assert.equal(turn.parts[2].message.attachments[0], attachment); + assert.deepEqual( + assistantTurnMessages(turn).map((item) => item.id), + ["intro", "continue", "final"], + ); + assert.equal( + assistantTurnContent(turn), + "Inspecting\n\nContinuing with the new constraint\n\nFixed", + ); + assert.deepEqual(assistantTurnUsage(turn), { + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + }); + assert.deepEqual( + transcriptEntryMessages(entries).map((item) => item.id), + ["root", "intro", "continue", "final"], + ); + assert.deepEqual( + assistantTurnTools(turn).map((item) => item.id), + ["read", "edit"], + ); +}); + +test("steering before first assistant output starts the same stable turn", () => { + const root = message("root", "user", "Start", { + createdAt: "2026-07-28T00:00:00.000Z", + }); + const steering = message("steer", "user", "Use the narrow path", { + steering: true, + createdAt: "2026-07-28T00:00:01.000Z", + }); + const records = [ + root, + steering, + message("read", "tool", "result", { toolName: "Read" }), + message("final", "assistant", "Done"), + ]; + const first = buildTranscriptEntries(records).entries; + const reloaded = buildTranscriptEntries( + records.map((record) => ({ + ...record, + ...(record.attachments + ? { attachments: record.attachments.map((attachment) => ({ ...attachment })) } + : {}), + })), + ).entries; + + assert.deepEqual(first, reloaded); + assert.equal(first.length, 2); + assert.equal(first[1].kind, "assistant-turn"); + assert.equal(first[1].id, "steer"); + assert.equal(first[1].startedAt, root.createdAt); + assert.deepEqual( + first[1].parts.map((part) => part.kind), + ["steering", "activity", "message"], + ); +}); + +test("orphaned steering stays visible and ordinary users keep hard boundaries", () => { + const leading = message("leading", "user", "Supplement from unloaded history", { + steering: true, + }); + const { entries } = buildTranscriptEntries([ + leading, + message("partial", "assistant", "Stopped", { status: "aborted" }), + message("queued", "user", "A new task"), + message("next", "assistant", "New answer"), + ]); + + assert.deepEqual( + entries.map((entry) => entry.kind), + ["message", "assistant-turn", "message", "assistant-turn"], + ); + assert.equal(entries[0].message, leading); + assert.equal(entries[1].id, "partial"); + assert.equal(entries[3].id, "next"); + assert.deepEqual( + transcriptEntryMessages(entries).map((message) => message.id), + ["leading", "partial", "queued", "next"], + ); +}); + +test("steering-only start can abort before a later ordinary task", () => { + const { entries } = buildTranscriptEntries([ + message("root", "user", "Start"), + message("steer", "user", "Use the fallback", { steering: true }), + message("aborted", "assistant", "Stopped", { status: "aborted" }), + message("queued", "user", "New task"), + message("next", "assistant", "New answer"), + ]); + + assert.deepEqual( + entries.map((entry) => entry.kind), + ["message", "assistant-turn", "message", "assistant-turn"], + ); + assert.equal(entries[1].id, "steer"); + assert.deepEqual( + entries[1].parts.map((part) => part.kind), + ["steering", "message"], + ); + assert.equal(entries[3].id, "next"); +}); + +test("compaction prevents marked steering from merging backward", () => { + const { entries } = buildTranscriptEntries( + [ + message("before", "assistant", "Before"), + message("steer", "user", "After compact", { steering: true }), + message("after", "assistant", "After"), + ], + [mark("cp", "before")], + ); + + assert.deepEqual( + entries.map((entry) => entry.kind), + ["assistant-turn", "compaction", "message", "assistant-turn"], + ); + assert.equal(entries[2].message.id, "steer"); +}); + +test("assistant turns retain the initiating user timestamp when it is loaded", () => { + const user = message("user", "user", "Start"); + const first = buildTranscriptEntries([ + user, + message("assistant", "assistant", "Working"), + ]).entries[1]; + const paged = buildTranscriptEntries([ + message("assistant", "assistant", "Working"), + ]).entries[0]; + + assert.equal(first.kind, "assistant-turn"); + assert.equal(first.startedAt, user.createdAt); + assert.equal(paged.kind, "assistant-turn"); + assert.equal(paged.startedAt, undefined); +}); + +test("started-at metadata participates in transcript entry reuse", () => { + const original = buildTranscriptEntries([ + message("user", "user", "Start"), + message("assistant", "assistant", "Done"), + ]).entries; + const changed = original.map((entry) => + entry.kind === "assistant-turn" + ? { ...entry, startedAt: "2026-07-28T00:00:00.000Z" } + : entry, + ); + const reused = reuseTranscriptEntries(original, changed); + + assert.notEqual(reused[1], original[1]); + assert.equal(reused[1].startedAt, "2026-07-28T00:00:00.000Z"); +}); + test("keeps thinking and tool-only activity in the assistant turn", () => { const { entries } = buildTranscriptEntries([ message("user", "user", "Inspect"), @@ -303,6 +495,113 @@ test("a resumed delegation continues in the latest Task card (ADR 0279)", () => activity.items[1].delegate.items.map((item) => item.message.id), ["delegate-1", "delegate-2"], ); + assert.deepEqual( + turn.ownedToolMessages.map((item) => item.id), + ["task-1", "task-2"], + ); +}); + +test("resumed delegates keep raw tool ownership on each original Task turn", () => { + const task1 = message("task-1", "tool", "report", { + toolName: "Task", + toolCallId: "task-1", + toolResult: { details: { delegationId: "del-1" } }, + }); + const edit1 = message("edit-1", "tool", "done", { + toolName: "Edit", + parentToolCallId: "task-1", + }); + const task2 = message("task-2", "tool", "report", { + toolName: "Task", + toolCallId: "task-2", + toolArgs: { resume: "del-1" }, + toolResult: { details: { delegationId: "del-2" } }, + }); + const edit2 = message("edit-2", "tool", "done", { + toolName: "Edit", + parentToolCallId: "task-2", + }); + const { entries } = buildTranscriptEntries([ + message("user-1", "user", "First"), + task1, + edit1, + message("answer-1", "assistant", "First done"), + message("user-2", "user", "Second"), + task2, + edit2, + message("answer-2", "assistant", "Second done"), + ]); + const first = entries[1]; + const second = entries[3]; + + assert.equal(first.kind, "assistant-turn"); + assert.equal(second.kind, "assistant-turn"); + assert.deepEqual( + first.ownedToolMessages.map((item) => item.id), + ["task-1", "edit-1"], + ); + assert.deepEqual( + second.ownedToolMessages.map((item) => item.id), + ["task-2", "edit-2"], + ); + assert.equal(first.parts[0].items[0].delegate, undefined); + assert.deepEqual( + second.parts[0].items[0].delegate.items.map((item) => item.message.id), + ["edit-1", "edit-2"], + ); +}); + +test("delayed delegate and rollback updates invalidate only the owning turn", () => { + const task = message("task", "tool", "report", { + toolName: "Task", + toolCallId: "task-call", + }); + const base = [ + message("user-1", "user", "First"), + task, + message("answer-1", "assistant", "First done"), + message("user-2", "user", "Second"), + message("answer-2", "assistant", "Second done"), + ]; + const initial = buildTranscriptEntries(base).entries; + const delayedEdit = message("delayed-edit", "tool", "done", { + toolName: "Edit", + parentToolCallId: "task-call", + toolResult: { details: { review: { state: "active" } } }, + }); + const delayed = buildTranscriptEntries([...base, delayedEdit]).entries; + const reusedDelayed = reuseTranscriptEntries(initial, delayed); + + assert.notEqual(reusedDelayed[1], initial[1]); + assert.equal(reusedDelayed[3], initial[3]); + assert.deepEqual( + reusedDelayed[1].ownedToolMessages.map((item) => item.id), + ["task", "delayed-edit"], + ); + + const rolledBackEdit = { + ...delayedEdit, + toolResult: { details: { review: { state: "rolledBack" } } }, + }; + const rolledBack = buildTranscriptEntries([...base, rolledBackEdit]).entries; + const reusedRollback = reuseTranscriptEntries(delayed, rolledBack); + + assert.notEqual(reusedRollback[1], delayed[1]); + assert.equal(reusedRollback[3], delayed[3]); + assert.equal( + reusedRollback[1].ownedToolMessages.at(-1), + rolledBackEdit, + ); + + const stable = reuseTranscriptEntries( + rolledBack, + buildTranscriptEntries([...base, rolledBackEdit]).entries, + ); + assert.equal(stable[1], rolledBack[1]); + assert.equal( + stable[1].ownedToolMessages, + rolledBack[1].ownedToolMessages, + ); }); test("a resume link whose parent Task row is gone leaves the card intact", () => { diff --git a/apps/desktop/test/chat-review-entry.test.mjs b/apps/desktop/test/chat-review-entry.test.mjs index 9754c55439..9a7c021d99 100644 --- a/apps/desktop/test/chat-review-entry.test.mjs +++ b/apps/desktop/test/chat-review-entry.test.mjs @@ -158,12 +158,12 @@ test("failed and scratch tool rows cannot manufacture review evidence", () => { ); }); -test("chat renders one message-owned card immediately after its tool row", () => { +test("chat renders message-owned review records immediately after their tool row", () => { assert.equal(transcriptSource.includes(""), false); assert.equal(transcriptSource.includes("review-changes-entry"), false); assert.match( transcriptSource, - /[\s\S]*/, + /]*\bmessage=\{item\.message\}[\s\S]*?\/>\s*/, ); assert.doesNotMatch(transcriptSource, /workspaceDiff|findWorkspaceChange/); assert.match(cardSource, /aria-expanded=\{open\}/); diff --git a/apps/desktop/test/composer-dock-height.test.mjs b/apps/desktop/test/composer-dock-height.test.mjs new file mode 100644 index 0000000000..3986e957fd --- /dev/null +++ b/apps/desktop/test/composer-dock-height.test.mjs @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; +import ts from "typescript"; +import { readComposerModule } from "./helpers/composer-source.mjs"; + +const hookSource = await readComposerModule("hooks/useComposerDockHeight.ts"); +const { outputText } = ts.transpileModule(hookSource, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}); + +// Isolate the React effect scheduler and browser geometry boundary. Real editor +// sizing and session-entry paint ordering are covered by the renderer fixture. +function mountDock({ withObserver = true } = {}) { + const slots = []; + const layout = []; + const passive = []; + const observers = []; + const writes = []; + let index = 0; + let height = 0; + let reads = 0; + const changed = (slot, deps) => !slot || !deps || + deps.length !== slot.deps?.length || deps.some((dep, i) => !Object.is(dep, slot.deps[i])); + const effect = (queue, create, deps) => { + const at = index++; + if (!changed(slots[at], deps)) return; + const slot = { deps, cleanup: slots[at]?.cleanup }; + slots[at] = slot; + queue.push(() => { + slot.cleanup?.(); + slot.cleanup = create(); + }); + }; + const react = { + useRef(initial) { + const at = index++; + return slots[at] ??= { current: initial }; + }, + useCallback(callback, deps) { + const at = index++; + if (changed(slots[at], deps)) slots[at] = { deps, callback }; + return slots[at].callback; + }, + useLayoutEffect: (create, deps) => effect(layout, create, deps), + useEffect: (create, deps) => effect(passive, create, deps), + }; + class Observer { + constructor(callback) { + this.deliver = callback; + this.disconnected = false; + observers.push(this); + } + observe(element) { this.element = element; } + disconnect() { this.disconnected = true; } + } + const exports = {}; + runInNewContext(outputText, { + exports, + require: (specifier) => { + assert.equal(specifier, "react"); + return react; + }, + ResizeObserver: withObserver ? Observer : undefined, + document: { + documentElement: { + style: { setProperty: (name, value) => writes.push([name, value]) }, + }, + }, + }); + const element = { + getBoundingClientRect() { + reads += 1; + return { height }; + }, + }; + return { + observers, + writes, + element, + get reads() { return reads; }, + get reserve() { return writes.at(-1)?.[1]; }, + commit(nextHeight, variant = "docked") { + height = nextHeight; + index = 0; + const ref = exports.useComposerDockHeight(variant); + ref.current = element; + for (const run of layout.splice(0)) run(); + }, + resize(nextHeight) { + height = nextHeight; + observers.at(-1).deliver(); + }, + unmount() { + for (const slot of slots) slot.cleanup?.(); + }, + }; +} + +test("dock reserve matches each content commit before passive effects or resize delivery", () => { + const dock = mountDock(); + for (const height of [208.4, 127.2, 208.4, 252.1, 127.2]) { + const before = dock.reads; + dock.commit(height); + assert.equal(dock.reserve, `${Math.round(height)}px`); + assert.equal(dock.reads - before, 1, "one dock measurement per commit"); + } + assert.equal(dock.observers.length, 1, "content commits do not resubscribe"); + assert.equal(dock.observers[0].element, dock.element); + assert.ok(dock.writes.every(([name]) => name === "--composer-dock-height")); + dock.unmount(); +}); + +test("equal rounded pixels do not invalidate global styles, including late resize delivery", () => { + const dock = mountDock(); + dock.commit(127.1); + dock.commit(127.4); + dock.resize(126.6); + assert.equal(dock.writes.length, 1); + dock.resize(128.6); + assert.equal(dock.reserve, "129px"); + assert.equal(dock.writes.length, 2); + dock.commit(129.2); + assert.equal(dock.writes.length, 2); + dock.unmount(); +}); + +test("variant changes and unmount disconnect the owned resize observer", () => { + const dock = mountDock(); + dock.commit(127.2, "home"); + const first = dock.observers[0]; + dock.commit(208.2, "docked"); + assert.equal(first.disconnected, true); + assert.equal(dock.observers.length, 2); + assert.equal(dock.observers[1].disconnected, false); + assert.equal(dock.reserve, "208px"); + dock.unmount(); + assert.equal(dock.observers[1].disconnected, true); +}); + +test("layout commits still publish when ResizeObserver is unavailable", () => { + const dock = mountDock({ withObserver: false }); + dock.commit(208.4); + dock.commit(127.2); + assert.equal(dock.reserve, "127px"); + assert.equal(dock.observers.length, 0); + dock.unmount(); +}); + +test("Composer registers dock publication after its editor sizing layout effect", async () => { + const source = await readFile(new URL("../src/components/Composer.tsx", import.meta.url), "utf8"); + const sizing = source.indexOf("}, [value]);"); + const publication = source.indexOf("const dockRef = useComposerDockHeight(variant);"); + assert.ok(sizing > -1 && publication > sizing); + assert.match(source, /ref=\{dockRef\}/); +}); + +test("retained settings-hidden chat cannot replace its measured dock reserve with zero", () => { + const dock = mountDock(); + dock.commit(208.4); + dock.commit(0); + dock.resize(0); + assert.equal(dock.reserve, "208px"); + assert.equal(dock.writes.length, 1); + dock.commit(127.2); + assert.equal(dock.reserve, "127px"); + dock.unmount(); +}); + +test("a dock initially mounted under hidden chat waits for its first layout", () => { + const dock = mountDock(); + dock.commit(0); + assert.equal(dock.writes.length, 0); + dock.resize(127.2); + assert.equal(dock.reserve, "127px"); + dock.unmount(); +}); diff --git a/apps/desktop/test/composer-draft-cache.test.mjs b/apps/desktop/test/composer-draft-cache.test.mjs index 9bba607865..9db06fc6b7 100644 --- a/apps/desktop/test/composer-draft-cache.test.mjs +++ b/apps/desktop/test/composer-draft-cache.test.mjs @@ -176,3 +176,10 @@ test("composer handles home drafts, deleted sessions, and async sends by key", ( assert.match(composer, /readComposerDraftRevision\(key\) !== expectedRevision/); assert.doesNotMatch(composer, /if \(accepted\) clearDraft\(\);/); }); + +test("session draft restoration runs during layout before the destination paints", () => { + assert.ok( + /useLayoutEffect\(\(\) => \{\s*const previousKey = draftKeyRef\.current;/.test(draftHook), + "restore the cached destination draft before the dock is measured for paint", + ); +}); diff --git a/apps/desktop/test/conversation-minimap.test.mjs b/apps/desktop/test/conversation-minimap.test.mjs index 128952a680..6415f43bfe 100644 --- a/apps/desktop/test/conversation-minimap.test.mjs +++ b/apps/desktop/test/conversation-minimap.test.mjs @@ -78,6 +78,44 @@ test("uses one assistant marker for all response fragments in a user turn", () = assert.equal(markers[1].preview, "First sentence.\n\nSecond sentence."); }); +test("projected grouped steering does not create a hidden marker or split the assistant marker", () => { + const markers = buildConversationMinimapMarkers([ + message("user", "user", "Initial task"), + message("assistant-before", "assistant", "First work segment."), + message("assistant-after", "assistant", "Second work segment."), + ]); + + assert.deepEqual( + markers.map(({ id, role }) => ({ id, role })), + [ + { id: "user", role: "user" }, + { id: "assistant-before", role: "assistant" }, + ], + ); + assert.equal( + markers[1].preview, + "First work segment.\n\nSecond work segment.", + ); +}); + +test("projected steering before output keeps ordinary user and assistant markers", () => { + const markers = buildConversationMinimapMarkers([ + message("user", "user", "Initial task"), + message("assistant", "assistant", "Answer"), + ]); + + assert.deepEqual(markers.map((marker) => marker.id), ["user", "assistant"]); +}); + +test("standalone leading steering retains a navigable user marker", () => { + const markers = buildConversationMinimapMarkers([ + { ...message("steer", "user", "Supplement from unloaded history"), steering: true }, + message("assistant", "assistant", "Answer"), + ]); + + assert.deepEqual(markers.map((marker) => marker.id), ["steer", "assistant"]); +}); + test("starts the assistant marker at the first contentful response fragment", () => { const markers = buildConversationMinimapMarkers([ message("user-1", "user", "Question"), diff --git a/apps/desktop/test/interaction-performance.test.mjs b/apps/desktop/test/interaction-performance.test.mjs index 637c1f79e7..e3a0e67c68 100644 --- a/apps/desktop/test/interaction-performance.test.mjs +++ b/apps/desktop/test/interaction-performance.test.mjs @@ -71,9 +71,10 @@ test("stream rendering avoids duplicate frame state and coalesces following", () // commit that reveals it, or the reveal shows one empty frame (ADR 0137). assert.match( transcript, - /const renderedMessages =\s*readingWindow \|\| firstCommit \|\| paneRevealed \? messages : deferredMessages/, + /const \[renderedMessages, renderedCompactions\] = current \? snapshot : deferred/, ); - assert.match(transcript, /const \{ entries, visible \} = useMemo/); + assert.match(transcript, /!isRunning \|\| readingWindow \|\| firstCommit \|\| paneRevealed \|\| pendingLatestRef\.current/); + assert.match(transcript, /const entries = useMemo/); assert.match( transcript, /buildTranscriptEntries\(renderedMessages, renderedCompactions\)/, @@ -88,7 +89,7 @@ test("stream rendering avoids duplicate frame state and coalesces following", () transcript, /const allHistoryEntries = useMemo\(\(\) => entries\.slice\(0, -1\), \[entries\]\)/, ); - assert.match(transcript, / { transcript, /if \(transition\.releasedFollow\) cancelFollowScroll\(\)/, ); - assert.match(transcript, /pinnedRef\.current = transition\.pinned/); + assert.match(transcript, /setPinned\(transition\.pinned\)/); assert.match(transcript, /setShowJump\(transition\.showJump\)/); }); @@ -140,7 +141,7 @@ test("send re-pins before paint instead of flashing the old transcript position" )?.[1] ?? ""; assert.match(turnStartEffect, /const turnStarted = isRunning && !wasRunningRef\.current/); assert.match(turnStartEffect, /cancelFollowScroll\(\)/); - assert.match(turnStartEffect, /pinnedRef\.current = true/); + assert.match(turnStartEffect, /setPinned\(true\)/); assert.match(turnStartEffect, /scrollToBottom\(\)/); assert.match(transcript, /const targetTop = Math\.max\(0, el\.scrollHeight - el\.clientHeight\)/); }); @@ -180,13 +181,13 @@ test("session activation pins the latest record before the first paint", () => { // first layout: settle at the newest turn with no cross-session state to // unwind, before the first paint. const activationEffect = transcript.match( - /useLayoutEffect\(\(\) => \{([\s\S]*?)\n \}, \[cancelFollowScroll, releaseDisclosureAnchor, scrollToBottom\]\);/, + /useLayoutEffect\(\(\) => \{([\s\S]*?)\n \}, \[cancelFollowScroll, positionAtFoldedBottom, releaseDisclosureAnchor\]\);/, )?.[1]; assert.ok(activationEffect); assert.match(activationEffect, /cancelFollowScroll\(\)/); - assert.match(activationEffect, /pinnedRef\.current = true/); + assert.match(activationEffect, /setPinned\(true\)/); assert.match(activationEffect, /setShowJump\(false\)/); - assert.match(activationEffect, /scrollToBottom\(\)/); + assert.match(activationEffect, /positionAtFoldedBottom\(\)/); assert.doesNotMatch(activationEffect, /smooth/); }); @@ -195,13 +196,13 @@ test("a revealed pane restores its own scroll position in the layout phase", () // scroller can be clamped while its content grows off screen, and a passive // effect would leave one visible frame at the wrong offset (ADR 0137). const revealEffect = transcript.match( - /useLayoutEffect\(\(\) => \{([\s\S]*?)\n \}, \[cancelFollowScroll, paneVisible, releaseDisclosureAnchor, scrollToBottom\]\);/, + /useLayoutEffect\(\(\) => \{([\s\S]*?)\n \}, \[cancelFollowScroll, paneVisible, positionAtFoldedBottom, recordScrollPosition, releaseDisclosureAnchor\]\);/, )?.[1]; assert.ok(revealEffect, "the reveal must restore position in a layout effect"); assert.match(revealEffect, /retainedScrollTopRef\.current = lastLaidOutScrollTopRef\.current/); - assert.match(revealEffect, /if \(pinnedRef\.current\) \{\s*scrollToBottom\(\);/); + assert.match(revealEffect, /if \(pinnedRef\.current\) \{\s*positionAtFoldedBottom\(\);/); assert.match(revealEffect, /el\.scrollTop = retained/); - assert.match(revealEffect, /lastScrollTopRef\.current = retained/); + assert.match(revealEffect, /recordScrollPosition\(el\.scrollTop\)/); }); test("a pane bounds its own first commit instead of rebuilding it", () => { @@ -274,7 +275,7 @@ test("first-commit hydration expands without moving the transcript", () => { )?.[1]; assert.ok(rebottom, "the hydration expansion must re-anchor in a layout effect"); assert.match(rebottom, /boundedFirstCommitRef\.current = false/); - assert.match(rebottom, /scrollToBottom\(\)/); + assert.match(rebottom, /positionAtFoldedBottom\(\)/); // A user who scrolled up during the bounded frame keeps their position. assert.match(rebottom, /if \(!pinnedRef\.current\) return/); diff --git a/apps/desktop/test/interaction-polish.test.mjs b/apps/desktop/test/interaction-polish.test.mjs index 038276c082..c689548e15 100644 --- a/apps/desktop/test/interaction-polish.test.mjs +++ b/apps/desktop/test/interaction-polish.test.mjs @@ -61,10 +61,12 @@ test("no partial sets scrollbar-width or scrollbar-color (they disable the pseud assert.doesNotMatch(declarations, /scrollbar-color\s*:/); }); -test("the renderer entry installs the scroll-reveal mark", async () => { +test("the renderer entry installs scroll-reveal and pointer-outside guards", async () => { const main = await readFile(new URL("../src/main.tsx", import.meta.url), "utf8"); assert.match(main, /import \{ installScrollbarReveal \} from "\.\/lib\/scrollbar-reveal";/); assert.match(main, /installScrollbarReveal\(document\);/); + assert.match(main, /import \{ installPointerOutside \} from "\.\/lib\/pointer-outside";/); + assert.match(main, /installPointerOutside\(document\);/); }); test("all renderer scrollbars share the compact reveal contract", () => { diff --git a/apps/desktop/test/pointer-outside.test.mjs b/apps/desktop/test/pointer-outside.test.mjs new file mode 100644 index 0000000000..dc16df117e --- /dev/null +++ b/apps/desktop/test/pointer-outside.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + POINTER_OUTSIDE_CLASS, + installPointerOutside, +} from "../src/lib/pointer-outside.ts"; + +function fakeRoot() { + const classes = new Set(); + const listeners = []; + const viewListeners = []; + const mutations = []; + return { + hidden: false, + listeners, + viewListeners, + classes, + mutations, + documentElement: { + classList: { + contains(name) { + return classes.has(name); + }, + add(name) { + mutations.push(["add", name]); + classes.add(name); + }, + remove(name) { + mutations.push(["remove", name]); + classes.delete(name); + }, + }, + }, + defaultView: { + addEventListener(type, listener) { + viewListeners.push({ type, listener }); + }, + removeEventListener(type, listener) { + const i = viewListeners.findIndex( + (item) => item.type === type && item.listener === listener, + ); + if (i >= 0) viewListeners.splice(i, 1); + }, + }, + addEventListener(type, listener) { + listeners.push({ type, listener }); + }, + removeEventListener(type, listener) { + const i = listeners.findIndex( + (item) => item.type === type && item.listener === listener, + ); + if (i >= 0) listeners.splice(i, 1); + }, + emit(type, event) { + for (const item of listeners) if (item.type === type) item.listener(event); + }, + blur() { + for (const item of viewListeners) if (item.type === "blur") item.listener(); + }, + }; +} + +test("leaving the document or blurring the window marks the pointer outside", () => { + const root = fakeRoot(); + const dispose = installPointerOutside(root); + root.emit("mouseleave"); + assert.equal(root.classes.has(POINTER_OUTSIDE_CLASS), true); + root.emit("mouseenter"); + assert.equal(root.classes.has(POINTER_OUTSIDE_CLASS), false); + root.blur(); + assert.equal(root.classes.has(POINTER_OUTSIDE_CLASS), true); + root.hidden = true; + root.emit("visibilitychange"); + assert.equal(root.classes.has(POINTER_OUTSIDE_CLASS), true); + dispose(); + assert.equal(root.classes.has(POINTER_OUTSIDE_CLASS), false); + assert.equal(root.listeners.length, 0); + assert.equal(root.viewListeners.length, 0); +}); + +test("mouseout with no related target is a window exit, not an internal move", () => { + const root = fakeRoot(); + installPointerOutside(root); + root.emit("mouseout", { relatedTarget: {} }); + assert.equal(root.classes.has(POINTER_OUTSIDE_CLASS), false); + root.emit("mouseout", { relatedTarget: null, toElement: null }); + assert.equal(root.classes.has(POINTER_OUTSIDE_CLASS), true); + root.emit("mouseover"); + assert.equal(root.classes.has(POINTER_OUTSIDE_CLASS), false); + root.emit("pointerleave"); + assert.equal(root.classes.has(POINTER_OUTSIDE_CLASS), true); +}); + +test("repeated pointer events only write the class on state changes", () => { + const root = fakeRoot(); + const dispose = installPointerOutside(root); + for (let i = 0; i < 100; i++) { + root.emit("mouseout", { relatedTarget: {} }); + root.emit("mouseover"); + } + assert.deepEqual(root.mutations, []); + root.emit("mouseout", { relatedTarget: null, toElement: null }); + root.emit("mouseleave"); + root.emit("pointerleave"); + root.blur(); + assert.deepEqual(root.mutations, [["add", POINTER_OUTSIDE_CLASS]]); + root.emit("mouseover"); + root.emit("mouseenter"); + root.emit("pointerenter"); + assert.deepEqual(root.mutations, [ + ["add", POINTER_OUTSIDE_CLASS], + ["remove", POINTER_OUTSIDE_CLASS], + ]); + dispose(); + root.emit("mouseout", { relatedTarget: null }); + assert.equal(root.classes.has(POINTER_OUTSIDE_CLASS), false); +}); diff --git a/apps/desktop/test/session-switch-performance.test.mjs b/apps/desktop/test/session-switch-performance.test.mjs index 99330c6c1d..a126decf3b 100644 --- a/apps/desktop/test/session-switch-performance.test.mjs +++ b/apps/desktop/test/session-switch-performance.test.mjs @@ -28,6 +28,7 @@ const [sessionRuntime, sessionSlice, sessionCoordination, events, sidebar, chatS const store = [sessionRuntime, sessionSlice, sessionCoordination, events].join("\n"); const readingRuntime = await readStoreModule("runtime/transcript-reading-runtime.ts"); const readingView = await read("../src/hooks/use-transcript-view.ts"); +const prepend = await read("../src/features/chat/transcript/hooks/useTranscriptPrepend.ts"); test("session reads use a bounded tail and load older pages on demand", () => { assert.match(store, /SESSION_TRANSCRIPT_PAGE_SIZE = 100/); @@ -161,8 +162,8 @@ test("a hidden pane does no reading work off screen", () => { // and measure row positions against a subtree the engine is not laying out. assert.match(transcript, /if \(!paneVisible\) return;\n\s*let frame = 0;/); assert.match( - transcript, - /const loadOlder = useCallback\(\(\) => \{[\s\S]*?if \(!paneVisibleRef\.current\) return;/, + prepend, + /const reachTop = useCallback[\s\S]*?if \(!paneVisible \|\| !el \|\| !frame\) return;/, ); assert.match(transcript, /\{paneVisible && !veilCovering \? \(\s* { diff --git a/apps/desktop/test/transcript-disclosure-reading.test.mjs b/apps/desktop/test/transcript-disclosure-reading.test.mjs index a969a1958a..69c154fe73 100644 --- a/apps/desktop/test/transcript-disclosure-reading.test.mjs +++ b/apps/desktop/test/transcript-disclosure-reading.test.mjs @@ -28,6 +28,7 @@ test("automatic disclosure does not claim a reading position", () => { // Automatic reveal/collapse may claim the parent hierarchy, but never calls // the scroll anchor notifier reserved for direct user interaction. assert.match(disclosure, /if \(previousOpen\.current && !open && !choice && ownsReadingPosition/); + assert.match(disclosure, /const identityChanged = previousKey\.current !== key;/); const revealEffect = disclosure.match( /useLayoutEffect\(\(\) => \{\s*if \(revealRequest === undefined[\s\S]*?\n \}, \[choices, key, parent\.claim, revealRequest\]\);/, )?.[0]; @@ -79,7 +80,7 @@ test("the transcript restores the held title inside its resize observer", () => // so the correction has to run from the observer, before follow. assert.match( transcript, - /const followScrollNow = useCallback\(\(\) => \{\s*if \(paneVisibleRef\.current && restoreDisclosureAnchor\(\)\) return;\s*if \(!paneVisibleRef\.current \|\| !pinnedRef\.current\) return;\s*cancelFollowScroll\(\);\s*scrollToBottom\(\);/, + /const followScrollNow = useCallback\(\(\) => \{\s*if \(paneVisibleRef\.current && restoreHeldDisclosure\(\)\) return;\s*if \(!paneVisibleRef\.current \|\| !pinnedRef\.current\) return;\s*cancelFollowScroll\(\);\s*scrollToBottom\(\);/, ); assert.match(transcript, /new ResizeObserver\(followScrollNow\)/); assert.match(transcript, /ro\.observe\(content, \{ box: "border-box" \}\)/); @@ -88,7 +89,7 @@ test("the transcript restores the held title inside its resize observer", () => test("holding a disclosure leaves follow without a delayed grab-back", () => { assert.match( transcript, - /const enterDisclosureReading = useCallback\(\(\) => \{\s*cancelFollowScroll\(\);\s*pinnedRef\.current = false;\s*setShowJump\(true\);/, + /const enterDisclosureReading = useCallback\(\(\) => \{\s*cancelFollowScroll\(\);\s*setPinned\(false\);\s*setShowJump\(true\);/, ); assert.match( transcript, @@ -102,15 +103,15 @@ test("holding a disclosure leaves follow without a delayed grab-back", () => { test("real input, a new turn and every navigation drop the held position", () => { assert.match( transcript, - /lastScrollGestureAtRef\.current = performance\.now\(\);\s*\/\/ Real input takes the viewport back from a held disclosure position\.\s*releaseDisclosureAnchor\(\);/, + /lastScrollGestureAtRef\.current = performance\.now\(\);\s*if \(isDisclosureAnchorHeld\(\)\) reanchorPrepend\(\);\s*releaseDisclosureAnchor\(\);/, ); assert.match( transcript, - /releaseDisclosureAnchor\(\);\s*cancelFollowScroll\(\);\s*pinnedRef\.current = true;\s*setShowJump\(false\);\s*scrollToBottom\(\);/, + /releaseDisclosureAnchor\(\);\s*cancelFollowScroll\(\);\s*setPinned\(true\);\s*setShowJump\(false\);\s*scrollToBottom\(\);/, ); assert.match( transcript, - /const jumpToLatest = useCallback\(\(\) => \{\s*releaseDisclosureAnchor\(\);\s*pinnedRef\.current = true;/, + /const jumpToLatest = useCallback\(\(\) => \{\s*invalidatePrepend\(\);\s*releaseDisclosureAnchor\(\);\s*setPinned\(true\);/, ); assert.match( transcript, @@ -118,6 +119,23 @@ test("real input, a new turn and every navigation drop the held position", () => ); }); +test("visible scrolling records retained offsets before paging or follow transitions", () => { + assert.match(transcript, + /const handleScroll = useCallback\(\(\) => \{\s*const el = scrollRef\.current;\s*if \(!el \|\| !paneVisibleRef\.current \|\| !transcriptHasLayout\(el\)\) return;\s*lastLaidOutScrollTopRef\.current = el\.scrollTop;/); + assert.match(transcript, /el\.scrollTop = retained;\s*recordScrollPosition\(el\.scrollTop\)/); + assert.doesNotMatch(transcript, /last(?:LaidOut)?ScrollTopRef\.current = retained/); +}); + +test("prepend invalidation is wired to navigation, new turns and unmount", () => { + assert.match(transcript, /useLayoutEffect\(invalidate, \[invalidate, readingWindow, searchRequestId, sessionId\]\)/); + assert.doesNotMatch(transcript, /useLayoutEffect\(invalidate, \[[^\]]*paneVisible/); + assert.match(transcript, /useLayoutEffect\(\(\) => \(\) => \{ controller\.cancel\(\); \}, \[controller\]\)/); + assert.match(transcript, /if \(turnStarted\) invalidatePrepend\(\)/); + assert.match(transcript, /if \(fresh\) invalidatePrepend\(\)/); + assert.match(transcript, /const recordSearchPosition = useCallback\([\s\S]*?invalidatePrepend\(\);\s*recordScrollPosition\(top\)/); + assert.match(transcript, /onPosition: recordSearchPosition,/); +}); + test("follow records the position the scroller actually reached", () => { // The intended target minus a fractional device pixel ratio was enough to // make the next scroll event look like the reader scrolling up. diff --git a/apps/desktop/test/transcript-scroll.test.mjs b/apps/desktop/test/transcript-scroll.test.mjs index ddb41246ee..ddea7fea12 100644 --- a/apps/desktop/test/transcript-scroll.test.mjs +++ b/apps/desktop/test/transcript-scroll.test.mjs @@ -7,6 +7,9 @@ import { isHistoryRevealPosition, isRecentScrollGesture, reduceTranscriptScroll, + capturePrependAnchor, + createTranscriptPrependController, + prependAnchorShift, transcriptHasLayout, } from "../src/lib/transcript-scroll.ts"; @@ -208,3 +211,321 @@ test("a pinned overflowing transcript is suppressed anywhere in the band", () => false, ); }); + +function prependGeometry() { + let scrollTop = 100; + const layout = { viewportTop: 500, rowContentTop: 130, rounding: 0 }; + const row = { + isConnected: true, + getAttribute: () => null, + closest: () => scroller, + getBoundingClientRect: () => { + const top = layout.viewportTop + layout.rowContentTop - scrollTop; + return { top, bottom: top + 50 }; + }, + }; + const scroller = { + get scrollTop() { return scrollTop; }, + set scrollTop(value) { scrollTop = Math.max(0, Math.min(value - layout.rounding, this.scrollHeight - this.clientHeight)); }, + scrollHeight: 1000, + clientHeight: 500, + getBoundingClientRect: () => ({ top: layout.viewportTop }), + querySelectorAll: () => [row], + contains: (node) => node === row, + }; + return { layout, row, scroller }; +} + +const messageIds = (...ids) => ids.map((id) => ({ id })); +const originalPage = messageIds("a", "b"); +const olderPage = messageIds("older", "a", "b"); +function prependFrame(messages = originalPage, overrides = {}) { + return { + messages, renderedMessages: messages, + windowSize: 80, historyLength: messages.length, mountedCount: messages.length, + ...overrides, + }; +} + +function prependReader() { + const geometry = prependGeometry(); + const controller = createTranscriptPrependController(); + controller.commit(prependFrame(), geometry.scroller, false); + return { ...geometry, controller }; +} + +test("continued scrolling without insertion has zero prepend correction", () => { + const { layout, row, scroller } = prependGeometry(); + const anchor = capturePrependAnchor(scroller); + assert.equal(anchor.top, 30); + assert.equal(anchor.scrollTop, 100); + scroller.scrollTop = 40; + assert.equal(prependAnchorShift(anchor, row.getBoundingClientRect().top - layout.viewportTop, scroller.scrollTop), 0); +}); + +test("a 200px prepend preserves the reader's 60px upward scroll", () => { + const { controller, layout, row, scroller } = prependReader(); + const request = controller.begin("page", scroller); + scroller.scrollTop = 40; + layout.rowContentTop += 200; + // Moving the whole pane is not insertion inside it. + layout.viewportTop += 25; + controller.settle(request); + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), 240); + assert.equal(row.getBoundingClientRect().top - layout.viewportTop, 90); +}); + +test("the raw page cannot consume its capture before deferred rows commit", () => { + const { controller, layout, scroller } = prependReader(); + const request = controller.begin("page", scroller); + controller.settle(request); + scroller.scrollTop = 40; + assert.equal(controller.commit(prependFrame(olderPage, { renderedMessages: originalPage }), scroller, false), null); + assert.equal(scroller.scrollTop, 40); + assert.equal(controller.begin("page", scroller), null, "one read owns the pending deferred commit"); + layout.rowContentTop += 200; + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), 240); + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), null, "restore is consumed exactly once"); +}); + +test("tail append and same-count updates do not consume a pending page capture", () => { + const { controller, layout, scroller } = prependReader(); + const request = controller.begin("page", scroller); + scroller.scrollTop = 40; + scroller.scrollHeight += 600; + assert.equal(controller.commit(prependFrame(messageIds("a", "b", "tail")), scroller, false), null); + assert.equal(controller.commit(prependFrame(messageIds("a", "edited")), scroller, false), null); + assert.equal(controller.commit(prependFrame(messageIds("older", "a")), scroller, false), null, "same count is not a page prepend"); + assert.equal(scroller.scrollTop, 40); + controller.settle(request); + layout.rowContentTop += 200; + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), 240); +}); + +test("a mounted page can precede promise completion without restoring twice or sticking loading", () => { + const { controller, layout, scroller } = prependReader(); + const request = controller.begin("page", scroller); + layout.rowContentTop += 200; + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), 300); + assert.equal(controller.loading, true); + assert.equal(controller.settle(request), true); + assert.equal(controller.loading, false); + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), null); + assert.ok(controller.begin("page", scroller)); +}); + +test("a no-op completed read retires its capture before unrelated updates", () => { + const { controller, layout, scroller } = prependReader(); + const request = controller.begin("page", scroller); + controller.settle(request); + assert.equal(controller.commit(prependFrame(), scroller, false), null); + assert.equal(controller.loading, false); + assert.equal(controller.begin("page", scroller), null, "the observer cannot loop on the same empty edge"); + scroller.scrollTop = 40; + layout.rowContentTop += 200; + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), null); + assert.equal(scroller.scrollTop, 40); + assert.ok(controller.begin("page", scroller)); +}); + +test("rejected reads release capture and duplicate in-flight reads are refused", async () => { + const { controller, layout, scroller } = prependReader(); + const request = controller.begin("page", scroller); + assert.equal(controller.begin("page", scroller), null); + assert.equal(controller.begin("window", scroller), null); + await Promise.reject(new Error("read failed")).catch(() => controller.settle(request, true)); + assert.equal(controller.loading, false); + layout.rowContentTop += 200; + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), null); + assert.equal(scroller.scrollTop, 100); +}); + +test("navigation cancellation makes a stale promise unable to finish a later request", () => { + const { controller, layout, scroller } = prependReader(); + const obsolete = controller.begin("page", scroller); + controller.cancel(); + scroller.scrollTop = 40; + const current = controller.begin("page", scroller); + assert.equal(controller.settle(obsolete), false); + assert.equal(controller.settle(obsolete, true), false); + assert.equal(controller.loading, true); + layout.rowContentTop += 200; + controller.settle(current); + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), 240); +}); + +test("cancellation drops obsolete geometry, while an explicit retry releases a failed edge", () => { + const { controller, layout, scroller } = prependReader(); + const rejected = controller.begin("page", scroller); + controller.settle(rejected, true); + assert.equal(controller.begin("page", scroller), null); + const retry = controller.begin("page", scroller, true); + assert.ok(retry); + controller.cancel(); + layout.rowContentTop += 200; + assert.equal(controller.settle(retry), false); + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), null); + assert.equal(scroller.scrollTop, 100); +}); + +test("a page outside the mounted window waits for local growth without counting the tail", () => { + const { controller, layout, scroller } = prependReader(); + const request = controller.begin("page", scroller); + controller.settle(request); + scroller.scrollTop = 40; + scroller.scrollHeight += 600; // a concurrent tail append, not mounted older rows + const loaded = prependFrame(olderPage, { mountedCount: 2, historyLength: 100 }); + assert.equal(controller.commit(loaded, scroller, false), 40); + assert.ok(controller.begin("window", scroller)); + assert.equal(controller.commit(loaded, scroller, false), null, "the state request alone is not a DOM commit"); + scroller.scrollTop = 20; + layout.rowContentTop += 200; + const mounted = { ...loaded, windowSize: 100, mountedCount: 100 }; + assert.equal(controller.commit(mounted, scroller, false), 220); +}); + +test("local growth without additional mounted rows retires the capture without scrolling", () => { + const { controller, layout, scroller } = prependReader(); + assert.ok(controller.begin("window", scroller)); + layout.rowContentTop += 50; + assert.equal(controller.commit(prependFrame(originalPage, { windowSize: 100 }), scroller, false), null); + assert.equal(scroller.scrollTop, 100); + assert.ok(controller.begin("page", scroller)); +}); + +test("underfilled auto-paging retains bottom ownership after insertion starts overflowing", () => { + const { controller, layout, scroller } = prependReader(); + scroller.scrollHeight = 400; + scroller.scrollTop = 0; + assert.equal(isHistoryRevealPosition(scroller, true), true); + const request = controller.begin("page", scroller); + controller.settle(request); + layout.rowContentTop += 500; + scroller.scrollHeight = 900; + assert.equal(controller.commit(prependFrame(olderPage), scroller, true), 400); + assert.equal(isHistoryRevealPosition(scroller, true), false); +}); + +test("correction records the achieved fractional scrollTop, not the intended target", () => { + const { controller, layout, scroller } = prependReader(); + const request = controller.begin("page", scroller); + scroller.scrollTop = 40; + layout.rowContentTop += 200; + layout.rounding = 0.25; + controller.settle(request); + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), 239.75); +}); + +test("a removed reading row never falls back to a possibly unrelated tail height delta", () => { + const { controller, row, scroller } = prependReader(); + const request = controller.begin("page", scroller); + controller.settle(request); + row.isConnected = false; + scroller.scrollHeight += 2000; + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), 100); +}); + +test("a regrouped turn restores its remounted message by stable identity", () => { + const { controller, layout, row, scroller } = prependReader(); + row.getAttribute = () => "a"; + const request = controller.begin("page", scroller); + scroller.scrollTop = 40; + row.isConnected = false; + const replacement = { ...row, isConnected: true }; + scroller.querySelectorAll = () => [replacement]; + scroller.contains = (node) => node === replacement; + layout.rowContentTop += 200; + controller.settle(request); + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), 240); +}); + +test("a nested scroller's row cannot become the outer prepend anchor", () => { + const { row, scroller } = prependGeometry(); + const nestedRow = { ...row, closest: () => ({}) }; + scroller.querySelectorAll = () => [nestedRow, row]; + assert.equal(capturePrependAnchor(scroller).node, row); +}); + +test("a page committed while hidden keeps its capture until the pane is laid out", () => { + const { controller, layout, scroller } = prependReader(); + const request = controller.begin("page", scroller); + // The reader moved after capture; reveal restores that last visible offset. + scroller.scrollTop = 40; + layout.rowContentTop += 200; + assert.equal(controller.commit(prependFrame(olderPage), null, false), null); + assert.equal(controller.settle(request), true); + assert.equal(controller.begin("page", scroller), null, "reveal must not replace the existing read"); + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), 240); + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), null); +}); + +for (const outcome of ["no-op", "rejected"]) { + test(`a ${outcome} read completed while hidden retires its capture`, async () => { + const { controller, layout, scroller } = prependReader(); + const request = controller.begin("page", scroller); + scroller.scrollTop = 40; + assert.equal(controller.commit(prependFrame(), null, false), null); + if (outcome === "rejected") { + await Promise.reject(new Error("hidden read failed")).catch(() => controller.settle(request, true)); + } else { + assert.equal(controller.settle(request), true); + } + assert.equal(controller.commit(prependFrame(), null, false), null); + assert.equal(controller.loading, false); + assert.equal(controller.begin("page", scroller), null, "reveal cannot retry the empty/failed edge automatically"); + // Reveal directly into an unrelated insertion, without an intervening + // visible no-op commit that could mask failure to retire while hidden. + layout.rowContentTop += 200; + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), null); + assert.equal(scroller.scrollTop, 40, "an unrelated later insertion cannot consume the hidden capture"); + assert.ok(controller.begin("page", scroller), "the changed edge can start a new read"); + }); +} + +test("navigation while hidden prevents a stale promise from owning the revealed request", async () => { + const { controller, layout, scroller } = prependReader(); + const obsolete = controller.begin("page", scroller); + let finishObsolete; + const stale = new Promise((resolve) => { finishObsolete = resolve; }) + .then(() => controller.settle(obsolete)); + scroller.scrollTop = 40; + assert.equal(controller.commit(prependFrame(olderPage), null, false), null); + assert.equal(controller.cancel(), true); + const destination = messageIds("destination"); + assert.equal(controller.commit(prependFrame(destination), null, false), null); + assert.equal(controller.commit(prependFrame(destination), scroller, false), null); + const current = controller.begin("page", scroller); + assert.ok(current); + finishObsolete(); + assert.equal(await stale, false); + assert.equal(controller.settle(obsolete, true), false, "a stale rejection cannot retire the new capture either"); + assert.equal(controller.loading, true); + layout.rowContentTop += 200; + assert.equal(controller.settle(current), true); + assert.equal(controller.commit(prependFrame(messageIds("earlier-destination", "destination")), scroller, false), 240); + assert.equal(controller.loading, false); +}); + +test("a disclosure hands its achieved position to the original pending page", () => { + const { controller, layout, scroller } = prependReader(); + const request = controller.begin("page", scroller); + layout.rowContentTop += 80; // disclosure changed the current reading geometry + scroller.querySelectorAll = () => { throw new Error("handoff must not scan every mounted row"); }; + controller.reanchor(scroller); + assert.equal(controller.begin("page", scroller), null, "the original read still owns paging"); + scroller.scrollTop = 40; + layout.rowContentTop += 200; + controller.settle(request); + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), 240); +}); + +test("a held disclosure can own insertion without a second prepend correction", () => { + const { controller, layout, scroller } = prependReader(); + const request = controller.begin("page", scroller); + layout.rowContentTop += 200; + scroller.scrollTop = 300; // held-title restoration already positioned this commit + controller.reanchor(scroller); + controller.settle(request); + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), 300); + assert.equal(controller.commit(prependFrame(olderPage), scroller, false), null); +}); diff --git a/apps/desktop/test/transcript-style.test.mjs b/apps/desktop/test/transcript-style.test.mjs index e8c8f43736..cb3b7c9ae4 100644 --- a/apps/desktop/test/transcript-style.test.mjs +++ b/apps/desktop/test/transcript-style.test.mjs @@ -126,8 +126,9 @@ test("tool details do not add a second visual indent", () => { * The width regression this guards: a content-sized `inline-flex` disclosure * header stopped at its own label, so a tool call never used the conversation * width — it stayed narrower than the prose, ignored the dragged band width, - * and let a long label overrun the chip. The header row now claims the band at - * every level, the label ellipsizes, and the caret trails the row. + * and let a long label overrun the chip. Tool headers claim the band; the + * whole-turn summary is content-sized independently of its detail rows. + * Labels ellipsize and the caret trails the header. */ test("tool-call disclosure headers span the conversation band", () => { const header = stylesSource.match(/\n\.tool-activity-header \{([^}]*)\}/)?.[1]; @@ -148,11 +149,10 @@ test("tool-call disclosure headers span the conversation band", () => { assert.ok(caret); assert.match(caret, /margin-inline-start:\s*auto;/); - // No level keeps its own width or label override: every disclosure header - // resolves through the single base row. + // Nested activity headers retain the base width; turn summaries may shrink. assert.doesNotMatch( stylesSource, - /\.(process-activity-group|turn-process) > \.tool-activity-header[^{]*\{[^}]*width:/, + /\.process-activity-group > \.tool-activity-header[^{]*\{[^}]*width:/, ); assert.doesNotMatch( stylesSource, @@ -230,6 +230,10 @@ test("transcript density and hover actions are quiet", () => { stylesSource, /\.message-actions \{[\s\S]*?opacity:\s*0;[\s\S]*?\.message-row:hover \.message-actions/, ); + assert.match( + stylesSource, + /html\.pointer-outside \.message-row:hover \.message-actions:not\(:focus-within\),\s*html\.pointer-outside \.message-actions:not\(:focus-within\)/, + ); assert.match(stylesSource, /\.message-row\.user \.message-actions \{[\s\S]*?justify-content:\s*flex-end;/); }); @@ -589,3 +593,24 @@ test("regenerate history pager and stable revision family are wired", async () = assert.match(sharedSource, /revisionRootId\?: string/); assert.match(sharedSource, /MessageRevisionSummary/); }); + +test("turn headers stay short in both states without narrowing detail rows", () => { + assert.match(stylesSource, /\.turn-process > \.tool-activity-header\s*\{\s*width: fit-content;\s*max-width: 100%;/); + assert.doesNotMatch(stylesSource, /\.turn-process:not\(\.open\) > \.tool-activity-header/); + assert.match(stylesSource, /\.tool-activity-header\s*\{\s*display: flex;\s*width: 100%;/); +}); + +test("partial tail pages absorb underfilled space above their content without a scroll spacer", () => { + const scroller = stylesSource.match(/\.thread-scroll\[data-tail-aligned="true"\]\s*\{([^}]*)\}/)?.[1]; + const content = stylesSource.match(/\.thread-scroll\[data-tail-aligned="true"\] > \.thread-content\s*\{([^}]*)\}/)?.[1]; + assert.ok(scroller); + assert.ok(content); + assert.match(scroller, /display:\s*flex;/); + assert.match(scroller, /flex-direction:\s*column;/); + assert.match(content, /flex:\s*0 0 auto;/); + assert.match(content, /margin-top:\s*auto;/); + assert.doesNotMatch(scroller + content, /min-height|padding|justify-content:\s*flex-end/); + assert.match(transcriptSource, /data-tail-aligned=\{alignHistoryTail \|\| undefined\}/); + assert.match(transcriptSource, /const alignHistoryTail = !readingWindow && hasMoreBefore/); + assert.doesNotMatch(transcriptSource, /tailAlignedRef/); +}); diff --git a/apps/desktop/test/transcript-window.test.mjs b/apps/desktop/test/transcript-window.test.mjs index 0953380f4c..36e88098e8 100644 --- a/apps/desktop/test/transcript-window.test.mjs +++ b/apps/desktop/test/transcript-window.test.mjs @@ -213,23 +213,24 @@ test("the transcript bounds mounted history and escalates at the top", () => { /isHistoryRevealPosition\(el, pinnedRef\.current && !gesturing\)/, ); const reachTop = transcript.match( - /const reachTop = useCallback\(\(\) => \{([\s\S]*?)\n \}, \[loadOlder, windowSize\]\);/, + /const reachTop = useCallback\(\(retry = false\) => \{([\s\S]*?)\n \}, \[controller,[^\]]*windowSize\]\);/, )?.[1]; assert.ok(reachTop, "reachTop must exist with a stable identity"); - assert.match(reachTop, /growTranscriptWindow\(windowSize, historyLengthRef\.current\)/); - assert.match(reachTop, /prependHeightRef\.current = el\.scrollHeight/); + assert.match(reachTop, /growTranscriptWindow\(windowSize, frame\.historyLength\)/); + assert.match(reachTop, /controller\.begin\("window", el\)/); assert.match(reachTop, /setWindowSize\(grown\)/); - assert.match(reachTop, /loadOlder\(\)/); + assert.match(reachTop, /controller\.begin\("page", el, retry\)/); + assert.match(reachTop, /await onLoadOlder\(\)/); }); -test("mounting rows above the viewport is anchored like a fetched page", () => { - // Both add height above the reading position, so both take the same - // pre-paint correction; without `windowSize` here, growing the window would - // shove the rows the user is reading down the screen. +test("prepend restoration follows the actual mounted projection in layout", () => { + // Runtime geometry/lifecycle cases live in transcript-scroll.test.mjs. This + // wiring guard ensures the controller is fed the deferred DOM, not raw count. assert.match( transcript, - /const delta = el\.scrollHeight - previousHeight;[\s\S]*?\}, \[messages\.length, windowSize\]\)/, + /useLayoutEffect\(\(\) => \{\s*if \(paneVisibleRef\.current\) restoreHeldDisclosure\(\);\s*commitPrepend\(\{\s*messages, renderedMessages, windowSize,[\s\S]*?mountedCount: historyEntries\.length,[\s\S]*?renderedMessages, restoreHeldDisclosure, windowSize\]\)/, ); + assert.doesNotMatch(transcript, /\[messages\.length, windowSize\]/); }); test("the window is owned per session pane, so no switch can inherit a budget", () => { @@ -246,16 +247,16 @@ test("the window is owned per session pane, so no switch can inherit a budget", }); test("the minimap keeps message dashes reachable and represents withheld history explicitly", () => { - // Message dashes still resolve to mounted DOM nodes. Older loaded/unloaded - // history is one honest continuation control, never a phantom message dash. + // Message dashes still resolve to mounted DOM nodes. Both bounded and fully + // mounted transcripts use entry projection, so grouped steering cannot create + // a marker whose only anchor is hidden inside a collapsed process. assert.match(minimap, /querySelectorAll\("\[data-minimap-id\]"\)/); assert.match(transcript, /messages=\{minimapMessages\}/); assert.match(transcript, /hasEarlier=\{hasEarlierHistory\}/); assert.match(transcript, /onRevealEarlier=\{revealEarlierHistory\}/); - assert.match( - transcript, - /transcriptWindow\.bounded\s*\?\s*transcriptEntryMessages\(/, - ); + assert.match(transcript, /const minimapMessages = useMemo\([\s\S]*?transcriptEntryMessages\(/); + assert.doesNotMatch(transcript, /transcriptWindow\.bounded\s*\?\s*transcriptEntryMessages\(/); + assert.doesNotMatch(transcript, /:\s*visible,/); assert.match( transcript, /const hasEarlierHistory = transcriptWindow\.hiddenAbove > 0 \|\| hasMoreBefore/, @@ -272,7 +273,7 @@ test("a visible history boundary advances without waiting for a scroll event", ( // stalls with loaded rows unmounted. assert.match( transcript, - /hasEarlierHistory,[\s\S]*?hydrationTick,[\s\S]*?loadingOlder,[\s\S]*?messages\.length,[\s\S]*?reachTop,[\s\S]*?sessionId,[\s\S]*?transcriptWindow\.hiddenAbove,/, + /hasEarlierHistory,[\s\S]*?hydrationTick,[\s\S]*?loadingOlder,[\s\S]*?renderedMessages,[\s\S]*?reachTop,[\s\S]*?sessionId,[\s\S]*?transcriptWindow\.hiddenAbove,/, ); // Escalation is bounded per run: one growth step or one page request, and the // effect only re-runs when that step actually changed the projection. diff --git a/apps/desktop/test/turn-file-summary.test.mjs b/apps/desktop/test/turn-file-summary.test.mjs new file mode 100644 index 0000000000..db259ce0b6 --- /dev/null +++ b/apps/desktop/test/turn-file-summary.test.mjs @@ -0,0 +1,624 @@ +import assert from "node:assert/strict"; +import { register } from "node:module"; +import test from "node:test"; + +register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); + +const { buildTranscriptEntries } = await import("../src/lib/assistant-turns.ts"); +const { summarizeTurnFileChanges } = await import( + "../src/lib/turn-file-summary.ts" +); +const { createWorkPanelSlice } = await import("../src/stores/slices/work-panel-slice.ts"); +const { transcriptViewMessages } = await import("../src/lib/transcript-reading.ts"); +const { createTranscriptSlice } = await import("../src/stores/slices/transcript-slice.ts"); +const { createTranscriptReadingRuntime } = await import("../src/stores/runtime/transcript-reading-runtime.ts"); +const { api } = await import("../src/lib/api.ts"); +const { reviewChangesFromMessage } = await import("../src/lib/workspace-review.ts"); + +const message = (id, role, extra = {}) => ({ + id, + role, + content: "", + createdAt: "2026-09-20T00:00:00.000Z", + ...extra, +}); + +const review = (snapshotId, path, extra = {}) => ({ + version: 1, + snapshotId, + messageId: `message-${snapshotId}`, + path, + operation: "edit", + status: "modified", + state: "active", + additions: 2, + deletions: 1, + hunks: [{ header: "@@ -1 +1 @@", lines: [{ type: "add", text: "next" }] }], + reversible: true, + ...extra, +}); + +const tool = (id, toolName, details, extra = {}) => + message(id, "tool", { + toolName, + toolStatus: "success", + toolCallId: `call-${id}`, + toolResult: { details }, + ...extra, + }); + +const turnEntries = (messages) => + buildTranscriptEntries(messages).entries.filter( + (entry) => entry.kind === "assistant-turn", + ); + +const workspaceEdit = (id, snapshotId, path, extraReview = {}, extraMessage = {}) => + tool( + id, + "Edit", + { root: "workspace", review: review(snapshotId, path, extraReview) }, + extraMessage, + ); + +const shell = (id, reviews, capture = "complete", extra = {}) => + tool( + id, + "Bash", + { + root: "workspace", + exitCode: 0, + reviews, + reviewCapture: { status: capture }, + }, + extra, + ); + +test("visual turns are summarized independently without round contamination", () => { + const [first, second] = turnEntries([ + message("user-1", "user"), + workspaceEdit("edit-1", "snapshot-1", "src/first.ts"), + message("answer-1", "assistant", { content: "First done" }), + message("user-2", "user"), + shell("shell-2", [review("snapshot-2", "src/second.ts")]), + message("answer-2", "assistant", { content: "Second done" }), + ]); + + assert.deepEqual( + summarizeTurnFileChanges(first).files.map((file) => file.path), + ["src/first.ts"], + ); + assert.deepEqual( + summarizeTurnFileChanges(second).files.map((file) => file.path), + ["src/second.ts"], + ); +}); + +test("screenshot workflow records the three Bash-copied workspace files", () => { + const failedWrite = tool( + "workspace-write", + "Write", + { root: "workspace", review: review("failed-write", "index.html") }, + { toolStatus: "error" }, + ); + const scratchWrite = tool("scratch-write", "Write", { + root: "scratch", + review: review("scratch-write", "index.html"), + }); + const copied = shell("copy-files", [ + review("shell-html", "index.html", { additions: 20, deletions: 0 }), + review("shell-css", "styles.css", { additions: 12, deletions: 0 }), + review("shell-js", "app.js", { additions: 8, deletions: 0 }), + ]); + const [entry] = turnEntries([ + failedWrite, + scratchWrite, + copied, + message("answer", "assistant", { content: "Done" }), + ]); + const summary = summarizeTurnFileChanges(entry); + + assert.equal(summary.fileCount, 3); + assert.equal(summary.operationCount, 3); + assert.deepEqual( + summary.files.map((file) => file.path), + ["app.js", "styles.css", "index.html"], + ); + assert.equal(summary.hasCompleteCapture, true); + assert.equal(summary.hasUnavailableCapture, false); +}); + +test("repeated paths group operations and dedupe repeated snapshots", () => { + const [entry] = turnEntries([ + workspaceEdit("early", "snapshot-early", "src/repeated.ts", { + additions: 3, + deletions: 0, + }), + shell("copy", [ + review("snapshot-other", "src/other.ts", { additions: 1, deletions: 4 }), + review("snapshot-late", "src/repeated.ts", { additions: 5, deletions: 2 }), + review("snapshot-late", "src/repeated.ts", { additions: 6, deletions: 2 }), + ]), + message("answer", "assistant", { content: "Done" }), + ]); + const summary = summarizeTurnFileChanges(entry); + + assert.equal(summary.fileCount, 2); + assert.equal(summary.operationCount, 3); + assert.deepEqual( + summary.files.map((file) => file.path), + ["src/repeated.ts", "src/other.ts"], + ); + assert.deepEqual( + summary.files[0].entries.map((record) => record.change.snapshotId), + ["snapshot-late", "snapshot-early"], + ); + assert.deepEqual([summary.additions, summary.deletions], [10, 6]); +}); + +test("failed Bash still contributes valid captured mutations", () => { + const [entry] = turnEntries([ + shell( + "failed-shell", + [review("failed-shell-change", "partial-output.html")], + "partial", + { toolStatus: "error" }, + ), + message("answer", "assistant", { content: "Command failed" }), + ]); + const summary = summarizeTurnFileChanges(entry); + + assert.equal(summary.fileCount, 1); + assert.equal(summary.files[0].path, "partial-output.html"); + assert.equal(summary.hasPartialCapture, true); +}); + +test("complete no-change shell captures do not create an empty summary", () => { + const [entry] = turnEntries([ + shell("read-only", []), + message("answer", "assistant", { content: "No changes" }), + ]); + const summary = summarizeTurnFileChanges(entry); + + assert.equal(summary.fileCount, 0); + assert.equal(summary.hasCompleteCapture, true); + assert.equal(summary.hasPartialCapture, false); + assert.equal(summary.hasUnavailableCapture, false); +}); + +test("partial and legacy shell scope remain visible without invented files", () => { + const [partialEntry] = turnEntries([ + shell("partial", [], "partial"), + message("partial-answer", "assistant", { content: "Partial" }), + ]); + const [legacyEntry] = turnEntries([ + tool("legacy", "Bash", { exitCode: 0 }), + message("legacy-answer", "assistant", { content: "Legacy" }), + ]); + + assert.equal(summarizeTurnFileChanges(partialEntry).hasPartialCapture, true); + assert.equal(summarizeTurnFileChanges(legacyEntry).hasUnavailableCapture, true); + assert.equal(summarizeTurnFileChanges(legacyEntry).fileCount, 0); +}); + +test("rolled-back records stay visible but leave active totals", () => { + const [entry] = turnEntries([ + shell("changes", [ + review("snapshot-rolled", "src/a.ts", { + state: "rolledBack", + additions: 8, + deletions: 3, + }), + review("snapshot-active", "src/a.ts", { additions: 2, deletions: 1 }), + ]), + message("answer", "assistant", { content: "Done" }), + ]); + const summary = summarizeTurnFileChanges(entry); + + assert.equal(summary.operationCount, 2); + assert.equal(summary.activeOperationCount, 1); + assert.equal(summary.rolledBackOperationCount, 1); + assert.deepEqual([summary.additions, summary.deletions], [2, 1]); +}); + +test("binary and truncated records remain available without line hunks", () => { + const [entry] = turnEntries([ + shell("binary-shell", [ + review("snapshot-binary", "asset.bin", { + binary: true, + hunks: [], + additions: 0, + deletions: 0, + }), + review("snapshot-large", "generated.txt", { + truncated: true, + hunks: [], + additions: 100, + deletions: 20, + }), + ]), + message("answer", "assistant", { content: "Done" }), + ]); + const summary = summarizeTurnFileChanges(entry); + + assert.equal(summary.files.find((file) => file.path === "asset.bin").entries[0].change.binary, true); + assert.equal(summary.files.find((file) => file.path === "generated.txt").entries[0].change.truncated, true); +}); + +test("Task-owned delegate edits are included while parent Bash .gradle caches are hidden", () => { + const task = tool("task", "Task", { status: "running" }); + const delegateEdits = Array.from({ length: 6 }, (_, index) => + workspaceEdit( + `delegate-${index + 1}`, + `snapshot-delegate-${index + 1}`, + `src/delegate-${index + 1}.ts`, + {}, + { parentToolCallId: "call-task", agentName: "fixer" }, + ), + ); + const caches = shell( + "parent-build", + Array.from({ length: 7 }, (_, index) => + review(`cache-${index + 1}`, `.gradle/caches/cache-${index + 1}.bin`, { + binary: true, + additions: 0, + deletions: 0, + }), + ), + ); + const direct = workspaceEdit("direct", "snapshot-direct", "src/direct.ts"); + const [entry] = turnEntries([ + task, + ...delegateEdits, + caches, + direct, + message("answer", "assistant", { content: "Done" }), + ]); + const summary = summarizeTurnFileChanges(entry); + + assert.equal(summary.fileCount, 7); + assert.equal(summary.files.some((file) => file.path.includes(".gradle")), false); + assert.deepEqual( + new Set(summary.files.map((file) => file.path)), + new Set(["src/direct.ts", ...delegateEdits.map((_, index) => `src/delegate-${index + 1}.ts`)]), + ); + assert.equal( + summary.files.find((file) => file.path === "src/delegate-1.ts").entries[0].message, + delegateEdits[0], + ); + assert.equal(summary.hasBashTool, true); +}); + +test("delayed delegate records remain owned by their Task turn", () => { + const task = tool("task", "Task", { status: "running" }); + const delayed = workspaceEdit( + "delayed", + "snapshot-delayed", + "src/delayed.ts", + {}, + { parentToolCallId: "call-task", agentName: "fixer" }, + ); + const [first, second] = turnEntries([ + message("user-1", "user"), + task, + message("answer-1", "assistant", { content: "Delegated" }), + message("user-2", "user"), + message("answer-2", "assistant", { content: "Later turn" }), + delayed, + ]); + + assert.deepEqual( + summarizeTurnFileChanges(first).files.map((file) => file.path), + ["src/delayed.ts"], + ); + assert.equal(summarizeTurnFileChanges(second).fileCount, 0); +}); + +test("resumed delegate edits stay with their original Task turns", () => { + const firstTask = tool("task-1", "Task", { delegationId: "delegate-1" }); + const firstEdit = workspaceEdit( + "delegate-first", + "snapshot-first", + "src/first-delegate.ts", + {}, + { parentToolCallId: "call-task-1", agentName: "fixer" }, + ); + const secondTask = tool( + "task-2", + "Task", + { delegationId: "delegate-2" }, + { toolArgs: { resume: "delegate-1" } }, + ); + const secondEdit = workspaceEdit( + "delegate-second", + "snapshot-second", + "src/second-delegate.ts", + {}, + { parentToolCallId: "call-task-2", agentName: "fixer" }, + ); + const [first, second] = turnEntries([ + message("user-1", "user"), + firstTask, + firstEdit, + message("answer-1", "assistant", { content: "First pass" }), + message("user-2", "user"), + secondTask, + secondEdit, + message("answer-2", "assistant", { content: "Second pass" }), + ]); + + assert.deepEqual( + summarizeTurnFileChanges(first).files.map((file) => file.path), + ["src/first-delegate.ts"], + ); + assert.deepEqual( + summarizeTurnFileChanges(second).files.map((file) => file.path), + ["src/second-delegate.ts"], + ); +}); + +test("same-turn resume groups original Task ownership without duplicating files", () => { + const [entry] = turnEntries([ + message("user", "user"), + tool("task-1", "Task", { delegationId: "delegate-1" }), + workspaceEdit( + "delegate-first", + "snapshot-first", + "src/first.ts", + {}, + { parentToolCallId: "call-task-1" }, + ), + tool( + "task-2", + "Task", + { delegationId: "delegate-2" }, + { toolArgs: { resume: "delegate-1" } }, + ), + workspaceEdit( + "delegate-second", + "snapshot-second", + "src/second.ts", + {}, + { parentToolCallId: "call-task-2" }, + ), + message("answer", "assistant", { content: "Done" }), + ]); + + assert.deepEqual( + summarizeTurnFileChanges(entry).files.map((file) => file.path), + ["src/second.ts", "src/first.ts"], + ); +}); + +test("interleaved parent and delegate edits keep raw latest-first snapshot order", () => { + const path = "src/shared.ts"; + const [entry] = turnEntries([ + message("user", "user"), + tool("task", "Task", { status: "running" }), + workspaceEdit( + "delegate-early", + "snapshot-delegate-early", + path, + {}, + { parentToolCallId: "call-task" }, + ), + workspaceEdit("parent-middle", "snapshot-parent-middle", path), + workspaceEdit( + "delegate-late", + "snapshot-delegate-late", + path, + {}, + { parentToolCallId: "call-task" }, + ), + message("answer", "assistant", { content: "Done" }), + ]); + + assert.deepEqual( + summarizeTurnFileChanges(entry).files[0].entries.map( + (record) => record.change.snapshotId, + ), + [ + "snapshot-delegate-late", + "snapshot-parent-middle", + "snapshot-delegate-early", + ], + ); +}); + +test("orphan delegate records are not attached to an unrelated Task turn", () => { + const [entry] = turnEntries([ + tool("task", "Task", { status: "running" }), + workspaceEdit( + "orphan", + "snapshot-orphan", + "src/orphan.ts", + {}, + { parentToolCallId: "call-missing-task", agentName: "fixer" }, + ), + message("answer", "assistant", { content: "Done" }), + ]); + + assert.equal(summarizeTurnFileChanges(entry).fileCount, 0); +}); + +test("malformed and legacy records keep the same counts without exposing hunks", () => { + const hunks = Array.from({ length: 8 }, (_, index) => ({ + header: `@@ -${index} +${index} @@`, + lines: [{ type: "add", text: `line-${index}` }], + })); + const [entry] = turnEntries([ + shell("mixed", [ + { version: 2, snapshotId: "legacy", path: "legacy.ts" }, + { ...review("invalid", "bad.ts"), additions: -1 }, + review("ok", "src/ok.ts", { hunks, additions: 4, deletions: 0 }), + review("ok", "src/ok.ts", { hunks, additions: 7, deletions: 2 }), + ]), + message("answer", "assistant", { content: "Done" }), + ]); + const summary = summarizeTurnFileChanges(entry); + + assert.equal(summary.fileCount, 1); + assert.equal(summary.operationCount, 1); + assert.equal(summary.files[0].path, "src/ok.ts"); + assert.equal(summary.files[0].entries[0].change.snapshotId, "ok"); + assert.deepEqual([summary.additions, summary.deletions], [7, 2]); + assert.equal("hunks" in summary.files[0].entries[0].change, false); +}); + +function reviewStore(messages, overrides = {}, pending = false) { + let state = { + activeSessionId: "session-a", messages, sessionCompactions: {}, transcriptViews: {}, + workPanelOpen: false, workPanelTabs: [], activeWorkPanelTabId: null, + workPanelFileRequest: null, workPanelContexts: {}, ...overrides, + }; + const actions = createWorkPanelSlice({ + get: () => state, + set: (update) => { state = { ...state, ...(typeof update === "function" ? update(state) : update) }; }, + isSessionSelectionPending: () => pending, + }); + return { actions, get: () => state }; +} + +const selectedFile = (entry) => { + const file = summarizeTurnFileChanges(entry).files[0]; + return { + sessionId: "session-a", turnId: entry.id, selectedPath: file.path, + snapshotIds: file.entries.map(({ change }) => change.snapshotId), + }; +}; + +test("summary navigation uses the same compaction boundaries as the mounted transcript", () => { + const messages = [ + workspaceEdit("before", "snapshot-before", "src/shared.ts"), + message("checkpoint", "assistant", { content: "Checkpoint" }), + workspaceEdit("after", "snapshot-after", "src/shared.ts"), + message("answer", "assistant", { content: "Done" }), + ]; + const marks = [{ throughMessageId: "checkpoint" }]; + const turn = buildTranscriptEntries(messages, marks).entries.at(-1); + assert.equal(turn.id, "after"); + const store = reviewStore(messages, { sessionCompactions: { "session-a": marks } }); + store.actions.openTurnFileReview(selectedFile(turn)); + assert.equal(store.get().workPanelOpen, true); + assert.equal(store.get().activeWorkPanelTabId, "review"); + assert.deepEqual(store.get().workPanelContexts["session-a"].reviewSelection.snapshotIds, ["snapshot-after"]); +}); + +test("summary navigation resolves a loaded history range instead of only the live tail", () => { + const live = [workspaceEdit("live", "snapshot-live", "src/shared.ts")]; + const history = [workspaceEdit("history", "snapshot-history", "src/shared.ts")]; + for (const focus of [null, { sessionId: "session-a", messageId: "history", query: "shared", requestId: 1 }]) { + const view = { messages: history, messageStart: 0, hasMoreBefore: false, hasMoreAfter: true, focus, loading: null }; + const turn = buildTranscriptEntries(transcriptViewMessages(live, view)).entries[0]; + const store = reviewStore(live, { transcriptViews: { "session-a": view } }); + store.actions.openTurnFileReview(selectedFile(turn)); + assert.equal(store.get().workPanelOpen, true); + assert.ok(store.get().workPanelContexts["session-a"].reviewSelection.snapshotIds.includes("snapshot-history")); + } +}); + +test("review navigation rejects stale sessions, pending selection and unrelated snapshots", () => { + const messages = [workspaceEdit("edit", "snapshot", "src/a.ts")]; + const selection = selectedFile(turnEntries(messages)[0]); + for (const invalid of [ + { ...selection, sessionId: "session-b" }, + { ...selection, turnId: "missing" }, + { ...selection, snapshotIds: ["foreign"] }, + { ...selection, selectedPath: "src/b.ts" }, + ]) { + const store = reviewStore(messages); + store.actions.openTurnFileReview(invalid); + assert.equal(store.get().workPanelOpen, false); + } + const pending = reviewStore(messages, {}, true); + pending.actions.openTurnFileReview(selection); + assert.equal(pending.get().workPanelOpen, false); +}); + +test("explicit file review leaves the subagent detail surface", () => { + const messages = [workspaceEdit("edit", "snapshot", "src/a.ts")]; + const store = reviewStore(messages, { + subagentPanel: { sessionId: "session-a", delegationId: "delegate" }, + }); + store.actions.openTurnFileReview(selectedFile(turnEntries(messages)[0])); + assert.equal(store.get().subagentPanel, null); + assert.equal(store.get().activeWorkPanelTabId, "review"); +}); + +function rollbackReadingStore(viewMessages) { + let state = { + activeSessionId: "session-a", isRunning: false, runningSessions: {}, + retainedSessionIds: ["session-a"], messages: [], sessionHistory: {}, + transcriptViews: { "session-a": { + messages: viewMessages, messageStart: 100, messageEnd: 101, + hasMoreBefore: true, hasMoreAfter: true, focus: null, loading: null, + } }, + showToast: (text) => assert.fail(text), + }; + const reads = []; + let reading; + const access = { + get: () => state, + set: (update) => { + const previous = state; + state = { ...state, ...(typeof update === "function" ? update(state) : update) }; + reading?.reconcile(state, previous); + }, + }; + reading = createTranscriptReadingRuntime(access, (_id, options) => + new Promise((resolve) => reads.push({ options, resolve })), + ); + const actions = createTranscriptSlice({ + ...access, runtime: { isSessionSelectionPending: () => false }, + }); + return { ...access, reads, actions, reading: reading.actions, view: () => state.transcriptViews["session-a"] }; +} + +for (const direction of ["before", "after", "target"]) { + test(`confirmed rollback supersedes pending ${direction} read and permits retry`, async (t) => { + const record = workspaceEdit("edit", "snapshot", "src/a.ts"); + const store = rollbackReadingStore([record]); + t.mock.method(api, "workspaceReviewRollback", async () => ({ status: "rolledBack", snapshotId: "snapshot" })); + const pending = direction === "target" + ? store.reading.navigateTranscript({ sessionId: "session-a", messageId: "edit", query: "a" }) + : store.reading.loadTranscriptPage("session-a", direction); + const pendingView = store.view(); + assert.equal(pendingView.loading, direction); + await store.actions.rollbackWorkspaceChange("edit", "snapshot"); + const rolledBack = store.view().messages[0]; + assert.equal(reviewChangesFromMessage(rolledBack)[0].state, "rolledBack"); + assert.notEqual(store.view(), pendingView); + assert.equal(store.view().loading, null); + + const retry = store.reading.loadTranscriptPage("session-a", "before"); + const retryView = store.view(); + assert.equal(store.reads.length, 2, "cleared loading must allow another page request"); + store.reads[0].resolve({ session: { messages: [record], messageStart: 0, messageEnd: 101 } }); + await pending; + assert.equal(store.view(), retryView, "superseded read must not clobber the newer request"); + assert.equal(reviewChangesFromMessage(store.view().messages[0])[0].state, "rolledBack"); + assert.equal(store.view().loading, "before"); + + store.reads[1].resolve({ session: { + messages: [message("older", "user"), rolledBack], messageStart: 0, + messageEnd: 101, hasMoreBefore: false, + } }); + await retry; + assert.equal(store.view().loading, null); + assert.equal(store.view().hasMoreBefore, false); + assert.deepEqual(store.view().messages.map((item) => item.id), ["older", "edit"]); + assert.equal(reviewChangesFromMessage(store.view().messages[1])[0].state, "rolledBack"); + }); +} + +test("rollback outside the reading range preserves its pending read identity", async (t) => { + const store = rollbackReadingStore([message("history", "user")]); + t.mock.method(api, "workspaceReviewRollback", async () => ({ status: "rolledBack", snapshotId: "snapshot" })); + const pending = store.reading.loadTranscriptPage("session-a", "before"); + const pendingView = store.view(); + await store.actions.rollbackWorkspaceChange("edit", "snapshot"); + assert.equal(store.view(), pendingView); + store.reads[0].resolve({ session: { messages: [message("older", "user")], messageStart: 0, hasMoreBefore: false } }); + await pending; + assert.equal(store.view().loading, null); + assert.equal(store.view().messages[0].id, "older"); +}); diff --git a/apps/desktop/test/turn-process.test.mjs b/apps/desktop/test/turn-process.test.mjs index 661d74fcc3..b8343ffe7d 100644 --- a/apps/desktop/test/turn-process.test.mjs +++ b/apps/desktop/test/turn-process.test.mjs @@ -1,9 +1,12 @@ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import { register } from "node:module"; import test from "node:test"; register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); const { buildTranscriptEntries } = await import("../src/lib/assistant-turns.ts"); +const { formatMessageTimestamp } = await import("../src/lib/message-timestamp.ts"); const { + processContainsMessage, projectTurnProcess, visibleProcessSteps, resolveThinkingDisplayMode, @@ -43,6 +46,64 @@ test("one turn groups thinking, tools and progress while retaining only the trai assert.equal(visibleProcessSteps(projected.process, "compact", false), 4); }); +test("steering is always process content and the trailing assistant answer stays outside", () => { + const entry = turn([ + message("root", "user", "Inspect", { + createdAt: "2026-09-17T00:00:00.000Z", + }), + message("intro", "assistant", "Starting", { + createdAt: "2026-09-17T00:00:01.000Z", + }), + message("read", "tool", "result", { + toolName: "Read", + createdAt: "2026-09-17T00:00:02.000Z", + }), + message("steer", "user", "Also inspect tests", { + steering: true, + createdAt: "2026-09-17T00:00:03.000Z", + }), + message("edit", "tool", "updated", { + toolName: "Edit", + createdAt: "2026-09-17T00:00:04.000Z", + }), + message("final", "assistant", "Fixed", { + createdAt: "2026-09-17T00:00:05.000Z", + responseDurationMs: 1000, + }), + ]); + const projected = projectTurnProcess(entry); + + assert.deepEqual( + projected.process.map((part) => part.kind), + ["message", "activity", "steering", "activity"], + ); + assert.deepEqual( + projected.responses.map((part) => part.message.id), + ["final"], + ); + assert.equal(visibleProcessSteps(projected.process, "detailed", false), 4); + assert.equal(processContainsMessage(projected.process, "steer"), true); + assert.deepEqual(turnProcessTiming(entry.parts, entry.startedAt), { + startedAt: Date.parse("2026-09-17T00:00:00.000Z"), + endedAt: Date.parse("2026-09-17T00:00:06.000Z"), + }); +}); + +test("a trailing steering bubble cannot be projected as an assistant response", () => { + const entry = turn([ + message("root", "user", "Inspect"), + message("progress", "assistant", "Working"), + message("steer", "user", "One more constraint", { steering: true }), + ]); + const projected = projectTurnProcess(entry); + + assert.equal(projected.responses.length, 0); + assert.deepEqual( + projected.process.map((part) => part.kind), + ["message", "steering"], + ); +}); + test("streamed text stays readable until later work establishes it as progress", () => { const text = message("text", "assistant", "Inspecting", { status: "streaming" }); assert.equal(projectTurnProcess(turn([text])).responses[0].message, text); @@ -92,6 +153,20 @@ test("compact thinking disappears after reasoning ends without removing stored d assert.equal(thinking.thinking, "Private reasoning"); }); +test("compact completed hosted search remains a visible process step", () => { + const search = { + kind: "hostedSearch", + message: message("search", "assistant", "", { status: "complete" }), + }; + const parts = [{ kind: "activity", items: [search] }]; + assert.equal(visibleProcessSteps(parts, "compact", false), 1); + parts[0].items.push({ + kind: "tool", + message: message("read", "tool", "result", { toolName: "Read" }), + }); + assert.equal(visibleProcessSteps(parts, "compact", false), 2); +}); + test("missing and unknown display settings retain detailed mode", () => { for (const value of [undefined, null, "hidden", false, "detailed"]) { assert.equal(resolveThinkingDisplayMode(value), "detailed"); @@ -99,16 +174,15 @@ test("missing and unknown display settings retain detailed mode", () => { assert.equal(resolveThinkingDisplayMode("compact"), "compact"); }); -test("both display modes group a turn and only compact auto-opens active failures", () => { +test("both modes group turns and only automatically show active work", () => { assert.equal(shouldGroupTurnProcess("detailed"), true); assert.equal(shouldGroupTurnProcess("compact"), true); - assert.equal(shouldAutoOpenTurnProcess("detailed", false, false), true); - assert.equal(shouldAutoOpenTurnProcess("detailed", true, false), true); - assert.equal(shouldAutoOpenTurnProcess("detailed", true, true), true); - assert.equal(shouldAutoOpenTurnProcess("compact", false, false), false); - assert.equal(shouldAutoOpenTurnProcess("compact", true, false), false); - assert.equal(shouldAutoOpenTurnProcess("compact", true, true), true); - assert.equal(shouldAutoOpenTurnProcess("compact", false, true), false); + for (const mode of ["detailed", "compact"]) { + assert.equal(shouldAutoOpenTurnProcess(mode, false, false), false); + assert.equal(shouldAutoOpenTurnProcess(mode, true, false), true); + assert.equal(shouldAutoOpenTurnProcess(mode, true, true), true); + assert.equal(shouldAutoOpenTurnProcess(mode, false, true), false); + } }); test("the last activity part owns detailed-mode's default-open tool", () => { @@ -132,25 +206,72 @@ test("the last activity part owns detailed-mode's default-open tool", () => { ); }); -test("history timing uses recorded ends and rejects invalid timestamps and durations", () => { +test("turn timing starts at the user request and falls back to loaded process rows", () => { const entry = turn([ - message("read", "tool", "", { toolCompletedAt: "2026-09-17T00:00:02.000Z" }), + message("user", "user", "Inspect", { + createdAt: "2026-09-17T00:00:00.000Z", + }), + message("read", "tool", "", { + createdAt: "2026-09-17T00:00:01.000Z", + toolCompletedAt: "2026-09-17T00:00:02.000Z", + }), message("answer", "assistant", "Done", { createdAt: "2026-09-17T00:00:03.000Z", responseDurationMs: 1000, }), ]); - const timing = turnProcessTiming(entry.parts); + const timing = turnProcessTiming(entry.parts, entry.startedAt); assert.equal(timing.endedAt - timing.startedAt, 4000); + + const paged = turn([ + message("answer", "assistant", "Done", { + createdAt: "2026-09-17T00:00:03.000Z", + responseDurationMs: 1000, + }), + ]); + assert.deepEqual(turnProcessTiming(paged.parts, paged.startedAt), { + startedAt: Date.parse("2026-09-17T00:00:03.000Z"), + endedAt: Date.parse("2026-09-17T00:00:04.000Z"), + }); + + const invalidUser = turn([ + message("user", "user", "Inspect", { createdAt: "bad" }), + message("answer", "assistant", "Done", { + createdAt: "2026-09-17T00:00:03.000Z", + }), + ]); + assert.equal( + turnProcessTiming(invalidUser.parts, invalidUser.startedAt).startedAt, + Date.parse("2026-09-17T00:00:03.000Z"), + ); assert.deepEqual( turnProcessTiming( turn([message("bad", "assistant", "x", { createdAt: "bad" })]).parts, + "bad", ), - { - startedAt: undefined, - endedAt: undefined, - }, + { startedAt: undefined, endedAt: undefined }, + ); +}); + +test("message timestamps use locale formatting and reject invalid dates", () => { + const value = "2026-09-17T13:45:00.000Z"; + assert.deepEqual(formatMessageTimestamp(value, "en-US"), { + dateTime: value, + label: new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)), + }); + assert.notEqual( + formatMessageTimestamp(value, "en-US").label, + formatMessageTimestamp(value, "de-DE").label, ); + assert.equal(formatMessageTimestamp("not-a-date", "en-US"), undefined); + assert.deepEqual(formatMessageTimestamp(Date.parse(value), "en-US"), { + dateTime: value, + label: formatMessageTimestamp(value, "en-US").label, + }); + assert.equal(formatMessageTimestamp(undefined, "en-US"), undefined); }); test("user boundaries retain independent processes and delegation details stay attached", () => { @@ -192,3 +313,14 @@ test("settings writes validate the mode without changing other preferences", asy /thinkingDisplayMode is invalid/, ); }); + +test("settling the process changes identity without remounting the subtree", async () => { + const source = await readFile( + new URL("../src/features/chat/transcript/TurnProcess.tsx", import.meta.url), + "utf8", + ); + assert.doesNotMatch(source, /key=\{phase\}/); + assert.match(source, /identity=\{disclosureKey\("turn", turnId, phase\)\}/); + assert.match(source, /timing=\{processTiming\}|timing: TurnProcessTiming/); + assert.match(source, /const summary = useMemo\(/); +}); diff --git a/apps/desktop/test/workspace-review.test.mjs b/apps/desktop/test/workspace-review.test.mjs new file mode 100644 index 0000000000..235c4eb18a --- /dev/null +++ b/apps/desktop/test/workspace-review.test.mjs @@ -0,0 +1,318 @@ +import assert from "node:assert/strict"; +import { register } from "node:module"; +import test from "node:test"; + +register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); + +const { + reviewChangeFromMessage, + reviewChangesFromMessage, + reviewChangesMetadataFromMessage, + reviewChangesFromMessages, + reviewCaptureFromMessage, + withReviewChangeState, +} = await import("../src/lib/workspace-review.ts"); + +const review = (snapshotId, path, extra = {}) => ({ + version: 1, + snapshotId, + messageId: "shell-message", + path, + operation: "edit", + status: "modified", + state: "active", + additions: 2, + deletions: 1, + hunks: [], + reversible: true, + ...extra, +}); + +const tool = (overrides = {}) => ({ + id: "shell-message", + role: "tool", + content: "", + createdAt: "2026-09-20T00:00:00.000Z", + toolName: "Bash", + toolStatus: "success", + toolResult: { + details: { + root: "workspace", + reviews: [ + review("snapshot-html", "index.html"), + review("snapshot-css", "styles.css"), + ], + reviewCapture: { status: "complete" }, + }, + }, + ...overrides, +}); + +test("legacy single review behavior is preserved", () => { + const message = tool({ + toolName: "Edit", + toolResult: { + details: { + root: "workspace", + review: review("snapshot-legacy", "src/legacy.ts"), + }, + }, + }); + + assert.equal(reviewChangeFromMessage(message)?.snapshotId, "snapshot-legacy"); + assert.deepEqual( + reviewChangesFromMessage(message).map((change) => change.path), + ["src/legacy.ts"], + ); +}); + +test("executed Bash accepts valid reviews on success and error", () => { + assert.deepEqual( + reviewChangesFromMessage(tool()).map((change) => change.path), + ["index.html", "styles.css"], + ); + assert.deepEqual( + reviewChangesFromMessage(tool({ toolStatus: "error" })).map( + (change) => change.path, + ), + ["index.html", "styles.css"], + ); + assert.equal( + reviewChangesFromMessage(tool({ toolStatus: "denied" })).length, + 0, + ); + assert.equal( + reviewChangesFromMessage(tool({ toolStatus: "running" })).length, + 0, + ); +}); + +test("review records validate independently and dedupe by snapshot id", () => { + const duplicate = review("snapshot-html", "index.html", { additions: 9 }); + const invalid = { ...review("snapshot-invalid", "bad.txt"), additions: -1 }; + const message = tool({ + toolResult: { + details: { + root: "workspace", + review: review("snapshot-legacy", "legacy.txt"), + reviews: [invalid, review("snapshot-html", "old.html"), duplicate], + reviewCapture: { status: "complete" }, + }, + }, + }); + const changes = reviewChangesFromMessage(message); + + assert.deepEqual( + changes.map((change) => change.snapshotId), + ["snapshot-legacy", "snapshot-html"], + ); + assert.equal(changes[1].path, "index.html"); + assert.equal(changes[1].additions, 9); + + const latest = reviewChangesFromMessages([ + message, + tool({ + id: "later", + toolResult: { + details: { + root: "workspace", + reviews: [review("snapshot-html", "latest.html")], + reviewCapture: { status: "complete" }, + }, + }, + }), + ]); + assert.equal(latest.find((entry) => entry.change.snapshotId === "snapshot-html")?.change.path, "latest.html"); +}); + +test("capture metadata distinguishes complete, partial and legacy unavailable", () => { + assert.equal(reviewCaptureFromMessage(tool()), "complete"); + assert.equal( + reviewCaptureFromMessage( + tool({ + toolResult: { + details: { + root: "workspace", + reviews: [], + reviewCapture: { status: "partial" }, + }, + }, + }), + ), + "partial", + ); + assert.equal( + reviewCaptureFromMessage( + tool({ toolResult: { details: { exitCode: 0 } } }), + ), + "unavailable", + ); + assert.equal(reviewCaptureFromMessage(tool({ toolStatus: "denied" })), null); +}); + +test("rollback state updates only the requested snapshot in review and reviews", () => { + const message = tool({ + toolResult: { + details: { + root: "workspace", + review: review("snapshot-html", "index.html"), + reviews: [ + review("snapshot-html", "index.html"), + review("snapshot-css", "styles.css"), + ], + reviewCapture: { status: "complete" }, + }, + }, + }); + const updated = withReviewChangeState(message, "rolledBack", "snapshot-css"); + const details = updated.toolResult.details; + + assert.equal(details.review.state, "active"); + assert.equal(details.reviews[0].state, "active"); + assert.equal(details.reviews[1].state, "rolledBack"); + assert.equal(reviewChangesFromMessage(updated)[1].state, "rolledBack"); +}); + +test("session review history includes delegate records with original identities", () => { + const parent = tool(); + const nested = tool({ + id: "nested-edit", + toolName: "Edit", + parentToolCallId: "parent-task-call", + toolResult: { + details: { + root: "workspace", + review: review("snapshot-nested", "delegate.ts"), + }, + }, + }); + const orphan = tool({ + id: "orphan-edit", + toolName: "Edit", + parentToolCallId: "missing-task-call", + toolResult: { + details: { + root: "workspace", + review: review("snapshot-orphan", "orphan.ts"), + }, + }, + }); + const rolledBack = tool({ + ...nested, + toolResult: { + details: { + root: "workspace", + review: review("snapshot-nested", "delegate.ts", { + state: "rolledBack", + }), + }, + }, + }); + + const entries = reviewChangesFromMessages([parent, nested, orphan, rolledBack]); + assert.deepEqual( + entries.map((entry) => entry.change.path), + ["index.html", "styles.css", "orphan.ts", "delegate.ts"], + ); + const delegateEntry = entries.at(-1); + assert.equal(delegateEntry.message, rolledBack); + assert.equal(delegateEntry.change.snapshotId, "snapshot-nested"); + assert.equal(delegateEntry.change.state, "rolledBack"); +}); + +test("only Bash records with an exact .gradle path segment are hidden", () => { + const bash = tool({ + toolResult: { + details: { + root: "workspace", + reviews: [ + review("gradle-root", ".gradle/caches/root.bin", { binary: true }), + review("gradle-nested", "module\\.gradle\\cache.bin", { binary: true }), + review("gradle-prefix", ".gradle-cache/kept.bin", { binary: true }), + review("gradle-basename", "module/.gradle", { binary: true }), + review("binary", "assets/kept.bin", { + binary: true, + additions: 0, + deletions: 0, + }), + ], + reviewCapture: { status: "complete" }, + }, + }, + }); + const explicitEdit = tool({ + toolName: "Edit", + toolResult: { + details: { + root: "workspace", + review: review("explicit-gradle", ".gradle/explicit.txt"), + }, + }, + }); + const explicitWrite = tool({ + toolName: "Write", + toolResult: { + details: { + root: "workspace", + review: review("explicit-gradle-write", "module/.gradle/explicit.txt"), + }, + }, + }); + + assert.deepEqual( + reviewChangesFromMessage(bash).map((change) => change.path), + [".gradle-cache/kept.bin", "module/.gradle", "assets/kept.bin"], + ); + assert.deepEqual( + reviewChangesMetadataFromMessage(bash).map((change) => change.path), + [".gradle-cache/kept.bin", "module/.gradle", "assets/kept.bin"], + ); + assert.equal(reviewChangeFromMessage(explicitEdit)?.path, ".gradle/explicit.txt"); + assert.equal( + reviewChangeFromMessage(explicitWrite)?.path, + "module/.gradle/explicit.txt", + ); +}); + +test("metadata parse matches full records without loading hunks", () => { + const hunks = [ + { + header: "@@ -1 +1 @@", + lines: [ + { type: "del", text: "old" }, + { type: "add", text: "next" }, + ], + }, + ]; + const invalid = { ...review("snapshot-invalid", "bad.txt"), additions: -1 }; + const malformed = { version: 2, snapshotId: "legacy", path: "legacy.txt" }; + const message = tool({ + toolResult: { + details: { + root: "workspace", + review: review("snapshot-legacy", "legacy.txt", { hunks }), + reviews: [ + invalid, + malformed, + review("snapshot-html", "index.html", { hunks, additions: 9 }), + ], + reviewCapture: { status: "complete" }, + }, + }, + }); + const full = reviewChangesFromMessage(message); + const metadata = reviewChangesMetadataFromMessage(message); + + assert.deepEqual( + metadata.map((change) => change.snapshotId), + full.map((change) => change.snapshotId), + ); + assert.deepEqual( + metadata.map(({ hunks: _hunks, ...change }) => change), + full.map(({ hunks: _hunks, ...change }) => change), + ); + assert.equal(full[0].hunks.length, 1); + assert.equal("hunks" in metadata[0], false); + assert.equal(reviewChangesFromMessage(tool({ toolStatus: "denied" })).length, 0); + assert.equal(reviewChangesMetadataFromMessage(tool({ toolStatus: "denied" })).length, 0); +}); diff --git a/crates/host-core/Cargo.toml b/crates/host-core/Cargo.toml index bf682c5428..c8cbb1c9fb 100644 --- a/crates/host-core/Cargo.toml +++ b/crates/host-core/Cargo.toml @@ -49,3 +49,4 @@ libc = "0.2" [dev-dependencies] tempfile = "3" +tokio = { version = "1", features = ["test-util"] } diff --git a/crates/host-core/src/review.rs b/crates/host-core/src/review.rs index 0885a9af24..6e5ffff000 100644 --- a/crates/host-core/src/review.rs +++ b/crates/host-core/src/review.rs @@ -14,6 +14,8 @@ use std::io::Read; use std::path::{Path, PathBuf}; use uuid::Uuid; +pub mod shell; + use crate::workspace::{self, ToolRoot}; const REVIEW_DIR: &str = "review-changes"; @@ -112,6 +114,7 @@ struct SnapshotMeta { pub struct PendingChange { snapshot_dir: PathBuf, before_path: PathBuf, + workspace_root: PathBuf, resolved: PathBuf, session_id: String, message_id: String, @@ -257,6 +260,7 @@ pub fn prepare_change( Ok(Some(PendingChange { snapshot_dir, + workspace_root: root.to_path_buf(), before_path, resolved, session_id: session_id.to_string(), @@ -401,25 +405,35 @@ fn make_hunks(ops: &[DiffOp]) -> Vec { hunks } -fn make_preview( - before_path: Option<&Path>, +fn make_preview_bytes( + before: Option<&[u8]>, before_exists: bool, - after_path: Option<&Path>, + after: Option<&[u8]>, after_exists: bool, ) -> (bool, bool, usize, usize, Vec) { - let Ok(Some(before)) = read_preview(before_path, before_exists) else { - return (false, true, 0, 0, Vec::new()); + let before = if before_exists { + let Some(bytes) = before else { + return (false, true, 0, 0, Vec::new()); + }; + bytes + } else { + &[] }; - let Ok(Some(after)) = read_preview(after_path, after_exists) else { - return (false, true, 0, 0, Vec::new()); + let after = if after_exists { + let Some(bytes) = after else { + return (false, true, 0, 0, Vec::new()); + }; + bytes + } else { + &[] }; if before.contains(&0) || after.contains(&0) { return (true, false, 0, 0, Vec::new()); } - let Ok(before) = String::from_utf8(before) else { + let Ok(before) = std::str::from_utf8(before) else { return (true, false, 0, 0, Vec::new()); }; - let Ok(after) = String::from_utf8(after) else { + let Ok(after) = std::str::from_utf8(after) else { return (true, false, 0, 0, Vec::new()); }; let before_lines: Vec = before.lines().map(str::to_string).collect(); @@ -435,13 +449,47 @@ fn make_preview( (false, false, additions, deletions, hunks) } +fn make_preview( + before_path: Option<&Path>, + before_exists: bool, + after_path: Option<&Path>, + after_exists: bool, +) -> (bool, bool, usize, usize, Vec) { + let Ok(Some(before)) = read_preview(before_path, before_exists) else { + return (false, true, 0, 0, Vec::new()); + }; + let Ok(Some(after)) = read_preview(after_path, after_exists) else { + return (false, true, 0, 0, Vec::new()); + }; + make_preview_bytes(Some(&before), before_exists, Some(&after), after_exists) +} + pub fn finalize_change(pending: &PendingChange) -> Result { - let after_exists = pending.resolved.is_file(); + let after_metadata = fs::symlink_metadata(&pending.resolved).ok(); + if after_metadata + .as_ref() + .is_some_and(|metadata| metadata.file_type().is_symlink()) + { + return Err(anyhow!("review target became a symlink")); + } + let after_exists = after_metadata + .as_ref() + .is_some_and(|metadata| metadata.file_type().is_file()); + if after_exists { + let guarded = workspace::resolve_in_workspace(&pending.workspace_root, &pending.path) + .map_err(|error| anyhow!(error))?; + if guarded != pending.resolved { + return Err(anyhow!("review target escaped workspace containment")); + } + } let after_hash = if after_exists { Some(hash_file(&pending.resolved)?) } else { None }; + if pending.before_exists == after_exists && pending.before_hash == after_hash { + return Err(anyhow!("review target did not change")); + } let (binary, truncated, additions, deletions, hunks) = make_preview( pending .before_copied @@ -455,8 +503,11 @@ pub fn finalize_change(pending: &PendingChange) -> Result { (true, false) => ReviewChangeStatus::Deleted, _ => ReviewChangeStatus::Modified, }; - // Existing files remain reversible even when the tool deleted them; new - // files need to exist after the tool so rollback can remove them. + let operation = match status { + ReviewChangeStatus::Added => ReviewChangeOperation::Write, + ReviewChangeStatus::Deleted => ReviewChangeOperation::Delete, + ReviewChangeStatus::Modified => pending.operation, + }; let reversible = if pending.before_exists { pending.before_copied } else { @@ -467,7 +518,7 @@ pub fn finalize_change(pending: &PendingChange) -> Result { session_id: pending.session_id.clone(), message_id: pending.message_id.clone(), path: pending.path.clone(), - operation: pending.operation, + operation, before_exists: pending.before_exists, before_hash: pending.before_hash.clone(), after_exists, @@ -481,7 +532,7 @@ pub fn finalize_change(pending: &PendingChange) -> Result { snapshot_id: pending.snapshot_id.clone(), message_id: pending.message_id.clone(), path: pending.path.clone(), - operation: pending.operation, + operation, status, state: ReviewChangeState::Active, additions, @@ -493,6 +544,82 @@ pub fn finalize_change(pending: &PendingChange) -> Result { }) } +pub(crate) fn finalize_shell_change( + data_dir: &Path, + session_id: &str, + message_id: &str, + path: &str, + before: Option<&[u8]>, + after: Option<&[u8]>, +) -> Result { + let snapshot_id = Uuid::new_v4().to_string(); + let snapshot_dir = snapshot_dir(data_dir, session_id, &snapshot_id)?; + fs::create_dir_all(&snapshot_dir)?; + let before_exists = before.is_some(); + let after_exists = after.is_some(); + let before_hash = before.map(|bytes| hex::encode(Sha256::digest(bytes))); + let after_hash = after.map(|bytes| hex::encode(Sha256::digest(bytes))); + if before_exists == after_exists && before_hash == after_hash { + let _ = fs::remove_dir_all(&snapshot_dir); + return Err(anyhow!("review target did not change")); + } + let before_copied = match before { + Some(bytes) => fs::write(before_path(&snapshot_dir), bytes).is_ok(), + None => false, + }; + let (binary, truncated, additions, deletions, hunks) = + make_preview_bytes(before, before_exists, after, after_exists); + let status = match (before_exists, after_exists) { + (false, true) => ReviewChangeStatus::Added, + (true, false) => ReviewChangeStatus::Deleted, + _ => ReviewChangeStatus::Modified, + }; + let operation = match status { + ReviewChangeStatus::Added => ReviewChangeOperation::Write, + ReviewChangeStatus::Deleted => ReviewChangeOperation::Delete, + ReviewChangeStatus::Modified => ReviewChangeOperation::Edit, + }; + let reversible = !before_exists || before_copied; + let meta = SnapshotMeta { + version: 1, + session_id: session_id.to_string(), + message_id: message_id.to_string(), + path: path.to_string(), + operation, + before_exists, + before_hash, + after_exists, + after_hash, + reversible, + state: ReviewChangeState::Active, + }; + if let Err(error) = write_meta(&metadata_path(&snapshot_dir), &meta) { + let _ = fs::remove_dir_all(&snapshot_dir); + return Err(error); + } + Ok(ReviewChange { + version: 1, + snapshot_id, + message_id: message_id.to_string(), + path: path.to_string(), + operation, + status, + state: ReviewChangeState::Active, + additions, + deletions, + hunks, + binary, + truncated, + reversible, + }) +} + +pub(crate) fn discard_snapshot(data_dir: &Path, session_id: &str, snapshot_id: &str) { + if let Ok(dir) = snapshot_dir(data_dir, session_id, snapshot_id) { + let _ = fs::remove_dir_all(dir); + } +} + fn restore_before(target: &Path, before: &Path) -> Result<()> { let bytes = fs::read(before).context("read review rollback snapshot")?; if bytes.len() as u64 > MAX_SNAPSHOT_BYTES { diff --git a/crates/host-core/src/review/shell.rs b/crates/host-core/src/review/shell.rs new file mode 100644 index 0000000000..70b2f5dbaa --- /dev/null +++ b/crates/host-core/src/review/shell.rs @@ -0,0 +1,882 @@ +use super::{discard_snapshot, finalize_shell_change, ReviewChange}; +use anyhow::{anyhow, Context, Result}; +use ignore::WalkBuilder; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::time::{Duration, Instant}; + +use crate::tools::ignore_rules; +use crate::workspace; + +const MAX_WALK_ENTRIES: usize = 10_000; +const MAX_REGULAR_FILES: usize = 2_000; +const MAX_FILE_BYTES: u64 = 1024 * 1024; +const MAX_TOTAL_BYTES: u64 = 32 * 1024 * 1024; +const MAX_TOTAL_HASH_BYTES: u64 = 64 * 1024 * 1024; +const MAX_CAPTURE_TIME: Duration = Duration::from_secs(3); +const MAX_EMITTED_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Clone, Copy, serde::Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum CaptureStatus { + Complete, + Partial, +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptureResult { + pub status: CaptureStatus, + pub reviews: Vec, +} + +#[derive(Debug, Clone)] +enum FileState { + Readable { + hash: String, + bytes: Option>, + }, + Unreadable, +} + +#[derive(Debug)] +pub struct ShellCapture { + data_dir: PathBuf, + root: PathBuf, + session_id: String, + message_id: String, + before: BTreeMap, + pre_manifest_complete: bool, + pre_content_complete: bool, + policy: PolicySnapshot, + excluded_roots: Vec, +} + +#[derive(Debug)] +struct ScanResult { + files: BTreeMap, + manifest_complete: bool, + content_complete: bool, + #[cfg(test)] + hashed_bytes: u64, + #[cfg(test)] + retained_bytes: u64, +} + +#[derive(Debug)] +struct PolicySnapshot { + files: BTreeMap, + complete: bool, +} + +#[derive(Debug)] +struct Candidate { + path: String, + before: Option>, + after: Option>, + after_hash: Option, +} + +fn relative_path(root: &Path, path: &Path) -> Option { + path.strip_prefix(root) + .ok() + .map(|value| value.to_string_lossy().replace('\\', "/")) +} + +fn canonical_excluded(root: &Path, candidates: impl IntoIterator) -> Vec { + candidates + .into_iter() + .filter_map(|path| { + let canonical = workspace::simple_canonicalize(&path).ok()?; + canonical.starts_with(root).then_some(canonical) + }) + .collect() +} + +#[cfg(windows)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + metadata.file_type().is_symlink() || metadata.file_attributes() & 0x400 != 0 +} + +#[cfg(not(windows))] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +fn read_hash_bounded(path: &Path, max_bytes: u64) -> Result<(String, Vec)> { + let before = fs::symlink_metadata(path)?; + if is_link_or_reparse(&before) || !before.file_type().is_file() || before.len() > max_bytes { + return Err(anyhow!("file is not a bounded ordinary file")); + } + let file = File::open(path)?; + let mut bytes = Vec::with_capacity(before.len() as usize); + file.take(max_bytes + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > max_bytes { + return Err(anyhow!("file grew beyond capture limit")); + } + let after = fs::symlink_metadata(path)?; + if is_link_or_reparse(&after) + || !after.file_type().is_file() + || after.len() != bytes.len() as u64 + { + return Err(anyhow!("file changed during bounded capture")); + } + let hash = hex::encode(Sha256::digest(&bytes)); + Ok((hash, bytes)) +} + +fn validate_components(root: &Path, relative: &str) -> Result { + if workspace::simple_canonicalize(root).ok().as_deref() != Some(root) { + return Err(anyhow!("workspace root changed during shell capture")); + } + let relative_path = Path::new(relative); + if relative_path.is_absolute() + || relative_path + .components() + .any(|part| !matches!(part, Component::Normal(_))) + || ignore_rules::is_sensitive_path(relative_path) + { + return Err(anyhow!("unsafe shell review path")); + } + let target = root.join(relative_path); + let mut current = root.to_path_buf(); + let count = relative_path.components().count(); + for (index, component) in relative_path.components().enumerate() { + let Component::Normal(name) = component else { + return Err(anyhow!("unsafe shell review path component")); + }; + current.push(name); + match fs::symlink_metadata(¤t) { + Ok(metadata) => { + if is_link_or_reparse(&metadata) { + return Err(anyhow!( + "shell review path contains a link or reparse point" + )); + } + if index + 1 < count && !metadata.file_type().is_dir() { + return Err(anyhow!("shell review parent is not a directory")); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound && index + 1 == count => { + return Ok(target); + } + Err(error) => return Err(error.into()), + } + } + Ok(target) +} + +fn sample_path(root: &Path, relative: &str) -> Result)>> { + let target = validate_components(root, relative)?; + match fs::symlink_metadata(&target) { + Ok(metadata) => { + if is_link_or_reparse(&metadata) || !metadata.file_type().is_file() { + return Err(anyhow!("shell review target is not an ordinary file")); + } + read_hash_bounded(&target, MAX_FILE_BYTES).map(Some) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn scan_workspace( + root: &Path, + excluded_roots: &[PathBuf], + capture_bytes: bool, + before_files: Option<&BTreeMap>, +) -> ScanResult { + let started = Instant::now(); + let mut walker = WalkBuilder::new(root); + walker.hidden(false).follow_links(false); + ignore_rules::configure_shell_review_walker(&mut walker, root, excluded_roots.to_vec()); + let mut entries = 0; + let mut files_seen = 0; + let mut total_bytes = 0_u64; + let mut files = BTreeMap::new(); + let mut manifest_complete = true; + let mut content_complete = true; + #[cfg(test)] + let mut retained_bytes = 0_u64; + for entry in walker.build() { + entries += 1; + if entries > MAX_WALK_ENTRIES || started.elapsed() > MAX_CAPTURE_TIME { + manifest_complete = false; + content_complete = false; + break; + } + let Ok(entry) = entry else { + manifest_complete = false; + content_complete = false; + continue; + }; + let Some(file_type) = entry.file_type() else { + manifest_complete = false; + content_complete = false; + continue; + }; + if !file_type.is_file() || file_type.is_symlink() { + continue; + } + files_seen += 1; + if files_seen > MAX_REGULAR_FILES { + manifest_complete = false; + content_complete = false; + break; + } + let Some(relative) = relative_path(root, entry.path()) else { + manifest_complete = false; + content_complete = false; + continue; + }; + let Ok(metadata) = entry.metadata() else { + files.insert(relative, FileState::Unreadable); + content_complete = false; + continue; + }; + let total_limit = if capture_bytes { + MAX_TOTAL_BYTES + } else { + MAX_TOTAL_HASH_BYTES + }; + if metadata.len() > MAX_FILE_BYTES + || total_bytes.saturating_add(metadata.len()) > total_limit + { + files.insert(relative, FileState::Unreadable); + content_complete = false; + continue; + } + match sample_path(root, &relative) { + Ok(Some((hash, bytes))) => { + total_bytes += bytes.len() as u64; + let retain_bytes = capture_bytes + || match before_files.and_then(|files| files.get(&relative)) { + Some(FileState::Readable { + hash: before_hash, .. + }) => before_hash != &hash, + Some(FileState::Unreadable) => false, + None => true, + }; + let bytes = retain_bytes.then_some(bytes); + #[cfg(test)] + if let Some(bytes) = bytes.as_ref() { + retained_bytes += bytes.len() as u64; + } + files.insert(relative, FileState::Readable { hash, bytes }); + } + Ok(None) | Err(_) => { + files.insert(relative, FileState::Unreadable); + content_complete = false; + } + } + } + ScanResult { + files, + manifest_complete, + content_complete, + #[cfg(test)] + hashed_bytes: total_bytes, + #[cfg(test)] + retained_bytes, + } +} + +fn add_policy_file(root: &Path, path: &Path, files: &mut BTreeMap) -> bool { + let Ok(metadata) = fs::symlink_metadata(path) else { + return true; + }; + if is_link_or_reparse(&metadata) + || !metadata.file_type().is_file() + || metadata.len() > MAX_FILE_BYTES + { + return false; + } + let Ok((hash, _)) = read_hash_bounded(path, MAX_FILE_BYTES) else { + return false; + }; + let Some(relative) = relative_path(root, path) else { + return false; + }; + files.insert(relative, hash); + true +} + +fn snapshot_policy(root: &Path, excluded_roots: &[PathBuf]) -> PolicySnapshot { + let started = Instant::now(); + let mut walker = WalkBuilder::new(root); + let excluded = excluded_roots.to_vec(); + walker + .hidden(false) + .parents(false) + .ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .follow_links(false) + .filter_entry(move |entry| { + if excluded + .iter() + .any(|path| entry.path() == path || entry.path().starts_with(path)) + { + return false; + } + let name = entry.file_name().to_string_lossy(); + if entry.file_type().is_some_and(|kind| kind.is_dir()) { + return !ignore_rules::DEFAULT_IGNORE_DIRS.contains(&name.as_ref()) + && !ignore_rules::SHELL_REVIEW_IGNORE_DIRS.contains(&name.as_ref()); + } + !ignore_rules::is_sensitive_file_name(&name) + }); + let mut files = BTreeMap::new(); + let mut complete = true; + let mut entries = 0; + for entry in walker.build() { + entries += 1; + if entries > MAX_WALK_ENTRIES || started.elapsed() > MAX_CAPTURE_TIME { + complete = false; + break; + } + let Ok(entry) = entry else { + complete = false; + continue; + }; + if entry.file_type().is_some_and(|kind| kind.is_dir()) { + complete &= add_policy_file(root, &entry.path().join(".git/info/exclude"), &mut files); + continue; + } + let name = entry.file_name().to_string_lossy(); + if name == ".gitignore" || name == ".ignore" || name == ".pi-desktopignore" { + complete &= add_policy_file(root, entry.path(), &mut files); + } + } + PolicySnapshot { files, complete } +} + +pub fn prepare( + data_dir: &Path, + session_id: &str, + message_id: &str, + workspace_root: Option<&Path>, + scratch_root: Option<&Path>, +) -> Result> { + let Some(workspace_root) = workspace_root else { + return Ok(None); + }; + let root = workspace::simple_canonicalize(workspace_root) + .context("canonicalize workspace for shell review")?; + let excluded_roots = canonical_excluded( + &root, + [ + scratch_root.map(Path::to_path_buf), + Some(data_dir.to_path_buf()), + ] + .into_iter() + .flatten(), + ); + let before = scan_workspace(&root, &excluded_roots, true, None); + let policy = snapshot_policy(&root, &excluded_roots); + Ok(Some(ShellCapture { + data_dir: data_dir.to_path_buf(), + root, + session_id: session_id.to_string(), + message_id: message_id.to_string(), + before: before.files, + pre_manifest_complete: before.manifest_complete, + pre_content_complete: before.content_complete, + policy, + excluded_roots, + })) +} + +fn persist_candidate(capture: &ShellCapture, candidate: Candidate) -> Result { + match ( + &candidate.after_hash, + sample_path(&capture.root, &candidate.path)?, + ) { + (Some(expected), Some((actual, _))) if expected == &actual => {} + (None, None) => {} + _ => { + return Err(anyhow!( + "shell review target changed after post-command capture" + )) + } + } + finalize_shell_change( + &capture.data_dir, + &capture.session_id, + &capture.message_id, + &candidate.path, + candidate.before.as_deref(), + candidate.after.as_deref(), + ) +} + +impl ShellCapture { + pub fn finish(mut self, process_complete: bool) -> CaptureResult { + let policy_after = snapshot_policy(&self.root, &self.excluded_roots); + let policy_drift = !self.policy.complete + || !policy_after.complete + || self.policy.files != policy_after.files; + let after = scan_workspace(&self.root, &self.excluded_roots, false, Some(&self.before)); + let mut paths = BTreeSet::new(); + paths.extend(self.before.keys().cloned()); + paths.extend(after.files.keys().cloned()); + let mut partial = !self.pre_manifest_complete + || !self.pre_content_complete + || !after.manifest_complete + || !after.content_complete + || policy_drift + || !process_complete; + let mut after_files = after.files; + let mut candidates = Vec::new(); + for path in paths { + let before = self.before.remove(&path); + let after_state = after_files.remove(&path); + let candidate = match (before, after_state) { + ( + Some(FileState::Readable { + hash: before_hash, + bytes: Some(before_bytes), + }), + Some(FileState::Readable { + hash: after_hash, + bytes: Some(after_bytes), + }), + ) if before_hash != after_hash => Some(Candidate { + path, + before: Some(before_bytes), + after: Some(after_bytes), + after_hash: Some(after_hash), + }), + ( + Some(FileState::Readable { + hash: before_hash, .. + }), + Some(FileState::Readable { + hash: after_hash, .. + }), + ) if before_hash == after_hash => None, + ( + Some(FileState::Readable { + bytes: Some(bytes), .. + }), + None, + ) => match sample_path(&self.root, &path) { + Ok(None) => Some(Candidate { + path, + before: Some(bytes), + after: None, + after_hash: None, + }), + Ok(Some(_)) | Err(_) => { + partial = true; + None + } + }, + ( + None, + Some(FileState::Readable { + hash: after_hash, + bytes: Some(after_bytes), + }), + ) if self.pre_manifest_complete && !policy_drift => Some(Candidate { + path, + before: None, + after: Some(after_bytes), + after_hash: Some(after_hash), + }), + (Some(FileState::Unreadable), _) | (_, Some(FileState::Unreadable)) => { + partial = true; + None + } + (None, Some(FileState::Readable { .. })) + | (Some(FileState::Readable { .. }), Some(FileState::Readable { .. })) + | (Some(FileState::Readable { bytes: None, .. }), None) => { + partial = true; + None + } + _ => None, + }; + if let Some(candidate) = candidate { + candidates.push(candidate); + } + } + + let mut reviews = Vec::new(); + let mut emitted = 0_usize; + for candidate in candidates { + match persist_candidate(&self, candidate) { + Ok(change) => { + let size = serde_json::to_vec(&change) + .map(|value| value.len()) + .unwrap_or(0); + if emitted.saturating_add(size) > MAX_EMITTED_BYTES { + partial = true; + discard_snapshot(&self.data_dir, &self.session_id, &change.snapshot_id); + continue; + } + emitted += size; + reviews.push(change); + } + Err(error) => { + partial = true; + tracing::warn!(%error, "shell review snapshot finalization failed"); + } + } + } + CaptureResult { + status: if partial { + CaptureStatus::Partial + } else { + CaptureStatus::Complete + }, + reviews, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::review::{rollback_change, ReviewChangeStatus}; + use tempfile::tempdir; + + fn start(data: &Path, workspace: &Path) -> ShellCapture { + prepare(data, "session", "message", Some(workspace), None) + .unwrap() + .unwrap() + } + + #[test] + fn captures_add_edit_delete_and_excludes_preexisting_dirty_files() { + let data = tempdir().unwrap(); + let workspace = tempdir().unwrap(); + fs::write(workspace.path().join("edit.txt"), "before\n").unwrap(); + fs::write(workspace.path().join("delete.txt"), "remove\n").unwrap(); + fs::write(workspace.path().join("dirty.txt"), "already dirty\n").unwrap(); + let capture = start(data.path(), workspace.path()); + fs::write(workspace.path().join("edit.txt"), "after\n").unwrap(); + fs::remove_file(workspace.path().join("delete.txt")).unwrap(); + fs::write(workspace.path().join("add.txt"), "new\n").unwrap(); + let result = capture.finish(true); + assert_eq!(result.status, CaptureStatus::Complete); + assert_eq!(result.reviews.len(), 3); + for (path, status) in [ + ("add.txt", ReviewChangeStatus::Added), + ("edit.txt", ReviewChangeStatus::Modified), + ("delete.txt", ReviewChangeStatus::Deleted), + ] { + assert_eq!( + result + .reviews + .iter() + .find(|item| item.path == path) + .map(|item| item.status), + Some(status) + ); + } + assert!(!result.reviews.iter().any(|item| item.path == "dirty.txt")); + } + + #[test] + fn gradle_caches_do_not_consume_capture_budget_or_hide_source_changes() { + let data = tempdir().unwrap(); + let workspace = tempdir().unwrap(); + let cache = workspace.path().join(".gradle/8.4/fileHashes"); + let nested_cache = workspace.path().join("module/.gradle"); + fs::create_dir_all(&cache).unwrap(); + fs::create_dir_all(&nested_cache).unwrap(); + fs::create_dir_all(workspace.path().join("src")).unwrap(); + for index in 0..=MAX_WALK_ENTRIES { + fs::write(cache.join(format!("{index}.bin")), [0, 1]).unwrap(); + } + fs::write(nested_cache.join("state.lock"), [0, 1]).unwrap(); + fs::write(workspace.path().join("src/Main.java"), "before\n").unwrap(); + let capture = start(data.path(), workspace.path()); + fs::write(cache.join("0.bin"), [0, 2]).unwrap(); + fs::write(nested_cache.join("state.lock"), [0, 2]).unwrap(); + fs::write(workspace.path().join("src/Main.java"), "after\n").unwrap(); + let result = capture.finish(true); + assert_eq!(result.status, CaptureStatus::Complete); + assert_eq!(result.reviews.len(), 1); + let change = &result.reviews[0]; + assert_eq!(change.path, "src/Main.java"); + assert_eq!(change.additions, 1); + assert_eq!(change.deletions, 1); + assert_eq!( + rollback_change( + data.path(), + "session", + &change.snapshot_id, + Some(workspace.path()) + ) + .unwrap() + .status, + "rolledBack" + ); + assert_eq!( + fs::read_to_string(workspace.path().join("src/Main.java")).unwrap(), + "before\n" + ); + assert_eq!(fs::read(cache.join("0.bin")).unwrap(), [0, 2]); + } + + #[test] + fn no_op_is_complete_and_empty() { + let data = tempdir().unwrap(); + let workspace = tempdir().unwrap(); + fs::write(workspace.path().join("same.txt"), "same\n").unwrap(); + let result = start(data.path(), workspace.path()).finish(true); + assert_eq!(result.status, CaptureStatus::Complete); + assert!(result.reviews.is_empty()); + } + + #[test] + fn nonzero_or_aborted_process_still_captures_and_marks_partial() { + let data = tempdir().unwrap(); + let workspace = tempdir().unwrap(); + let capture = start(data.path(), workspace.path()); + fs::write(workspace.path().join("failed.txt"), "written\n").unwrap(); + let result = capture.finish(false); + assert_eq!(result.status, CaptureStatus::Partial); + assert_eq!(result.reviews.len(), 1); + } + + #[test] + fn snapshots_roll_back_independently_and_conflicts_are_guarded() { + let data = tempdir().unwrap(); + let workspace = tempdir().unwrap(); + fs::write(workspace.path().join("a.txt"), "a0\n").unwrap(); + fs::write(workspace.path().join("b.txt"), "b0\n").unwrap(); + let capture = start(data.path(), workspace.path()); + fs::write(workspace.path().join("a.txt"), "a1\n").unwrap(); + fs::write(workspace.path().join("b.txt"), "b1\n").unwrap(); + let result = capture.finish(true); + let a = result + .reviews + .iter() + .find(|item| item.path == "a.txt") + .unwrap(); + let b = result + .reviews + .iter() + .find(|item| item.path == "b.txt") + .unwrap(); + assert_eq!( + rollback_change( + data.path(), + "session", + &a.snapshot_id, + Some(workspace.path()) + ) + .unwrap() + .status, + "rolledBack" + ); + fs::write(workspace.path().join("b.txt"), "later\n").unwrap(); + assert_eq!( + rollback_change( + data.path(), + "session", + &b.snapshot_id, + Some(workspace.path()) + ) + .unwrap() + .status, + "conflict" + ); + } + + #[test] + fn oversized_existing_file_does_not_hide_new_small_files() { + let data = tempdir().unwrap(); + let workspace = tempdir().unwrap(); + fs::write( + workspace.path().join("large.bin"), + vec![0_u8; MAX_FILE_BYTES as usize + 1], + ) + .unwrap(); + let capture = start(data.path(), workspace.path()); + for name in ["index.html", "style.css", "app.js"] { + fs::write(workspace.path().join(name), format!("{name}\n")).unwrap(); + } + let result = capture.finish(true); + assert_eq!(result.status, CaptureStatus::Partial); + assert_eq!(result.reviews.len(), 3); + } + + #[test] + fn ignore_policy_drift_never_reports_an_exposed_old_file_as_added() { + let data = tempdir().unwrap(); + let workspace = tempdir().unwrap(); + fs::write(workspace.path().join(".gitignore"), "old.txt\n").unwrap(); + fs::write(workspace.path().join("old.txt"), "preexisting\n").unwrap(); + let capture = start(data.path(), workspace.path()); + fs::write(workspace.path().join(".gitignore"), "").unwrap(); + let result = capture.finish(true); + assert_eq!(result.status, CaptureStatus::Partial); + assert!(!result.reviews.iter().any(|item| item.path == "old.txt")); + } + + #[test] + fn post_sample_growth_is_rejected_without_unbounded_read() { + let data = tempdir().unwrap(); + let workspace = tempdir().unwrap(); + let capture = start(data.path(), workspace.path()); + let path = workspace.path().join("grow.txt"); + fs::write(&path, "small\n").unwrap(); + let (hash, bytes) = sample_path(workspace.path(), "grow.txt").unwrap().unwrap(); + fs::write(&path, vec![b'x'; MAX_FILE_BYTES as usize + 1]).unwrap(); + let error = persist_candidate( + &capture, + Candidate { + path: "grow.txt".into(), + before: None, + after: Some(bytes), + after_hash: Some(hash), + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("bounded ordinary file")); + } + + #[test] + fn post_sample_mutation_is_rejected() { + let data = tempdir().unwrap(); + let workspace = tempdir().unwrap(); + let capture = start(data.path(), workspace.path()); + let path = workspace.path().join("mutate.txt"); + fs::write(&path, "first\n").unwrap(); + let (hash, bytes) = sample_path(workspace.path(), "mutate.txt") + .unwrap() + .unwrap(); + fs::write(&path, "later\n").unwrap(); + let error = persist_candidate( + &capture, + Candidate { + path: "mutate.txt".into(), + before: None, + after: Some(bytes), + after_hash: Some(hash), + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("changed after post-command")); + } + + #[test] + fn sensitive_and_ignored_files_are_never_captured() { + let data = tempdir().unwrap(); + let workspace = tempdir().unwrap(); + let capture = start(data.path(), workspace.path()); + fs::write(workspace.path().join(".env"), "secret").unwrap(); + fs::create_dir(workspace.path().join("node_modules")).unwrap(); + fs::write(workspace.path().join("node_modules/a.js"), "ignored").unwrap(); + let result = capture.finish(true); + assert_eq!(result.status, CaptureStatus::Complete); + assert!(result.reviews.is_empty()); + } + + #[test] + fn post_scan_retains_only_changed_or_added_bytes_within_hash_bound() { + let workspace = tempdir().unwrap(); + let unchanged = vec![b'u'; 32 * 1024]; + let changed_before = vec![b'b'; 24 * 1024]; + let changed_after = vec![b'a'; 20 * 1024]; + let added = vec![b'n'; 12 * 1024]; + fs::write(workspace.path().join("unchanged.bin"), &unchanged).unwrap(); + fs::write(workspace.path().join("changed.bin"), &changed_before).unwrap(); + let before = scan_workspace(workspace.path(), &[], true, None); + fs::write(workspace.path().join("changed.bin"), &changed_after).unwrap(); + fs::write(workspace.path().join("added.bin"), &added).unwrap(); + + let after = scan_workspace(workspace.path(), &[], false, Some(&before.files)); + + assert_eq!( + after.hashed_bytes, + (unchanged.len() + changed_after.len() + added.len()) as u64 + ); + assert_eq!( + after.retained_bytes, + (changed_after.len() + added.len()) as u64 + ); + assert!(after.hashed_bytes <= MAX_TOTAL_HASH_BYTES); + assert!(matches!( + after.files.get("unchanged.bin"), + Some(FileState::Readable { bytes: None, .. }) + )); + assert!(matches!( + after.files.get("changed.bin"), + Some(FileState::Readable { + bytes: Some(bytes), .. + }) if bytes == &changed_after + )); + assert!(matches!( + after.files.get("added.bin"), + Some(FileState::Readable { + bytes: Some(bytes), .. + }) if bytes == &added + )); + } + + #[cfg(unix)] + fn replace_with_symlink(target: &Path, destination: &Path) { + fs::remove_file(target).unwrap(); + std::os::unix::fs::symlink(destination, target).unwrap(); + } + + #[cfg(unix)] + #[test] + fn live_inside_symlink_replacement_is_skipped_as_partial() { + let data = tempdir().unwrap(); + let workspace = tempdir().unwrap(); + fs::write(workspace.path().join("file.txt"), "before\n").unwrap(); + fs::write(workspace.path().join("other.txt"), "inside\n").unwrap(); + let capture = start(data.path(), workspace.path()); + replace_with_symlink( + &workspace.path().join("file.txt"), + &workspace.path().join("other.txt"), + ); + let result = capture.finish(true); + assert_eq!(result.status, CaptureStatus::Partial); + assert!(result.reviews.iter().all(|item| item.path != "file.txt")); + } + + #[cfg(unix)] + #[test] + fn live_outside_symlink_replacement_is_skipped_as_partial() { + let data = tempdir().unwrap(); + let workspace = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::write(workspace.path().join("file.txt"), "before\n").unwrap(); + fs::write(outside.path().join("outside.txt"), "outside\n").unwrap(); + let capture = start(data.path(), workspace.path()); + replace_with_symlink( + &workspace.path().join("file.txt"), + &outside.path().join("outside.txt"), + ); + let result = capture.finish(true); + assert_eq!(result.status, CaptureStatus::Partial); + assert!(result.reviews.is_empty()); + } + + #[cfg(unix)] + #[test] + fn broken_symlink_replacement_is_skipped_as_partial() { + let data = tempdir().unwrap(); + let workspace = tempdir().unwrap(); + fs::write(workspace.path().join("file.txt"), "before\n").unwrap(); + let capture = start(data.path(), workspace.path()); + replace_with_symlink( + &workspace.path().join("file.txt"), + &workspace.path().join("missing.txt"), + ); + let result = capture.finish(true); + assert_eq!(result.status, CaptureStatus::Partial); + assert!(result.reviews.is_empty()); + } +} diff --git a/crates/host-core/src/rpc/mod.rs b/crates/host-core/src/rpc/mod.rs index a01663aecd..8b56a567e6 100644 --- a/crates/host-core/src/rpc/mod.rs +++ b/crates/host-core/src/rpc/mod.rs @@ -1910,16 +1910,32 @@ async fn handle_request( .get("snapshotId") .and_then(|value| value.as_str()) .ok_or_else(|| rpc_err(1002, "snapshotId required", "INVALID_PARAMS"))?; - let st = state.lock().await; - let workspace_root = resolve_tool_workspace(&st, session_id)?; + let (workspace_root, data_dir, workspace_mutation_locks) = { + let st = state.lock().await; + ( + resolve_tool_workspace(&st, session_id)?, + st.data_dir.clone(), + st.workspace_mutation_locks.clone(), + ) + }; + let _workspace_guard = match workspace_root.as_deref() { + Some(root) => Some( + workspace_mutation_locks + .acquire(std::path::Path::new(root)) + .await + .map_err(|error| rpc_err(1000, error.message(), error.code()))?, + ), + None => None, + }; let outcome = review::rollback_change( - &st.data_dir, + &data_dir, session_id, snapshot_id, workspace_root.as_deref().map(std::path::Path::new), ) .map_err(|error| rpc_err(1000, error.to_string(), "INTERNAL"))?; if matches!(outcome.status, "rolledBack" | "alreadyRolledBack") { + let st = state.lock().await; if let Some(root) = workspace_root.as_deref() { let resolved = std::path::Path::new(root).join(&outcome.path); st.hashline.invalidate_path( @@ -3675,11 +3691,20 @@ async fn handle_request( // Admission follows permission so approval waits do not occupy // execution capacity. Keep this permit through review, the // runner, and bookkeeping so every accepted call is bounded. - let tool_budget = { + let (tool_budget, workspace_mutation_locks) = { let st = state.lock().await; - st.tool_budget.clone() + (st.tool_budget.clone(), st.workspace_mutation_locks.clone()) }; - let _tool_permit = match tool_budget.acquire(&p.session_id, &p.tool_name).await { + let workspace_lock_root = workspace_path.as_deref().map(std::path::Path::new); + let _tool_permit = match tool_budget + .acquire( + &p.session_id, + &p.tool_name, + workspace_lock_root, + &workspace_mutation_locks, + ) + .await + { Ok(permit) => permit, Err(error) => { tracing::warn!( @@ -3734,6 +3759,28 @@ async fn handle_request( ); None }); + let shell_review = if p.tool_name == "Bash" { + match review::shell::prepare( + &data_dir, + &p.session_id, + &p.tool_call_id, + ws_path.as_deref(), + scratch_path.as_deref(), + ) { + Ok(capture) => capture, + Err(error) => { + tracing::warn!( + session_id = %p.session_id, + tool_call_id = %p.tool_call_id, + error = %error, + "shell review capture unavailable" + ); + None + } + } + } else { + None + }; let mut bash_options = None; if p.tool_name == "Bash" { let (shell_id, cancellation) = { @@ -3819,6 +3866,36 @@ async fn handle_request( .await }; result.tool_call_id = p.tool_call_id.clone(); + if p.tool_name == "Bash" { + let captured = shell_review.map(|capture| { + let process_complete = !matches!( + result.error_code.as_deref(), + Some("TOOL_ABORTED" | "TOOL_TIMEOUT") + ); + capture.finish(process_complete) + }); + let reviews = captured + .as_ref() + .map(|capture| serde_json::to_value(&capture.reviews)) + .transpose() + .map_err(|error| rpc_err(1000, error.to_string(), "INTERNAL"))? + .unwrap_or_else(|| json!([])); + let status = captured + .as_ref() + .map(|capture| json!(capture.status)) + .unwrap_or_else(|| json!("unavailable")); + if !result.content.is_object() { + result.content = json!({ + "error": result.content, + "code": result.error_code.clone().unwrap_or_else(|| "TOOL_FAILED".into()) + }); + } + if let Some(object) = result.content.as_object_mut() { + object.insert("root".to_string(), json!("workspace")); + object.insert("reviews".to_string(), reviews); + object.insert("reviewCapture".to_string(), json!({ "status": status })); + } + } if let Some(pending) = pending_review { if result.ok { diff --git a/crates/host-core/src/sessions.rs b/crates/host-core/src/sessions.rs index d0b9c5d1e4..8c9ceb18a1 100644 --- a/crates/host-core/src/sessions.rs +++ b/crates/host-core/src/sessions.rs @@ -11,8 +11,11 @@ use std::sync::{Mutex, OnceLock}; use crate::transcripts::{self, CompactionRecord, MessageRecord, RevisionRecord}; +mod display; mod fork_files; +use display::record_to_ui_for_display; + pub const MODES: [&str; 3] = ["plan", "goal", "agent"]; /// Maximum number of Unicode scalar values accepted for a user-defined title. @@ -612,145 +615,6 @@ pub(crate) fn record_to_ui(record: MessageRecord) -> UiMessage { } } -const DISPLAY_TRUNCATION_MARKER: &str = - "\n\n[truncated for display; the full content remains in the transcript]"; - -fn truncate_display_text(text: String, limit: usize) -> (String, bool) { - if text.chars().count() <= limit { - return (text, false); - } - let marker_len = DISPLAY_TRUNCATION_MARKER.chars().count(); - if limit <= marker_len { - return ( - DISPLAY_TRUNCATION_MARKER.chars().take(limit).collect(), - true, - ); - } - let head = limit - marker_len; - ( - format!( - "{}{}", - text.chars().take(head).collect::(), - DISPLAY_TRUNCATION_MARKER - ), - true, - ) -} - -fn preview_value(value: Value, limit: usize) -> Value { - fn walk(value: Value, budget: &mut usize) -> Value { - if *budget == 0 { - return Value::String("[truncated for display]".into()); - } - match value { - Value::String(text) => { - let (clipped, _) = truncate_display_text(text, *budget); - *budget = (*budget).saturating_sub(clipped.chars().count()); - Value::String(clipped) - } - Value::Array(items) => { - let mut output = Vec::with_capacity(items.len().min(32)); - for item in items { - if *budget == 0 || output.len() >= 256 { - output.push(Value::String("[truncated for display]".into())); - break; - } - output.push(walk(item, budget)); - } - Value::Array(output) - } - Value::Object(object) => { - let mut output = serde_json::Map::new(); - for (key, item) in object { - if *budget == 0 || output.len() >= 256 { - output.insert("_truncated".into(), Value::Bool(true)); - break; - } - output.insert(key, walk(item, budget)); - } - Value::Object(output) - } - other => other, - } - } - - let mut budget = limit.max(1); - walk(value, &mut budget) -} - -fn cap_record_block_value(value: &mut Value, budget: &mut usize) { - let original = std::mem::take(value); - *value = match original { - Value::String(text) if *budget > 0 => { - let (clipped, _) = truncate_display_text(text, *budget); - *budget = budget.saturating_sub(clipped.chars().count()); - Value::String(clipped) - } - Value::String(_) => Value::String(String::new()), - other => other, - }; -} - -fn cap_record_blocks_for_display(record: &mut MessageRecord, limit: usize) { - let Some(blocks) = record.blocks.as_array_mut() else { - return; - }; - let mut text_budget = limit.max(1); - let mut thinking_budget = limit.max(1); - for block in blocks { - let Some(object) = block.as_object_mut() else { - continue; - }; - match object.get("type").and_then(Value::as_str) { - Some("text") => { - if let Some(text) = object.get_mut("text") { - cap_record_block_value(text, &mut text_budget); - } - } - Some("thinking") => { - if let Some(text) = object.get_mut("text") { - cap_record_block_value(text, &mut thinking_budget); - } - } - Some("tool_call") => { - for key in ["args", "result"] { - if let Some(value) = object.get_mut(key) { - *value = preview_value(std::mem::take(value), limit); - } - } - if let Some(text) = object.get_mut("text") { - cap_record_block_value(text, &mut text_budget); - } - } - _ => {} - } - } -} - -fn record_to_ui_for_display(mut record: MessageRecord, limit: usize) -> UiMessage { - // Bound the canonical block values before record_to_ui concatenates text - // blocks or clones tool payloads. This keeps a 50 MB selected line from - // producing another 50 MB temporary UI string on the host. - cap_record_blocks_for_display(&mut record, limit); - let mut message = record_to_ui(record); - let (content, _) = truncate_display_text(message.content, limit); - message.content = content; - if let Some(thinking) = message.thinking.take() { - let (thinking, _) = truncate_display_text(thinking, limit); - message.thinking = Some(thinking); - } - if let Some(args) = message.tool_args.take() { - message.tool_args = Some(preview_value(args, limit)); - } - if let Some(result) = message.tool_result.take() { - message.tool_result = Some(preview_value(result, limit)); - } - if let Some(error) = message.error.take() { - message.error = Some(preview_value(error, limit)); - } - message -} - pub(crate) fn dedupe_records(records: Vec) -> Vec { let mut ordered: Vec = Vec::with_capacity(records.len()); let mut by_id: std::collections::HashMap = @@ -848,19 +712,29 @@ fn clone_records_for_fork( } } // Review snapshots are owned by the source session's - // workspace. Keep the copied diff evidence visible, but - // prevent a fork from offering a rollback against a - // snapshot it does not own. + // workspace. Keep copied diff evidence visible, but make + // every legacy or shell-array record non-reversible. if object.get("type").and_then(Value::as_str) == Some("tool_call") { - if let Some(review) = object + if let Some(details) = object .get_mut("result") .and_then(Value::as_object_mut) .and_then(|result| result.get_mut("details")) .and_then(Value::as_object_mut) - .and_then(|details| details.get_mut("review")) - .and_then(Value::as_object_mut) { - review.insert("reversible".into(), Value::Bool(false)); + if let Some(review) = + details.get_mut("review").and_then(Value::as_object_mut) + { + review.insert("reversible".into(), Value::Bool(false)); + } + if let Some(reviews) = + details.get_mut("reviews").and_then(Value::as_array_mut) + { + for review in reviews { + if let Some(review) = review.as_object_mut() { + review.insert("reversible".into(), Value::Bool(false)); + } + } + } } } } @@ -2594,15 +2468,31 @@ pub fn update_tool_review_state( else { continue; }; - let Some(review) = details.get_mut("review").and_then(Value::as_object_mut) else { - continue; + let mut update = |review: &mut serde_json::Map| { + if review.get("snapshotId").and_then(Value::as_str) != Some(snapshot_id) { + return false; + } + review.insert("state".into(), Value::String(state.to_string())); + true }; - if review.get("snapshotId").and_then(Value::as_str) != Some(snapshot_id) { - continue; + if details + .get_mut("review") + .and_then(Value::as_object_mut) + .is_some_and(&mut update) + { + changed = true; + break; + } + if let Some(reviews) = details.get_mut("reviews").and_then(Value::as_array_mut) { + if reviews + .iter_mut() + .filter_map(Value::as_object_mut) + .any(&mut update) + { + changed = true; + break; + } } - review.insert("state".into(), Value::String(state.to_string())); - changed = true; - break; } if changed { replace_messages(db, session_id, &messages)?; @@ -4037,6 +3927,42 @@ mod tests { ); } + #[test] + fn fork_keeps_review_evidence_but_marks_every_snapshot_irreversible() { + let record = MessageRecord { + id: "tool-message".into(), + role: "tool".into(), + tool_name: Some("Bash".into()), + is_error: false, + blocks: json!([{ + "type": "tool_call", + "callId": "call-1", + "result": { + "details": { + "review": { + "snapshotId": "legacy-snapshot", + "reversible": true + }, + "reviews": [ + { "snapshotId": "snapshot-a", "reversible": true }, + { "snapshotId": "snapshot-b", "reversible": true } + ] + } + } + }]), + meta: None, + created_at: "2026-09-20T00:00:00Z".into(), + }; + let (cloned, _, _) = clone_records_for_fork(vec![record]); + let details = &cloned[0].blocks[0]["result"]["details"]; + assert_eq!(details["review"]["snapshotId"], "legacy-snapshot"); + assert_eq!(details["review"]["reversible"], false); + assert_eq!(details["reviews"][0]["snapshotId"], "snapshot-a"); + assert_eq!(details["reviews"][0]["reversible"], false); + assert_eq!(details["reviews"][1]["snapshotId"], "snapshot-b"); + assert_eq!(details["reviews"][1]["reversible"], false); + } + #[test] fn fork_remaps_each_checkpoint_in_the_chain_on_its_own() { let db = test_db(); @@ -4516,6 +4442,33 @@ mod tests { })) ); + let mut array_tool = updated.messages[1].clone(); + array_tool.tool_result = Some(json!({ + "ok": true, + "details": { + "root": "workspace", + "reviews": [ + { "snapshotId": "snapshot-a", "state": "active" }, + { "snapshotId": "snapshot-b", "state": "active" } + ] + } + })); + replace_messages( + &db, + &session.id, + &[user_msg("m1", "shell", "2025-05-01T00:00:00Z"), array_tool], + ) + .unwrap(); + assert!( + update_tool_review_state(&db, &session.id, "m2", "snapshot-b", "rolledBack",).unwrap() + ); + let array_updated = get_session(&db, &session.id).unwrap().unwrap(); + let reviews = array_updated.messages[1].tool_result.as_ref().unwrap()["details"]["reviews"] + .as_array() + .unwrap(); + assert_eq!(reviews[0]["state"], "active"); + assert_eq!(reviews[1]["state"], "rolledBack"); + // seq allocation is monotonic per session. let last_seq: i64 = db .conn() @@ -4848,6 +4801,108 @@ mod tests { assert_eq!(full.messages[1].content.len(), 80_000); } + #[test] + fn display_limited_reload_preserves_structural_review_metadata() { + let db = test_db(); + let session = create_session(&db, None, None, None, None, None).unwrap(); + let stdout = "stdout line\n".repeat(8_000); + let reviews = (0..300) + .map(|index| { + json!({ + "version": 1, + "snapshotId": format!("snapshot-{index}"), + "messageId": "tool-review", + "path": format!("src/generated/file-{index}.txt"), + "operation": "edit", + "status": "modified", + "state": if index == 299 { "rolledBack" } else { "active" }, + "additions": index + 1, + "deletions": index, + "hunks": [{ + "header": "@@ -1,1 +1,1 @@", + "lines": [{ "type": "add", "text": "x".repeat(1_024) }] + }], + "binary": index == 7, + "reversible": index != 299 + }) + }) + .collect::>(); + let tool = UiMessage { + id: "tool-review".into(), + role: "tool".into(), + content: stdout.clone(), + attachments: None, + steering: None, + created_at: "2026-09-20T00:00:00Z".into(), + thinking: None, + status: Some("complete".into()), + model_id: None, + provider_id: None, + usage: None, + response_duration_ms: None, + response_output_tokens: None, + error: None, + revision_root_id: None, + revision_count: None, + active_revision: None, + tool_name: Some("Bash".into()), + tool_call_id: Some("call-review".into()), + tool_status: Some("success".into()), + tool_args: Some(json!({ "command": "generate files" })), + tool_result: Some(json!({ + "content": [{ "type": "text", "text": stdout }], + "details": { + "root": "workspace", + "reviewCapture": { "status": "partial" }, + "reviews": reviews + } + })), + tool_completed_at: Some("2026-09-20T00:00:01Z".into()), + tool_duration_ms: Some(1_000), + is_error: None, + parent_tool_call_id: None, + agent_name: None, + hosted_search: None, + session_message: None, + }; + append_message(&db, &session.id, &tool, None).unwrap(); + + let full = get_session(&db, &session.id).unwrap().unwrap(); + let full_result = full.messages[0].tool_result.as_ref().unwrap(); + assert_eq!(full.messages[0].content, tool.content); + assert_eq!( + full_result["details"]["reviews"].as_array().unwrap().len(), + 300 + ); + + let limited = get_session_with_options( + &db, + &session.id, + SessionReadOptions { + message_around: None, + message_before: None, + message_limit: Some(1), + content_limit: Some(64 * 1_024), + }, + ) + .unwrap() + .unwrap(); + let result = limited.messages[0].tool_result.as_ref().unwrap(); + let details = &result["details"]; + assert_eq!(details["root"], "workspace"); + assert_eq!(details["reviewCapture"]["status"], "partial"); + let reviews = details["reviews"].as_array().unwrap(); + assert_eq!(reviews.len(), 300); + assert_eq!(reviews[0]["snapshotId"], "snapshot-0"); + assert_eq!(reviews[0]["version"], 1); + assert_eq!(reviews[0]["path"], "src/generated/file-0.txt"); + assert_eq!(reviews[0]["additions"], 1); + assert_eq!(reviews[7]["binary"], true); + assert_eq!(reviews[299]["snapshotId"], "snapshot-299"); + assert_eq!(reviews[299]["state"], "rolledBack"); + assert_eq!(reviews[299]["reversible"], false); + } + #[test] fn assistant_thinking_roundtrips_as_canonical_blocks() { let db = test_db(); diff --git a/crates/host-core/src/sessions/display.rs b/crates/host-core/src/sessions/display.rs new file mode 100644 index 0000000000..1aff6bfe00 --- /dev/null +++ b/crates/host-core/src/sessions/display.rs @@ -0,0 +1,621 @@ +use serde_json::{Map, Value}; + +use super::{record_to_ui, UiMessage}; +use crate::transcripts::MessageRecord; + +const DISPLAY_TRUNCATION_MARKER: &str = + "\n\n[truncated for display; the full content remains in the transcript]"; +// Shell capture can include the union of 2,000 before and 2,000 after paths. +const MAX_REVIEW_RECORDS: usize = 4_000; +const MAX_REVIEW_METADATA_BYTES: usize = 1024 * 1024; +const MAX_REVIEW_ID_BYTES: usize = 256; +// Covers Windows' maximum extended path in UTF-8, within the aggregate budget. +const MAX_REVIEW_PATH_BYTES: usize = 128 * 1024; +const MAX_HUNK_HEADER_BYTES: usize = 1024; +const MAX_PROJECTED_HUNKS: usize = 1_000; +const MAX_PROJECTED_HUNK_LINES: usize = 4_000; +const DISPLAY_TRUNCATED: &str = "[truncated for display]"; + +fn truncate_display_text(text: String, limit: usize) -> (String, bool) { + if text.chars().nth(limit).is_none() { + return (text, false); + } + let marker_len = DISPLAY_TRUNCATION_MARKER.chars().count(); + if limit <= marker_len { + return ( + DISPLAY_TRUNCATION_MARKER.chars().take(limit).collect(), + true, + ); + } + let head = limit - marker_len; + ( + format!( + "{}{}", + text.chars().take(head).collect::(), + DISPLAY_TRUNCATION_MARKER + ), + true, + ) +} + +fn preview_value(value: Value, limit: usize) -> Value { + fn walk(value: Value, budget: &mut usize) -> Value { + if *budget == 0 { + return Value::String(DISPLAY_TRUNCATED.into()); + } + match value { + Value::String(text) => { + let (clipped, _) = truncate_display_text(text, *budget); + *budget = (*budget).saturating_sub(clipped.chars().count()); + Value::String(clipped) + } + Value::Array(items) => { + let mut output = Vec::with_capacity(items.len().min(32)); + for item in items { + if *budget == 0 || output.len() >= 256 { + output.push(Value::String(DISPLAY_TRUNCATED.into())); + break; + } + output.push(walk(item, budget)); + } + Value::Array(output) + } + Value::Object(object) => { + let mut output = Map::new(); + for (key, item) in object { + if *budget == 0 || output.len() >= 256 { + output.insert("_truncated".into(), Value::Bool(true)); + break; + } + output.insert(key, walk(item, budget)); + } + Value::Object(output) + } + other => other, + } + } + + let mut budget = limit.max(1); + walk(value, &mut budget) +} + +struct ReviewProjectionBudget { + metadata_bytes: usize, + hunk_chars: usize, + hunks: usize, + hunk_lines: usize, +} + +impl ReviewProjectionBudget { + fn new(display_limit: usize) -> Self { + Self { + metadata_bytes: MAX_REVIEW_METADATA_BYTES, + hunk_chars: display_limit.max(1), + hunks: MAX_PROJECTED_HUNKS, + hunk_lines: MAX_PROJECTED_HUNK_LINES, + } + } +} +fn truncate_display_str(text: &str, limit: usize) -> (String, bool) { + if text.chars().nth(limit).is_none() { + return (text.to_string(), false); + } + let marker_len = DISPLAY_TRUNCATION_MARKER.chars().count(); + if limit <= marker_len { + return ( + DISPLAY_TRUNCATION_MARKER.chars().take(limit).collect(), + true, + ); + } + let head = limit - marker_len; + ( + format!( + "{}{}", + text.chars().take(head).collect::(), + DISPLAY_TRUNCATION_MARKER + ), + true, + ) +} + +fn bounded_non_empty_string(value: Option<&Value>, max_bytes: usize) -> Option<&str> { + let value = value?.as_str()?; + (value.len() <= max_bytes && !value.trim().is_empty()).then_some(value) +} + +fn known_string<'a>(value: Option<&'a Value>, allowed: &[&str]) -> Option<&'a str> { + let value = value?.as_str()?; + allowed.contains(&value).then_some(value) +} + +fn project_hunks(value: Option<&Value>, budget: &mut ReviewProjectionBudget) -> (Vec, bool) { + let Some(hunks) = value.and_then(Value::as_array) else { + return (Vec::new(), true); + }; + let mut output = Vec::new(); + let mut clipped = false; + + for hunk in hunks { + if budget.hunks == 0 || budget.hunk_chars == 0 { + clipped = true; + break; + } + let Some(hunk) = hunk.as_object() else { + clipped = true; + continue; + }; + let Some(header) = bounded_non_empty_string(hunk.get("header"), MAX_HUNK_HEADER_BYTES) + else { + clipped = true; + continue; + }; + let header_chars = header.chars().count(); + if header_chars > budget.hunk_chars { + clipped = true; + break; + } + let Some(lines) = hunk.get("lines").and_then(Value::as_array) else { + clipped = true; + continue; + }; + + budget.hunks -= 1; + budget.hunk_chars -= header_chars; + let mut projected_lines = Vec::new(); + for line in lines { + if budget.hunk_lines == 0 || budget.hunk_chars == 0 { + clipped = true; + break; + } + let Some(line) = line.as_object() else { + clipped = true; + continue; + }; + let Some(line_type) = known_string(line.get("type"), &["add", "del", "context"]) else { + clipped = true; + continue; + }; + let Some(text) = line.get("text").and_then(Value::as_str) else { + clipped = true; + continue; + }; + let (text, text_clipped) = truncate_display_str(text, budget.hunk_chars); + budget.hunk_chars = budget.hunk_chars.saturating_sub(text.chars().count()); + budget.hunk_lines -= 1; + clipped |= text_clipped; + projected_lines.push(Value::Object(Map::from_iter([ + ("type".into(), Value::String(line_type.into())), + ("text".into(), Value::String(text)), + ]))); + if text_clipped { + break; + } + } + output.push(Value::Object(Map::from_iter([ + ("header".into(), Value::String(header.into())), + ("lines".into(), Value::Array(projected_lines)), + ]))); + } + + (output, clipped) +} + +fn project_review(value: &Value, budget: &mut ReviewProjectionBudget) -> Option { + let review = value.as_object()?; + if review.get("version").and_then(Value::as_u64) != Some(1) { + return None; + } + let snapshot_id = bounded_non_empty_string(review.get("snapshotId"), MAX_REVIEW_ID_BYTES)?; + let message_id = bounded_non_empty_string(review.get("messageId"), MAX_REVIEW_ID_BYTES)?; + let path = bounded_non_empty_string(review.get("path"), MAX_REVIEW_PATH_BYTES)?; + let operation = known_string(review.get("operation"), &["write", "edit", "delete"])?; + let status = known_string(review.get("status"), &["added", "modified", "deleted"])?; + let state = known_string(review.get("state"), &["active", "rolledBack"])?; + let additions = review.get("additions").and_then(Value::as_u64)?; + let deletions = review.get("deletions").and_then(Value::as_u64)?; + let reversible = review.get("reversible").and_then(Value::as_bool)?; + let metadata_bytes = snapshot_id + .len() + .saturating_add(message_id.len()) + .saturating_add(path.len()); + if metadata_bytes > budget.metadata_bytes { + return None; + } + budget.metadata_bytes -= metadata_bytes; + + let (hunks, hunks_clipped) = project_hunks(review.get("hunks"), budget); + let mut output = Map::from_iter([ + ("version".into(), Value::from(1)), + ("snapshotId".into(), Value::String(snapshot_id.into())), + ("messageId".into(), Value::String(message_id.into())), + ("path".into(), Value::String(path.into())), + ("operation".into(), Value::String(operation.into())), + ("status".into(), Value::String(status.into())), + ("state".into(), Value::String(state.into())), + ("additions".into(), Value::from(additions)), + ("deletions".into(), Value::from(deletions)), + ("hunks".into(), Value::Array(hunks)), + ("reversible".into(), Value::Bool(reversible)), + ]); + if review.get("binary").and_then(Value::as_bool) == Some(true) { + output.insert("binary".into(), Value::Bool(true)); + } + if hunks_clipped || review.get("truncated").and_then(Value::as_bool) == Some(true) { + output.insert("truncated".into(), Value::Bool(true)); + } + Some(Value::Object(output)) +} + +fn has_review_metadata(result: &Value) -> bool { + result + .get("details") + .and_then(Value::as_object) + .is_some_and(|details| { + details.get("root").and_then(Value::as_str) == Some("workspace") + && ["reviewCapture", "review", "reviews"] + .iter() + .any(|key| details.contains_key(*key)) + }) +} + +fn project_review_details(result: &Value, limit: usize) -> Option> { + let details = result.get("details")?.as_object()?; + if !has_review_metadata(result) { + return None; + } + + let mut output = Map::new(); + if details.get("root").and_then(Value::as_str) == Some("workspace") { + output.insert("root".into(), Value::String("workspace".into())); + } + if let Some(status) = details + .get("reviewCapture") + .and_then(Value::as_object) + .and_then(|capture| { + known_string( + capture.get("status"), + &["complete", "partial", "unavailable"], + ) + }) + { + output.insert( + "reviewCapture".into(), + Value::Object(Map::from_iter([( + "status".into(), + Value::String(status.into()), + )])), + ); + } + + let mut budget = ReviewProjectionBudget::new(limit); + if let Some(review) = details + .get("review") + .and_then(|review| project_review(review, &mut budget)) + { + output.insert("review".into(), review); + } + if let Some(reviews) = details.get("reviews").and_then(Value::as_array) { + let projected = reviews + .iter() + .take(MAX_REVIEW_RECORDS) + .filter_map(|review| project_review(review, &mut budget)) + .collect(); + output.insert("reviews".into(), Value::Array(projected)); + } + Some(output) +} + +fn preview_tool_result(value: Value, limit: usize) -> Value { + let review_details = project_review_details(&value, limit); + let mut output = preview_value(value, limit); + let (Some(review_details), Some(result)) = (review_details, output.as_object_mut()) else { + return output; + }; + + let details = result + .entry("details") + .or_insert_with(|| Value::Object(Map::new())); + if !details.is_object() { + *details = Value::Object(Map::new()); + } + if let Some(details) = details.as_object_mut() { + for key in ["root", "reviewCapture", "review", "reviews"] { + details.remove(key); + } + details.extend(review_details); + } + output +} +fn finish_tool_result_preview(value: Value, limit: usize) -> Value { + if has_review_metadata(&value) { + value + } else { + preview_value(value, limit) + } +} + +fn cap_record_block_value(value: &mut Value, budget: &mut usize) { + let original = std::mem::take(value); + *value = match original { + Value::String(text) if *budget > 0 => { + let (clipped, _) = truncate_display_text(text, *budget); + *budget = budget.saturating_sub(clipped.chars().count()); + Value::String(clipped) + } + Value::String(_) => Value::String(String::new()), + other => other, + }; +} + +fn is_native_review_tool(name: Option<&str>) -> bool { + matches!(name, Some("Write" | "Edit" | "Bash")) +} + +fn cap_record_blocks_for_display(record: &mut MessageRecord, limit: usize) { + let Some(blocks) = record.blocks.as_array_mut() else { + return; + }; + let mut text_budget = limit.max(1); + let mut thinking_budget = limit.max(1); + for block in blocks { + let Some(object) = block.as_object_mut() else { + continue; + }; + match object.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = object.get_mut("text") { + cap_record_block_value(text, &mut text_budget); + } + } + Some("thinking") => { + if let Some(text) = object.get_mut("text") { + cap_record_block_value(text, &mut thinking_budget); + } + } + Some("tool_call") => { + let preserve_reviews = + is_native_review_tool(object.get("name").and_then(Value::as_str)); + if let Some(value) = object.get_mut("args") { + *value = preview_value(std::mem::take(value), limit); + } + if let Some(value) = object.get_mut("result") { + let original = std::mem::take(value); + *value = if preserve_reviews { + preview_tool_result(original, limit) + } else { + preview_value(original, limit) + }; + } + if let Some(text) = object.get_mut("text") { + cap_record_block_value(text, &mut text_budget); + } + } + _ => {} + } + } +} + +pub(super) fn record_to_ui_for_display(mut record: MessageRecord, limit: usize) -> UiMessage { + // Bound canonical values before record_to_ui clones the selected tool block. + cap_record_blocks_for_display(&mut record, limit); + let mut message = record_to_ui(record); + let (content, _) = truncate_display_text(message.content, limit); + message.content = content; + if let Some(thinking) = message.thinking.take() { + let (thinking, _) = truncate_display_text(thinking, limit); + message.thinking = Some(thinking); + } + if let Some(args) = message.tool_args.take() { + message.tool_args = Some(preview_value(args, limit)); + } + if let Some(result) = message.tool_result.take() { + message.tool_result = Some(if is_native_review_tool(message.tool_name.as_deref()) { + finish_tool_result_preview(result, limit) + } else { + preview_value(result, limit) + }); + } + if let Some(error) = message.error.take() { + message.error = Some(preview_value(error, limit)); + } + message +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn valid_review(index: usize, hunk_text: &str) -> Value { + json!({ + "version": 1, + "snapshotId": format!("snapshot-{index}"), + "messageId": "tool-message", + "path": format!("src/file-{index}.txt"), + "operation": "edit", + "status": "modified", + "state": if index.is_multiple_of(2) { "active" } else { "rolledBack" }, + "additions": index + 1, + "deletions": index, + "hunks": [{ + "header": "@@ -1,1 +1,1 @@", + "lines": [{ "type": "add", "text": hunk_text }] + }], + "binary": index == 7, + "reversible": index.is_multiple_of(2), + }) + } + + #[test] + fn review_projection_preserves_legacy_record_and_clips_hunks() { + let result = preview_tool_result( + json!({ + "content": [{ "type": "text", "text": "x".repeat(10_000) }], + "details": { + "root": "workspace", + "review": valid_review(7, &"line".repeat(1_000)), + "reviewCapture": { "status": "complete", "unknown": "ignored" } + } + }), + 256, + ); + let details = &result["details"]; + assert_eq!(details["root"], "workspace"); + assert_eq!(details["reviewCapture"], json!({ "status": "complete" })); + assert_eq!(details["review"]["snapshotId"], "snapshot-7"); + assert_eq!(details["review"]["state"], "rolledBack"); + assert_eq!(details["review"]["binary"], true); + assert_eq!(details["review"]["truncated"], true); + assert!(details["review"].get("unknown").is_none()); + let lines = details["review"]["hunks"][0]["lines"].as_array().unwrap(); + assert_eq!(lines[0]["type"], "add"); + assert!(lines[0]["text"] + .as_str() + .unwrap() + .contains("truncated for display")); + } + + #[test] + fn review_projection_keeps_native_union_plural_identities() { + let reviews = (0..MAX_REVIEW_RECORDS) + .map(|index| valid_review(index, "")) + .collect::>(); + let result = preview_tool_result( + json!({ + "content": [{ "type": "text", "text": "x".repeat(80_000) }], + "details": { + "root": "workspace", + "reviewCapture": { "status": "partial" }, + "reviews": reviews + } + }), + 64 * 1024, + ); + let reviews = result["details"]["reviews"].as_array().unwrap(); + assert_eq!(reviews.len(), MAX_REVIEW_RECORDS); + assert_eq!(reviews[0]["snapshotId"], "snapshot-0"); + let last = MAX_REVIEW_RECORDS - 1; + assert_eq!(reviews[last]["snapshotId"], format!("snapshot-{last}")); + assert_eq!(reviews[last]["state"], "rolledBack"); + assert_eq!(reviews[last]["path"], format!("src/file-{last}.txt")); + } + + #[test] + fn review_projection_drops_malformed_and_oversized_records() { + let oversized_id = "x".repeat(MAX_REVIEW_ID_BYTES + 1); + let mut missing_reversible = valid_review(2, "ok"); + missing_reversible + .as_object_mut() + .unwrap() + .remove("reversible"); + let mut valid = valid_review(3, "ok"); + valid["unknown"] = Value::String("z".repeat(100_000)); + let result = preview_tool_result( + json!({ + "details": { + "root": "workspace", + "reviews": [ + { "version": 1, "snapshotId": oversized_id }, + { "version": 2, "snapshotId": "future" }, + missing_reversible, + "not-an-object", + valid + ], + "unknown": "u".repeat(100_000) + } + }), + 128, + ); + let reviews = result["details"]["reviews"].as_array().unwrap(); + assert_eq!(reviews.len(), 1); + assert_eq!(reviews[0]["snapshotId"], "snapshot-3"); + assert!(serde_json::to_vec(&result).unwrap().len() < 8 * 1024); + } + + #[test] + fn unrelated_values_keep_original_double_preview_semantics() { + let value = json!({ "alpha": "x".repeat(1_000), "omega": "kept only with budget" }); + let first = preview_tool_result(value.clone(), 64); + assert_eq!( + finish_tool_result_preview(first, 64), + preview_value(preview_value(value, 64), 64) + ); + } + + #[test] + fn non_workspace_results_keep_original_preview_semantics() { + for root in ["scratch", "external"] { + let value = json!({ "details": { + "root": root, + "review": valid_review(0, "small"), + "reviewCapture": { "status": "unavailable" } + }}); + let first = preview_tool_result(value.clone(), 4096); + assert_eq!(first["details"]["root"], root); + assert_eq!( + finish_tool_result_preview(first, 4096), + preview_value(preview_value(value, 4096), 4096) + ); + } + } + + #[test] + fn malformed_hunks_are_explicitly_truncated() { + for hunks in [None, Some(json!("damaged"))] { + let mut review = valid_review(0, "small"); + match hunks { + Some(value) => { + review["hunks"] = value; + } + None => { + review.as_object_mut().unwrap().remove("hunks"); + } + } + let result = preview_tool_result( + json!({ "details": { + "root": "workspace", "review": review + }}), + 1024, + ); + assert_eq!(result["details"]["review"]["hunks"], json!([])); + assert_eq!(result["details"]["review"]["truncated"], true); + } + } + + #[test] + fn long_native_paths_keep_their_review_identity() { + let path = format!("{}file.txt", "long-directory/".repeat(600)); + let mut review = valid_review(0, "small"); + review["path"] = Value::String(path.clone()); + let result = preview_tool_result( + json!({ "details": { + "root": "workspace", "review": review + }}), + 256, + ); + assert_eq!(result["details"]["review"]["path"], path); + assert_eq!(result["details"]["review"]["snapshotId"], "snapshot-0"); + } + + #[test] + fn unrelated_tool_review_fields_keep_generic_projection() { + for tool_name in ["Read", "mcp_example", "plugin_example"] { + let result = json!({ "content": "x".repeat(10_000), "details": { + "root": "workspace", "review": valid_review(0, "small") + }}); + let message: UiMessage = serde_json::from_value(json!({ + "id": "unrelated-tool", "role": "tool", "content": "", + "createdAt": "2026-09-20T00:00:00Z", "toolName": tool_name, + "toolResult": result.clone() + })) + .unwrap(); + let (record, _) = crate::sessions::ui_to_record(&message); + let projected = record_to_ui_for_display(record, 256); + assert_eq!( + projected.tool_result, + Some(preview_value(preview_value(result, 256), 256)) + ); + } + } +} diff --git a/crates/host-core/src/state.rs b/crates/host-core/src/state.rs index 76de5500e4..03181790a2 100644 --- a/crates/host-core/src/state.rs +++ b/crates/host-core/src/state.rs @@ -10,7 +10,7 @@ use crate::permissions::PermissionManager; use crate::plans::PlanManager; use crate::plugins::PluginManager; use crate::secrets::SecretStore; -use crate::tool_budget::ToolBudget; +use crate::tool_budget::{ToolBudget, WorkspaceMutationLocks}; use crate::user_skills::UserSkillRegistry; use crate::user_subagents::UserSubagentRegistry; use crate::workspace::WorkspaceState; @@ -59,6 +59,7 @@ pub struct AppState { pub plugin_batch_import_rates: HashMap>, pub plugin_delete_rates: HashMap>, pub tool_budget: ToolBudget, + pub workspace_mutation_locks: WorkspaceMutationLocks, /// (session_id, tool_call_id) -> cancellation signal for an active Bash /// process. The signal is removed by the execution owner in all outcomes. pub active_bash_cancellations: HashMap<(String, String), tokio::sync::watch::Sender>, @@ -146,6 +147,7 @@ impl AppState { plugin_batch_import_rates: HashMap::new(), plugin_delete_rates: HashMap::new(), tool_budget: ToolBudget::new(), + workspace_mutation_locks: WorkspaceMutationLocks::default(), active_bash_cancellations: HashMap::new(), pending_permissions: HashMap::new(), pending_bash_aborts: HashMap::new(), diff --git a/crates/host-core/src/tool_budget.rs b/crates/host-core/src/tool_budget.rs index 3e224d2a91..1e478503cc 100644 --- a/crates/host-core/src/tool_budget.rs +++ b/crates/host-core/src/tool_budget.rs @@ -1,9 +1,10 @@ use std::collections::HashMap; +use std::path::Path; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use std::time::Duration; -use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore}; +use tokio::sync::{Mutex, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore}; pub const MAX_IN_FLIGHT_TOOLS: usize = 16; pub const MAX_IN_FLIGHT_SHELL: usize = 4; @@ -13,7 +14,7 @@ pub const MAX_IN_FLIGHT_MUTATIONS_PER_SESSION: usize = 1; pub const MAX_IN_FLIGHT_PLUGINS: usize = 4; pub const MAX_IN_FLIGHT_PER_SESSION: usize = 4; pub const MAX_QUEUED_TOOLS: usize = 64; -/// How long a call waits for its class permit before admission fails. A call +/// How long a call waits for workspace, class and session admission. A call /// waits here after the permission gate and before it runs, so the transport /// deadline has to carry it too. Mirrored by `TOOL_QUEUE_WAIT_MS` in /// `packages/shared/src/rpc-timeouts.ts`. @@ -67,6 +68,58 @@ pub struct ToolPermit { _class: OwnedSemaphorePermit, _session: OwnedSemaphorePermit, _session_mutation: Option, + _workspace_mutation: Option>, +} + +struct QueuedTool { + queued: Arc, +} + +impl QueuedTool { + fn enter(queued: Arc) -> Result { + queued + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |depth| { + (depth < MAX_QUEUED_TOOLS).then_some(depth + 1) + }) + .map_err(|depth| AdmissionError::QueueFull { + queue_depth: depth + 1, + })?; + Ok(Self { queued }) + } +} + +impl Drop for QueuedTool { + fn drop(&mut self) { + self.queued.fetch_sub(1, Ordering::SeqCst); + } +} + +#[derive(Clone, Default)] +pub struct WorkspaceMutationLocks { + locks: Arc>>>>, +} + +impl WorkspaceMutationLocks { + async fn lock_for(&self, root: &Path) -> Arc> { + let key = crate::workspace::simple_canonicalize(root) + .unwrap_or_else(|_| root.to_path_buf()) + .to_string_lossy() + .to_string(); + let mut locks = self.locks.lock().await; + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) { + return lock; + } + let lock = Arc::new(Mutex::new(())); + locks.insert(key, Arc::downgrade(&lock)); + lock + } + + pub(crate) async fn acquire(&self, root: &Path) -> Result, AdmissionError> { + tokio::time::timeout(QUEUE_WAIT, self.lock_for(root).await.lock_owned()) + .await + .map_err(|_| AdmissionError::QueueWaitTimeout) + } } #[derive(Debug, Clone, Copy)] @@ -110,8 +163,19 @@ impl ToolBudget { &self, session_id: &str, tool_name: &str, + workspace_root: Option<&Path>, + workspace_locks: &WorkspaceMutationLocks, ) -> Result { + let deadline = tokio::time::Instant::now() + QUEUE_WAIT; let class = ToolClass::from_name(tool_name); + let workspace_lock = if matches!(class, ToolClass::Mutation | ToolClass::Shell) { + match workspace_root { + Some(root) => Some(workspace_locks.lock_for(root).await), + None => None, + } + } else { + None + }; let class_semaphore = self.class_semaphore(class); let session_semaphore = self.session_semaphore(session_id).await; let session_mutation_semaphore = match class { @@ -119,37 +183,42 @@ impl ToolBudget { _ => None, }; - if let Some(permit) = Self::try_acquire( - self.total.clone(), - class_semaphore.clone(), - session_semaphore.clone(), - session_mutation_semaphore.clone(), - ) { - return Ok(permit); - } - - let queue_depth = self.queued.fetch_add(1, Ordering::SeqCst) + 1; - if queue_depth > MAX_QUEUED_TOOLS { - self.queued.fetch_sub(1, Ordering::SeqCst); - return Err(AdmissionError::QueueFull { queue_depth }); + // Try every resource without waiting, releasing partial guards on failure. + // In particular, never reserve class/total capacity behind a workspace lock. + let workspace_mutation = match &workspace_lock { + Some(lock) => lock.clone().try_lock_owned().map(Some), + None => Ok(None), + }; + if let Ok(workspace_mutation) = workspace_mutation { + if let Some(mut permit) = Self::try_acquire( + self.total.clone(), + class_semaphore.clone(), + session_semaphore.clone(), + session_mutation_semaphore.clone(), + ) { + permit._workspace_mutation = workspace_mutation; + return Ok(permit); + } } - let result = tokio::time::timeout( - QUEUE_WAIT, - Self::acquire_all( + let _queued = QueuedTool::enter(self.queued.clone())?; + tokio::time::timeout_at(deadline, async { + let workspace_mutation = match workspace_lock { + Some(lock) => Some(lock.lock_owned().await), + None => None, + }; + let mut permit = Self::acquire_all( self.total.clone(), class_semaphore, session_semaphore, session_mutation_semaphore, - ), - ) - .await; - self.queued.fetch_sub(1, Ordering::SeqCst); - - match result { - Ok(permit) => Ok(permit), - Err(_) => Err(AdmissionError::QueueWaitTimeout), - } + ) + .await; + permit._workspace_mutation = workspace_mutation; + permit + }) + .await + .map_err(|_| AdmissionError::QueueWaitTimeout) } pub fn snapshot(&self) -> ToolBudgetSnapshot { @@ -196,9 +265,6 @@ impl ToolBudget { session: Arc, session_mutation: Option>, ) -> Option { - // Reserve the narrow per-session mutation slot first. This keeps a - // queued second Write/Edit from consuming a global mutation permit - // while it waits for the first mutation in the same session. let session_mutation_permit = match session_mutation { Some(semaphore) => Some(semaphore.try_acquire_owned().ok()?), None => None, @@ -211,6 +277,7 @@ impl ToolBudget { _class: class_permit, _session: session_permit, _session_mutation: session_mutation_permit, + _workspace_mutation: None, }) } @@ -220,9 +287,6 @@ impl ToolBudget { session: Arc, session_mutation: Option>, ) -> ToolPermit { - // Keep the per-session mutation permit outside the global capacity - // wait so one session cannot reserve global slots while its earlier - // mutation is still running. let session_mutation_permit = match session_mutation { Some(semaphore) => Some( semaphore @@ -249,6 +313,7 @@ impl ToolBudget { _class: class_permit, _session: session_permit, _session_mutation: session_mutation_permit, + _workspace_mutation: None, } } } @@ -261,67 +326,308 @@ impl Default for ToolBudget { #[cfg(test)] mod tests { - use super::ToolBudget; + use super::{AdmissionError, ToolBudget, WorkspaceMutationLocks, MAX_QUEUED_TOOLS, QUEUE_WAIT}; + use std::future::{poll_fn, Future}; + use std::path::Path; + use std::pin::Pin; + use std::task::Poll; use std::time::Duration; + async fn assert_pending(mut future: Pin<&mut F>) { + poll_fn(|cx| { + assert!(future.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + } + + #[tokio::test(start_paused = true)] + async fn workspace_and_class_waits_share_one_deadline() { + let budget = ToolBudget::new(); + let locks = WorkspaceMutationLocks::default(); + let root = Path::new("shared"); + let workspace = locks.acquire(root).await.unwrap(); + let mut holders = Vec::new(); + for index in 0..super::MAX_IN_FLIGHT_MUTATIONS { + holders.push( + budget + .acquire(&format!("holder-{index}"), "Edit", None, &locks) + .await + .unwrap(), + ); + } + let mut waiter = Box::pin(budget.acquire("waiter", "Write", Some(root), &locks)); + assert_pending(waiter.as_mut()).await; + tokio::time::advance(Duration::from_secs(20)).await; + drop(workspace); + assert_pending(waiter.as_mut()).await; + tokio::time::advance(Duration::from_secs(10)).await; + let result = poll_fn(|cx| match waiter.as_mut().poll(cx) { + Poll::Ready(result) => Poll::Ready(result), + Poll::Pending => { + panic!("workspace and class waits exceeded the single admission deadline") + } + }) + .await; + assert!(matches!(result, Err(AdmissionError::QueueWaitTimeout))); + assert_eq!(budget.snapshot().queued, 0); + assert_eq!(budget.snapshot().active, holders.len()); + assert!(locks.lock_for(root).await.try_lock_owned().is_ok()); + drop(holders); + assert!(budget + .acquire("waiter", "Write", Some(root), &locks) + .await + .is_ok()); + assert_eq!(budget.snapshot().active, 0); + } + + #[tokio::test(start_paused = true)] + async fn workspace_and_class_waiters_share_queue_capacity() { + let budget = ToolBudget::new(); + let locks = WorkspaceMutationLocks::default(); + let root = Path::new("shared"); + let workspace = locks.acquire(root).await.unwrap(); + let mut holders = Vec::new(); + for index in 0..super::MAX_IN_FLIGHT_SHELL { + holders.push( + budget + .acquire(&format!("holder-{index}"), "Bash", None, &locks) + .await + .unwrap(), + ); + } + let mut waiters = Vec::new(); + for index in 0..MAX_QUEUED_TOOLS { + let workspace_root = if index == 0 { None } else { Some(root) }; + let mut waiter = Box::pin(budget.acquire("waiter", "Bash", workspace_root, &locks)); + assert_pending(waiter.as_mut()).await; + // Refill Tokio's cooperative budget before polling the next admission. + tokio::task::yield_now().await; + waiters.push(waiter); + } + assert_eq!(budget.snapshot().queued, MAX_QUEUED_TOOLS); + // Only the class waiter can reserve a total permit; workspace waiters cannot. + assert_eq!(budget.snapshot().active, holders.len() + 1); + let mut overflow = Box::pin(budget.acquire("overflow", "Edit", Some(root), &locks)); + let result = poll_fn(|cx| match overflow.as_mut().poll(cx) { + Poll::Ready(result) => Poll::Ready(result), + Poll::Pending => panic!("a full queue must reject workspace waiters immediately"), + }) + .await; + assert!(matches!(result, Err(AdmissionError::QueueFull { .. }))); + assert_eq!(budget.snapshot().queued, MAX_QUEUED_TOOLS); + // Free capacity still takes the immediate path even when the queue is full. + assert!(budget.acquire("reader", "Read", None, &locks).await.is_ok()); + // Queue rejection also releases a workspace acquired by the fast path. + let other = Path::new("other"); + assert!(matches!( + budget + .acquire("overflow", "Bash", Some(other), &locks) + .await, + Err(AdmissionError::QueueFull { .. }) + )); + assert!(locks.lock_for(other).await.try_lock_owned().is_ok()); + drop(waiters); + assert_eq!(budget.snapshot().queued, 0); + assert_eq!(budget.snapshot().active, holders.len()); + drop(workspace); + drop(holders); + assert!(budget + .acquire("waiter", "Bash", Some(root), &locks) + .await + .is_ok()); + } + + #[tokio::test(start_paused = true)] + async fn cancelled_workspace_waiter_releases_queue_slot() { + let budget = ToolBudget::new(); + let locks = WorkspaceMutationLocks::default(); + let root = Path::new("shared"); + let holder = budget + .acquire("holder", "Bash", Some(root), &locks) + .await + .unwrap(); + let mut waiter = Box::pin(budget.acquire("waiter", "Edit", Some(root), &locks)); + assert_pending(waiter.as_mut()).await; + assert_eq!(budget.snapshot().queued, 1); + assert_eq!(budget.snapshot().active, 1); + assert_eq!(budget.snapshot().mutations, 0); + drop(waiter); + assert_eq!(budget.snapshot().queued, 0); + drop(holder); + let permit = budget + .acquire("waiter", "Edit", Some(root), &locks) + .await + .unwrap(); + assert!(locks.lock_for(root).await.try_lock_owned().is_err()); + drop(permit); + assert!(locks.lock_for(root).await.try_lock_owned().is_ok()); + assert_eq!(budget.snapshot().active, 0); + } + + #[tokio::test(start_paused = true)] + async fn cancelled_class_or_session_waiter_releases_queue_lock_and_permits() { + for block_class in [true, false] { + let budget = ToolBudget::new(); + let locks = WorkspaceMutationLocks::default(); + let root = Path::new("shared"); + let mut holders = Vec::new(); + let count = if block_class { + super::MAX_IN_FLIGHT_MUTATIONS + } else { + super::MAX_IN_FLIGHT_PER_SESSION + }; + for index in 0..count { + let session = if block_class { + format!("holder-{index}") + } else { + "waiter".to_string() + }; + let tool = if block_class { "Edit" } else { "Read" }; + holders.push(budget.acquire(&session, tool, None, &locks).await.unwrap()); + } + let mut waiter = Box::pin(budget.acquire("waiter", "Write", Some(root), &locks)); + assert_pending(waiter.as_mut()).await; + assert_eq!(budget.snapshot().queued, 1); + assert_eq!(budget.snapshot().active, holders.len() + 1); + assert!(locks.lock_for(root).await.try_lock_owned().is_err()); + drop(waiter); + assert_eq!(budget.snapshot().queued, 0); + assert_eq!(budget.snapshot().active, holders.len()); + assert_eq!( + budget.snapshot().mutations, + if block_class { count } else { 0 } + ); + assert!(locks.lock_for(root).await.try_lock_owned().is_ok()); + assert_eq!( + budget + .session_mutation_semaphore("waiter") + .await + .available_permits(), + 1 + ); + drop(holders); + assert!(budget + .acquire("waiter", "Write", Some(root), &locks) + .await + .is_ok()); + assert_eq!(budget.snapshot().active, 0); + } + } + + #[tokio::test(start_paused = true)] + async fn standalone_workspace_wait_remains_bounded() { + let locks = WorkspaceMutationLocks::default(); + let root = Path::new("shared"); + let holder = locks.acquire(root).await.unwrap(); + let mut waiter = Box::pin(locks.acquire(root)); + assert_pending(waiter.as_mut()).await; + tokio::time::advance(QUEUE_WAIT).await; + assert!(matches!( + waiter.await, + Err(AdmissionError::QueueWaitTimeout) + )); + drop(holder); + assert!(locks.acquire(root).await.is_ok()); + } + #[tokio::test] async fn limits_shell_concurrency_and_reports_active_work() { let budget = ToolBudget::new(); + let locks = WorkspaceMutationLocks::default(); let mut permits = Vec::new(); for index in 0..4 { permits.push( budget - .acquire(&format!("session-{index}"), "Bash") + .acquire(&format!("session-{index}"), "Bash", None, &locks) .await .unwrap(), ); } - let snapshot = budget.snapshot(); assert_eq!(snapshot.active, 4); assert_eq!(snapshot.shell, 4); - assert_eq!(snapshot.queued, 0); - let waiting_budget = budget.clone(); - let waiter = - tokio::spawn(async move { waiting_budget.acquire("session-waiter", "Bash").await }); + let waiting_locks = locks.clone(); + let waiter = tokio::spawn(async move { + waiting_budget + .acquire("session-waiter", "Bash", None, &waiting_locks) + .await + }); drop(permits); assert!(waiter.await.unwrap().is_ok()); - assert_eq!(budget.snapshot().active, 0); } #[tokio::test] async fn separates_session_capacity() { let budget = ToolBudget::new(); + let locks = WorkspaceMutationLocks::default(); let mut first = Vec::new(); for _ in 0..4 { - first.push(budget.acquire("session-a", "Read").await.unwrap()); + first.push( + budget + .acquire("session-a", "Read", None, &locks) + .await + .unwrap(), + ); } - - let second = budget.acquire("session-b", "Read").await; - assert!(second.is_ok()); - let waiting_budget = budget.clone(); - let waiter = tokio::spawn(async move { waiting_budget.acquire("session-a", "Read").await }); + assert!(budget + .acquire("session-b", "Read", None, &locks) + .await + .is_ok()); drop(first); - assert!(waiter.await.unwrap().is_ok()); } #[tokio::test] async fn serializes_mutations_within_a_session() { let budget = ToolBudget::new(); - let first = budget.acquire("session-a", "Edit").await.unwrap(); + let locks = WorkspaceMutationLocks::default(); + let first = budget + .acquire("session-a", "Edit", None, &locks) + .await + .unwrap(); let mut waiter = tokio::spawn({ let budget = budget.clone(); - async move { budget.acquire("session-a", "Write").await } + let locks = locks.clone(); + async move { budget.acquire("session-a", "Write", None, &locks).await } }); - assert!(tokio::time::timeout(Duration::from_millis(50), &mut waiter) .await .is_err()); - assert_eq!(budget.snapshot().mutations, 1); + drop(first); + assert!(tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .unwrap() + .unwrap() + .is_ok()); + } + #[tokio::test] + async fn serializes_same_workspace_across_sessions_but_not_other_workspaces() { + let budget = ToolBudget::new(); + let locks = WorkspaceMutationLocks::default(); + let first = budget + .acquire("session-a", "Bash", Some(Path::new("shared")), &locks) + .await + .unwrap(); + let mut same = tokio::spawn({ + let budget = budget.clone(); + let locks = locks.clone(); + async move { + budget + .acquire("session-b", "Write", Some(Path::new("shared")), &locks) + .await + } + }); + assert!(budget + .acquire("session-c", "Edit", Some(Path::new("other")), &locks) + .await + .is_ok()); + assert!(tokio::time::timeout(Duration::from_millis(50), &mut same) + .await + .is_err()); drop(first); - assert!(tokio::time::timeout(Duration::from_secs(1), &mut waiter) + assert!(tokio::time::timeout(Duration::from_secs(1), same) .await .unwrap() .unwrap() diff --git a/crates/host-core/src/tools/ignore_rules.rs b/crates/host-core/src/tools/ignore_rules.rs index 09b549c7b0..f6feb8d7b9 100644 --- a/crates/host-core/src/tools/ignore_rules.rs +++ b/crates/host-core/src/tools/ignore_rules.rs @@ -48,6 +48,9 @@ pub const DEFAULT_IGNORE_DIRS: &[&str] = &[ ".git", ]; +/// Extra directories excluded only from automatic shell review scans. +pub(crate) const SHELL_REVIEW_IGNORE_DIRS: &[&str] = &[".gradle"]; + /// File names hidden from unscoped walks (spec 15 §4). const DEFAULT_IGNORE_FILES: &[&str] = &[".DS_Store"]; @@ -128,18 +131,31 @@ pub fn user_global_ignore_file() -> Option { candidate.is_file().then_some(candidate) } -/// Apply the ignore layers to an in-process walk. `scoped` walks (explicit -/// `path` argument) keep only the security denylist. -pub fn configure_walker(walker: &mut WalkBuilder, ignore_root: &Path, scoped: bool) { +fn configure_walker_inner( + walker: &mut WalkBuilder, + ignore_root: &Path, + scoped: bool, + excluded_roots: Vec, + include_user_global: bool, + extra_ignored_dirs: &'static [&'static str], +) { if !scoped { if let Some(file) = workspace_ignore_file(ignore_root) { let _ = walker.add_ignore(file); } - if let Some(file) = user_global_ignore_file() { - let _ = walker.add_ignore(file); + if include_user_global { + if let Some(file) = user_global_ignore_file() { + let _ = walker.add_ignore(file); + } } } walker.filter_entry(move |entry| { + if excluded_roots + .iter() + .any(|root| entry.path() == root || entry.path().starts_with(root)) + { + return false; + } let Some(name) = entry.file_name().to_str() else { return true; }; @@ -154,7 +170,7 @@ pub fn configure_walker(walker: &mut WalkBuilder, ignore_root: &Path, scoped: bo .and_then(Path::file_name) .is_some_and(|parent| parent == ".git")); } - return !is_default_ignored_dir_name(name); + return !is_default_ignored_dir_name(name) && !extra_ignored_dirs.contains(&name); } if is_sensitive_file_name(name) { return false; @@ -166,6 +182,30 @@ pub fn configure_walker(walker: &mut WalkBuilder, ignore_root: &Path, scoped: bo }); } +/// Apply the ignore layers to an in-process walk. `scoped` walks (explicit +/// `path` argument) keep only the security denylist. +pub fn configure_walker(walker: &mut WalkBuilder, ignore_root: &Path, scoped: bool) { + configure_walker_inner(walker, ignore_root, scoped, Vec::new(), true, &[]); +} + +pub(crate) fn configure_shell_review_walker( + walker: &mut WalkBuilder, + ignore_root: &Path, + excluded_roots: Vec, +) { + walker.git_global(false); + // Build-tool bookkeeping is not a user edit and must not consume the + // bounded shell scan. Explicit Read/Write/Edit and search keep their scope. + configure_walker_inner( + walker, + ignore_root, + false, + excluded_roots, + false, + SHELL_REVIEW_IGNORE_DIRS, + ); +} + /// Extra `rg` arguments implementing the same layers. `.env.*` is left to the /// post-filter (`is_sensitive_path`) because an rg include glob would switch /// the whole run into whitelist mode. diff --git a/docs/adr/0043-message-owned-review-snapshots-and-rollback.md b/docs/adr/0043-message-owned-review-snapshots-and-rollback.md index 66b00d7bf1..76adc4c2fc 100644 --- a/docs/adr/0043-message-owned-review-snapshots-and-rollback.md +++ b/docs/adr/0043-message-owned-review-snapshots-and-rollback.md @@ -11,6 +11,8 @@ - Amended by: [ADR 0087](0087-line-anchored-edit-contract.md) (`Edit` may now record a move as two entries, and a rollback invalidates session edit snapshots for the path) +- Amended by: [Shell workspace review evidence](shell-workspace-review-evidence.md) + (bounded Bash interval capture and multiple snapshots per message). ## Context @@ -45,10 +47,19 @@ that produced it rather than to a mutable repository snapshot. Session deletion removes it; startup removes directories for missing sessions. Forked transcripts retain visible diff evidence but mark rollback unavailable because the source snapshot belongs to another session. -5. Scratch-root writes, failed tools, and tools without a structured workspace - path do not create review records. Large or binary files may still show - status/count metadata while omitting hunks and rollback when the bounded - previous content cannot be retained. +5. Scratch-root writes and denied tools do not create review records. Failed + Write/Edit tools retain their existing behavior. Bash can produce records + after a nonzero exit or interruption when its execution already changed + files; bounded capture explicitly reports incomplete coverage. Large or binary + files retain their existing diff and rollback limits. +6. A completed visual assistant turn may also project its loaded top-level + review records into a file summary below the answer. This is a second view + of the same evidence, not a new snapshot or rollback boundary. It groups + paths, deduplicates snapshot ids and labels counts as cumulative edit totals + rather than a net turn diff. Individual records retain their existing guarded + rollback. Bash records come from bounded host capture rather than command + parsing. Nested delegate records are not attributed to the parent summary, + and a bounded transcript does not claim complete turn coverage. ## Consequences @@ -56,8 +67,8 @@ that produced it rather than to a mutable repository snapshot. - Every visible diff is tied to the exact message that produced it, including additions, deletions, and modifications. - Rollback is explicit, idempotent, and refuses to overwrite later work. -- Review no longer promises to describe unrelated shell mutations; structured - Write/Edit results are the durable review boundary. +- Bash captures workspace changes during its execution interval; external shell + history and legacy messages without evidence cannot be reconstructed. - Snapshot bytes add host-owned local storage, bounded by file and diff caps. ## Alternatives rejected diff --git a/docs/adr/0120-bounded-session-history-windows.md b/docs/adr/0120-bounded-session-history-windows.md index 5548ceeb59..e4ccb09f34 100644 --- a/docs/adr/0120-bounded-session-history-windows.md +++ b/docs/adr/0120-bounded-session-history-windows.md @@ -32,6 +32,13 @@ the full payload had already crossed the process boundary. renderer window cannot delete older messages. 4. The uncapped default remains available to non-renderer callers, so model reconstruction and persistence semantics do not depend on UI pagination. +5. Structured workspace review evidence has its own bounded display projection. + A large output string or diff hunk must not consume the identities, paths, + counts, states, or capture status needed to render recorded file changes. + Both singular `review` and plural `reviews` remain parseable after capped + reloads. Diff bodies may be shortened with an explicit truncation flag; + malformed or oversized metadata does not bypass the display bounds. The + original transcript and rollback snapshots are never rewritten by this read. ## Alternatives considered diff --git a/docs/adr/shell-workspace-review-evidence.md b/docs/adr/shell-workspace-review-evidence.md new file mode 100644 index 0000000000..1e38bbc038 --- /dev/null +++ b/docs/adr/shell-workspace-review-evidence.md @@ -0,0 +1,75 @@ +# ADR: Shell workspace review evidence + +- Status: Accepted +- Date: 2026-09-20 +- Amends: [ADR 0043](0043-message-owned-review-snapshots-and-rollback.md) + +## Context + +A task can write files to scratch and copy them into the workspace through Bash +(or PowerShell under the Bash protocol name). Write/Edit-only review omits those +actual deliverables. Parsing shell command strings cannot reliably identify +mutations, and a final Git diff includes changes that preceded the task. + +## Decision + +Host-core captures bounded before/after evidence around an admitted Bash +execution. It observes regular files inside the authorized workspace using the +existing ignore policy, excludes scratch and host data roots, and does not +follow symlinks. File enumeration, retained bytes, and emitted diff evidence are +bounded. Unreadable, oversized or unvisited paths are unknown, not absent; +partial scans must never fabricate additions or deletions. + +The host serializes its Bash, Write and Edit execution for the same canonical +workspace across sessions. The guard covers preparation, tool execution and +finalization, while different workspaces remain independent. External programs +can still mutate files during an interval; this is interval evidence rather +than an OS-level attribution audit. Rollback retains the existing post-hash +conflict guard. + +Each changed file uses the existing version-1 ReviewChange and independent +snapshot identifier. A shell result adds `root: "workspace"`, `reviews: []`, +and `reviewCapture: { status: "complete" | "partial" | "unavailable" }` to its +structured details. These additive fields leave stdout, stderr, exit status, +error and cancellation semantics intact. Captured mutations survive a nonzero +exit, timeout or cancellation. Denied commands do not execute or create capture +evidence. A complete no-op has an empty array and renders no file summary. + +The legacy singular `review` remains readable. Consumers select each record by +snapshot id; rollback updates only that record in its original tool message, +including records produced by a delegate and persisted in the parent session. +Turn summaries assign delegate evidence through each raw message's original +`parentToolCallId`; visual resume-chain merging does not transfer edits between +turns, and delayed delegates are never inferred from time or adjacency. Forking +marks inherited records non-reversible. Existing transcript JSON supports the +additive details without a database migration or protocol version change. Old +shell messages without evidence remain explicitly unavailable; later builds must +not invent historical before content from the current workspace. + +## Consequences + +- Scratch-to-workspace copy produces durable file records and a turn summary. +- Historical cards survive restart and Git commits independently of Git state. +- Same-workspace mutations trade parallel throughput for unambiguous host-owned + capture intervals. Reads and operations on different workspaces stay independent. +- Large or ignored workspaces can yield partial evidence; coverage is explicit. +- The visual turn summary groups parent tools plus records owned by attached + `Task` delegates and reports cumulative active edit counts, not a net + durable-host-turn diff. Session Review includes the same original message and + snapshot identities so file selection and rollback remain available. +- Bash-only records beneath an exact `.gradle` directory segment are omitted by + frontend consumers as incidental build-cache evidence, including old session + logs. A file named `.gradle`, explicit Write/Edit records, and unrelated binary + files remain visible. +- Review remains host-owned. The renderer never scans files or writes rollback + content directly. + +## Validation + +Cover added/modified/deleted files, pre-existing unchanged dirty files, +scratch-to-workspace copy, no-op commands, nonzero exits after writing, +interruption, bounds, ignored and sensitive files, link replacement, independent +rollback and conflicts. Verify same-workspace serialization across sessions, +legacy and array transcript state updates, fork compatibility and actual mounted +chat rendering with failed Write, scratch Write, Bash multi-file evidence and a +final answer. diff --git a/docs/adr/turn-process-and-thinking-display.md b/docs/adr/turn-process-and-thinking-display.md index 8b9c10b627..7d03049c46 100644 --- a/docs/adr/turn-process-and-thinking-display.md +++ b/docs/adr/turn-process-and-thinking-display.md @@ -2,8 +2,8 @@ - Status: Accepted - Date: 2026-09-17 -- Amended: 2026-09-20 -- Issues: #510, #461 +- Amended: 2026-09-21 +- Issues: #510, #461, #639 - Amends: D071, [ADR 0242](0242-delta-only-streaming-updates.md) ## Context @@ -16,78 +16,90 @@ readers also need a thinking indicator without rapidly changing reasoning text. ## Decision -Both display modes project each loaded assistant-turn entry into one turn-process -disclosure plus its trailing answer. Thinking, tools and intermediate assistant -text keep their original order inside the process. A trailing assistant text -stays visible while streaming; if later tool or thinking activity follows, that -text moves into the process without changing the stored message. There is no -semantic final-answer marker in `UiMessage`, so the renderer does not guess -intent from wording. User/system messages and compaction dividers retain their -existing turn boundaries. +Both display modes project each loaded assistant-turn entry into one +turn-process disclosure plus its trailing answer. Thinking, tools and +intermediate assistant text keep their original order inside the process. A +trailing assistant text stays visible while streaming; if later tool or +thinking activity follows, that text moves into the process without changing +the stored message. There is no semantic final-answer marker in `UiMessage`, +so the renderer does not guess intent from wording. User/system messages and +compaction dividers retain their existing turn boundaries. + +Accepted human supplements carry the existing durable `steering: true` marker. +When the loaded task context exists, these user messages are ordered parts of +the same process rather than new task boundaries. Earlier work stays active +while the supplement is delivered; completion folds the work before and after +it together with the supplemental bubble. The initial user timestamp continues +to anchor elapsed time. Supplemental user text is never assistant answer text, +model usage or a second task summary. Search can reveal the contained bubble. +Ordinary queued prompts, including the queue's **Send now** action, and legacy +messages without the marker still start new tasks. Send now promotes a queued +prompt and gracefully stops the active turn; it does not use active-turn +steering. No durable parent-task link is stored for the promoted prompt, so the +renderer must not infer a continuation from priority, adjacency, aborted +status, timestamps, or message content. Active-turn supplemental input uses the +existing Alt+Enter steering path. The process has three independent disclosure levels: the whole turn process, an ordinary activity group, and one item's details. An ordinary activity group -contains one contiguous tool/search/thinking segment between progress paragraphs -and appears only when the mode has at least two visible items. A singleton uses -its item disclosure directly; hidden compact-mode thinking does not create a -redundant wrapper. Existing Task topology remains its segment's container and is -not duplicated inside an ordinary activity group. - -Detailed mode starts active and completed whole-process disclosures open. The -ordinary group that owns the active execution segment starts open, then closes -on completion only while untouched. Other completed ordinary groups start -closed. Compact mode starts process and ordinary-group disclosures closed, but -an untouched active process containing any recorded failed or denied tool stays -open through later recovery and closes on turn completion if still untouched. -Compact mode keeps every tool/search payload closed and renders no reasoning -text or excerpt; it shows only the active thinking indicator and omits empty -completed thinking-only containers. +contains one contiguous tool/search/thinking segment between progress +paragraphs and appears only when the mode has at least two visible items. A +singleton uses its item disclosure directly; hidden compact-mode thinking does +not create a redundant wrapper. Existing Task topology remains its segment's +container and is not duplicated inside an ordinary activity group. -Detailed mode preserves the leaf default only for the literal final item of the -last activity group. If that item is an eligible tool-call or hosted-search row, -its payload starts open; failed and denied items remain guarded closed. The -renderer does not scan backward past a final thinking item to open an earlier -tool. Opening a closed ancestor exposes the retained leaf state without opening -all descendants. +Both modes start an active whole-process disclosure expanded and collapse it on +completion. The active-to-completed transition uses a new process identity so +active nested interaction cannot keep completed history expanded. The header +shows an icon-only failure/issue marker whenever the process has issues, +including while folded. Reopening after completion +survives subsequent updates; search navigation opens the containing process, +then the activity group that owns the named message. Item-level targeting is +not part of this change. -Each header toggles only its own level. Parent and child states are independent: -closing a parent preserves descendant choices, reopening restores them, and -sibling groups do not form an accordion. Manual interaction with an item claims -the containing group and process as user-owned without toggling either ancestor; -completion must not close a container around content the user opened, focused, -or selected. Manual choices survive streaming, completion, mode changes, -reparenting from singleton to group, and row remounts while the owning retained -session pane remains alive. Pane eviction, session deletion, or renderer restart -releases this presentation memory; it is not stored in messages or host settings. +Detailed mode retains reasoning in the process. The ordinary group that owns +the active execution segment starts open, then closes on completion only while +untouched. Other completed ordinary groups start closed. Leaf auto-open applies +only to the literal final item of the last activity group when it is an +eligible tool-call or hosted-search row; failed and denied items remain guarded +closed. The renderer does not scan backward past a final thinking item. Compact +keeps its indicator-only thinking policy, starts ordinary groups closed, and +keeps every tool/search payload closed. -Search/navigation reveals the ancestor path its target needs: the process, then -the activity group that owns the named message. Item-level targeting is not part -of this change, so each row's own details stay behind its own disclosure. -Replaying the same request does not repeatedly override a later manual close. -Compact-mode reasoning remains hidden until the user chooses Detailed. Assistant -errors, stopped trailing partial answers, permission/question/plan/goal -decisions, and any other pending action surface remain outside hidden process -content and reachable without expanding it. +Each nested header toggles only its own level. Parent and child states are +independent: closing a parent preserves descendant choices, reopening restores +them, and sibling groups do not form an accordion. Manual interaction with an +item claims the containing group without toggling it. Nested choices survive +streaming, mode changes, reparenting from singleton to group, and row remounts +while the owning retained session pane remains alive. Pane eviction, session +deletion, or renderer restart releases this presentation memory; it is not +stored in messages or host settings. Settings → AI → Defaults retains `thinkingDisplayMode`, the optional `detailed | compact` `AppSettings` field. Absent or unrecognized values resolve to detailed. The field changes presentation only; it does not alter provider thinking levels, runtime/model context, stored reasoning, export, permissions, -execution, or copy payloads. Process headers use recorded timing, direct -tool/search counts, running state, and issue counts; they do not double-count -delegated child work or treat a failed child as a failed assistant turn. +execution, or copy payloads. Process headers use recorded timing, tool counts, +running state, and issue counts; they do not double-count delegated child work +or treat a failed child as a failed assistant turn. Process durations start at +the initiating user timestamp when loaded, fall back to the first valid part +timestamp, and end at recorded message/tool completion. A live UI clock adds no +persisted fields. User bubbles and completed assistant turns display a localized +semantic time from existing timestamps in the hover action chrome. ## Consequences - Both modes expose one whole-process disclosure while keeping the final answer - and actionable interruptions reachable outside it. -- Detailed mode keeps progress narration visible by default, folds untouched + and actionable interruptions reachable outside it. Completed processes start + collapsed even in Detailed. +- Detailed mode keeps nested progress narration recoverable, folds untouched completed activity groups, and preserves the literal-final-item leaf default. -- Compact mode remains the low-detail option: the process is folded, payloads - stay closed, and reasoning content is suppressed. +- Compact mode remains the low-detail option: the process is folded after + completion, payloads stay closed, and reasoning content is suppressed. - Disclosure memory is pane-owned presentation state with stable turn, group, - and item identities; it is neither a persisted transcript contract nor a - central workflow-store concern. + and item identities; whole-process completion uses a distinct identity so + claimed active opens do not persist. It is neither a persisted transcript + contract nor a central workflow-store concern. - This groups loaded transcript entries only; it does not reconstruct unloaded history or join turns across compaction boundaries. diff --git a/docs/spec/03-runtime/03-tools-and-permissions.md b/docs/spec/03-runtime/03-tools-and-permissions.md index 924aa21cfb..fae939e73a 100644 --- a/docs/spec/03-runtime/03-tools-and-permissions.md +++ b/docs/spec/03-runtime/03-tools-and-permissions.md @@ -239,9 +239,9 @@ as binary content. ## 4c. Message-owned review snapshots and rollback -`Write` and `Edit` are the structured review boundary. For a successful -workspace-root mutation, host-core captures the previous file before execution -and adds bounded review evidence to the tool result: +`Write` and `Edit` capture the previous workspace file before execution and +attach a singular `review` to successful results. Bash additionally captures a +bounded workspace interval and attaches multiple records in `reviews`: ```ts type ReviewChange = { @@ -277,6 +277,20 @@ type ReviewChange = { The hash guard uses the full digest, not the 16-bit `tag`. - Review snapshot files live outside the workspace and are removed with their session; orphaned session directories are swept on host startup. +- Admitted Bash execution compares bounded pre/post workspace snapshots using + the existing ignore rules, excluding scratch/host data, links, and root or + nested `.gradle` cache directories before traversal consumes scan budgets. + This extra exclusion is limited to automatic Bash review capture; explicit + Read/Write/Edit and search scopes are unchanged. Its details + contain `root: "workspace"`, `reviews: ReviewChange[]`, and `reviewCapture` + with status `complete`, `partial`, or `unavailable`. Unknown/unvisited files + are not assumed absent. No-op complete captures have an empty array. +- Bash records survive nonzero exit and interruption if files were changed. + Denied commands are not scanned. Each file has an independent snapshot id; + rollback updates only that record, and forks mark every inherited record + non-reversible. Legacy singular records remain supported. Capture bounds and + external-writer limitations are described in + [Shell workspace review evidence](../../adr/shell-workspace-review-evidence.md). ## 4d. Mutation ordering and edit recovery @@ -286,6 +300,20 @@ concurrently, but a session never has two in-flight mutations. The host holds the per-session mutation permit before consuming a global mutation slot, so a queued mutation cannot reserve capacity while it waits for an earlier edit. +Host-owned Write/Edit/Bash execution also takes a canonical workspace mutation +guard across sessions. It spans review preparation, execution and finalization +to prevent another host mutation contaminating a shell capture. Acquisition is +cancellable; different workspace roots remain independent. External programs +are outside this lock, so rollback still requires the post-content hash. + +Tool admission has one 30-second deadline shared by workspace, total, class, +and session waits. Workspace waiters count toward the same 64-call bounded +queue as permit waiters; a full queue rejects additional waiting calls with +`HOST_OVERLOADED`, while immediately available calls still run. Waiting for a +workspace never reserves total or class permits. Timeout or dropping an +admission future releases its queue slot, partial permits, and workspace guard. +Standalone workspace acquisition for rollback retains its 30-second bound. + `Edit` names positions and supplies new content only; it never matches existing text. Every call carries the whole-file `tag` minted by whichever tool last displayed the content, and the host rejects a call whose tag does not hash the diff --git a/docs/spec/03-runtime/04-data-storage.md b/docs/spec/03-runtime/04-data-storage.md index 85caae73f3..5ece47fb9b 100644 --- a/docs/spec/03-runtime/04-data-storage.md +++ b/docs/spec/03-runtime/04-data-storage.md @@ -110,6 +110,15 @@ snapshot directory with `session.delete` and sweeps directories whose session no longer exists at startup. A snapshot is never inferred from Git, so a later commit does not remove historical review evidence. +Captured Bash mutations use an additive `details.reviews` array and explicit +`details.reviewCapture` coverage status. Every record owns an independent +snapshot directory even when several files belong to one tool message. Failed +or interrupted shell execution can retain evidence of completed mutations. +Rollback rewrites only the matching snapshot id in the canonical transcript; +forking marks all inherited array records non-reversible, as for legacy singular +records. These details require no database or transcript schema migration. +See [Shell workspace review evidence](../../adr/shell-workspace-review-evidence.md). + ### 2.1 Transcript files (D119) `sessions/.jsonl` — first line is a session header, then one line @@ -1244,6 +1253,15 @@ line to the bounded page. This is derived read-only context, not a new persisted relationship or index. The renderer's unified reading view is shared by ordinary history and search; it never becomes canonical mutation or model input. +Display-limited tool results preserve bounded workspace review metadata +independently of output text and diff bodies. `details.root`, capture status, +and valid `review`/`reviews` identities, paths, counts, and rollback state must +survive large output strings or hunks. Shortened hunks remain structurally +valid and carry `truncated: true`. This applies to reopening sessions, session +switches, and older-page reads; it never mutates the canonical transcript or +snapshot files. Malformed and oversized evidence remains subject to explicit +metadata limits. + A bounded window is served through a per-session **transcript layout**: the byte offset of every message and compaction line, plus the file length those offsets were recorded against. Serving a window then seeks to its first selected line diff --git a/docs/spec/04-ux/06-settings-ia.md b/docs/spec/04-ux/06-settings-ia.md index 3da69ee945..0806b28205 100644 --- a/docs/spec/04-ux/06-settings-ia.md +++ b/docs/spec/04-ux/06-settings-ia.md @@ -238,16 +238,18 @@ Settings is a **full-window page** that replaces the app sidebar + main chrome ( to Off, and has no follow-the-session entry. Settings search indexes the card, its switch, the template row, the default-model row, and the reasoning row. - **Thinking display mode** uses a menu select with Detailed (default) and - Compact. Both modes use one whole-process disclosure. Detailed starts the - process open, keeps reasoning visible, opens the active multi-item activity - group, and closes an untouched group when it completes; Compact starts the - process and groups closed, keeps tool/search payloads closed, shows only an - active thinking indicator, and hides finished reasoning. Singleton activity - uses its item disclosure directly in either mode. The global preference - persists as `thinkingDisplayMode` in host-owned settings; missing values use - Detailed. It affects presentation only, not model reasoning configuration, - and explicit disclosure choices are retained for the mounted session pane. - Settings search indexes the row and both mode names. + Compact. Both modes use one whole-process disclosure that starts expanded + while running and resets to collapsed on completion, including after nested + interaction or earlier tool failures. Detailed keeps reasoning and nested + tool groups available inside the disclosure, opens the active multi-item + activity group, and closes an untouched group when it completes. Compact + starts nested groups closed, keeps tool/search payloads closed, shows only + an active thinking indicator, and hides finished reasoning. Singleton + activity uses its item disclosure directly in either mode. The global + preference persists as `thinkingDisplayMode` in host-owned settings; missing + values use Detailed. It affects presentation only, not model reasoning + configuration, and nested disclosure choices are retained for the mounted + session pane. Settings search indexes the row and both mode names. - The **Command shell** row in Defaults uses the host-discovered catalog of native PowerShell 5.1, PowerShell 7, cmd, Git Bash, and Bash with IDs `windows-powershell`, `windows-pwsh`, `cmd`, `git-bash`, and diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index 9c54367975..7903385ce3 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -817,12 +817,25 @@ reading surface of the workstation. ### Turn process and thinking display -Both Detailed and Compact project each loaded assistant turn into one whole-process +Both display modes project each loaded assistant turn into one whole-process disclosure containing reasoning, tools, hosted searches and intermediate assistant text in transcript order. The trailing answer streams outside that disclosure; later activity can reclassify provisional answer text into the process without changing the stored message. Assistant errors and stopped trailing partial answers also stay outside it. User/system messages and compaction boundaries are unchanged. +A human user message carrying the persisted `steering: true` marker belongs to the +loaded task context and stays in order inside its process. It does not settle +preceding work or create another task summary. Completion folds the supplemental +bubble and work on both sides together; the final answer remains outside. Unmarked +messages, including queued new tasks and legacy rows, remain new boundaries. +The queue's **Send now** action gracefully stops the active turn and starts a +new turn; it does not set `steering` or persist a parent-task link. It therefore +remains separate. Alt+Enter is the active-turn supplemental-input path. +Leading supplemental rows with no loaded task context remain visible rather than +being attached to unrelated history. Search can reveal folded supplements, and +their text/attachments are excluded from assistant answer and usage aggregation. +Grouped supplements share the task's visible minimap anchor instead of adding +markers for hidden rows. Standalone leading supplements retain their own marker. Within the process, an ordinary activity group represents one contiguous tool/search/thinking segment between progress paragraphs. It renders a group header @@ -830,12 +843,21 @@ only when the current mode has two or more visible items. A singleton uses its i disclosure directly, compact-hidden thinking never creates an empty wrapper, and the existing Task topology remains the container for delegated work. -Detailed starts active and completed whole-process disclosures open. The ordinary -group owning the active execution segment starts open, then closes on completion -only if untouched; other completed groups start closed. Compact starts the process -and ordinary groups closed. Its untouched active process remains open when any -failed or denied tool has been recorded, through later successful recovery, and -closes on completion if still untouched. Group headers summarize count, running +The whole process starts expanded while active. Completion resets it to collapsed, +including after active interaction or failed/denied tool calls, by using a new +process identity. The header shows an icon-only failure/issue marker whenever +the process has issues, including while folded, with the issue count in the +accessible name rather than visible text; completed manual reopening and new +search reveals remain effective. The header shows elapsed time and a tool count. +The header stays content-sized in both states; expanded details use the available +conversation width. +Elapsed time starts at the initiating user message when loaded, otherwise at +the first valid assistant/tool timestamp, and ends at the latest recorded +message/tool completion. + +The ordinary group owning the active execution segment starts open in Detailed, +then closes on completion only if untouched; other completed groups start closed. +Compact starts ordinary groups closed. Group headers summarize count, running state and issue count without treating a failed child as a failed turn. In Detailed, only the literal final item of the last activity group receives the @@ -844,21 +866,91 @@ Failed and denied rows remain closed, and a final thinking item does not cause a backward scan for an earlier tool. Compact keeps all tool/search payloads closed and suppresses reasoning text and excerpts; only its active thinking indicator remains. -Whole process, group and item are independent controls. Closing an ancestor keeps -descendant choices and reopening restores them; opening a parent never expands all -children. User interaction with a child claims its ancestors without toggling them, -so completion cannot close around opened, focused or selected content. Choices use -stable turn/group/item identities and remain while the retained session pane lives, -including mode changes and row remounts; pane eviction, deletion or renderer restart -reapplies defaults rather than persisting disclosure state to messages or settings. +Nested group and item controls are independent of the whole-process identity. +Closing an ancestor keeps descendant choices and reopening restores them; opening +a parent never expands all children. User interaction with a child claims its +group without toggling it. Whole-process completion still folds because it uses +a new identity. Nested choices remain while the retained session pane lives, +including mode changes and row remounts; pane eviction, deletion or renderer +restart reapplies defaults rather than persisting disclosure state to messages +or settings. Search/navigation reveals the process and the activity group that own the named message, and applies each reveal request once. Item-level targeting is not part -of this change. Compact reasoning stays hidden until -the user selects Detailed. Permission, question, plan/goal approval and other -pending action cards remain reachable outside a hidden process. See +of this change. Compact reasoning stays hidden until the user selects Detailed. +Permission, question, plan/goal approval and other pending action cards remain +reachable outside a hidden process. See [ADR turn-process-and-thinking-display](../../adr/turn-process-and-thinking-display.md). +### Task timestamp and recorded file summary + +User messages show a semantic `time` in the hover action chrome under the bubble, +formatted in the current UI locale and local timezone from the persisted +`createdAt`. Completed assistant turns show the same hover timestamp from the +recorded process end. Invalid timestamps are omitted. Optimistic messages use +their send timestamp until acknowledged. The timestamp stays in the existing +hover action row and is not shown at rest, including when Chromium keeps +`:hover` after the pointer leaves the window. + +An inactive assistant turn shows a file summary below the response when its +loaded parent or durably Task-owned delegate tool messages contain workspace +Write/Edit or captured Bash review snapshots. The localized heading reads "Edited N files this turn" +(Simplified Chinese: "本轮编辑 N 个文件"). Up to five files are shown in full. +When there are more than five files, the summary initially shows the first three +and offers a show-more control with the remaining count; collapsing restores +those three rows. Clicking a path opens the right work +panel's Review tab and displays that file's records from this visual turn with +their diffs expanded and existing hash-guarded rollback controls. The chat list +does not expand inline review cards. Selection identifies snapshots rather than +copying messages, so rollback updates remain live; another session or another +turn's edits to the same path cannot leak into the selected file view. +Navigation validates against the displayed reading range and its compaction +boundaries, not just the canonical live tail. Opening a valid file selection +replaces any foreground subagent detail with Review. Loaded history/search +records resolve their selected snapshots from that same range; confirmed +rollback updates both live and retained reading projections, without redirecting +the active session or applying a late response to a different pending selection. +A rollback-confirmed reading range clears any superseded loading state in the +same update. Pre-rollback page/search responses cannot overwrite that state or +strand subsequent history reads behind an obsolete loading guard. +Snapshot identities are deduplicated. Counts describe recorded edit operations, +not a net start-to-end Git diff; repeated edits retain their individual evidence. +Rolled-back records remain inspectable and are excluded from active edit totals. + +The summary preserves its rounded outer border and padded header/list regions, +using the transcript's existing tool/review colors, typography and hover states. +Its compact icon and count title sit in the header, with totals alongside, +separated from the file list by spacing and a subtle divider. Each row centers its path and +counts vertically within its hover target. At narrow widths, paths and the title +truncate while counts stay visible, without overlapping the file list. + +The source is persisted message-owned review evidence, never the current dirty +worktree or artifact index. Failed Write/Edit and scratch writes add no evidence; +Bash mutations captured before nonzero exit or interruption remain reviewable. +Binary/oversized records retain their existing review limits. Bash results use +`reviews` plus explicit complete/partial/unavailable capture metadata. Capture +metadata remains internal: the chat card has no scope/disclaimer subtitle or +empty unavailable card. With no file records it is absent. Delegate edits are +attributed through their original `parentToolCallId` to the spawning Task's +turn, including delayed results. Resuming a delegate may merge its visual +process card, but never moves earlier edit evidence to the resumed turn. +Summary and Review preserve the original message/snapshot identities and apply +the same admission rules. Bash records under `.gradle` cache directories are +excluded, including records loaded from older sessions; explicit Write/Edit +records and unrelated binary assets remain inspectable. +History pages can cut a visual turn. A nonempty file summary renders its frame, +loaded file paths, numeric file count, and cumulative edit totals immediately, +including accessible count labels and the remaining-file expansion count. +Those paths remain actionable in Review using their exact loaded snapshot IDs. +The process remains expandable with the duration, tool count, and issue count +calculated from the currently loaded records. Counts and duration may change as +earlier or later pages arrive; no completeness gate hides the current values. +This does not fetch unbounded history or claim that a loaded or compaction-split +segment is a complete durable host-turn audit. Live evidence and rollback can +also update statistics. +The additive tool details need no schema or protocol version change. See +[Shell workspace review evidence](../../adr/shell-workspace-review-evidence.md). + ### 4.4 States | State | Behavior | @@ -870,7 +962,7 @@ pending action cards remain reachable outside a hidden process. See | Session switch | A first-opened session paints at its latest record; a revisited pane paints at its own retained position. Bounded first commit and full-history expansion show the same position: no post-paint height correction may shift the visible rows, in either direction | | Turn start (send / retry / regenerate) | Re-pins and positions the latest content before paint, even if the user had scrolled up; the later persisted user-message event does not flash the transcript at its top, and the composer collapse / indicator layout clamps during the send never release follow mode | | Idle (after stream) | Auto-scroll unlocked; user can scroll freely | -| Message-scoped review snapshot | Each successful workspace Write/Edit tool row is followed by one compact InlineReviewCard carrying that message's added/modified/deleted status and explicit addition/deletion totals. It renders as a single flat list row on the tool-row rhythm — disclosure caret, Git-style status letter (`A`/`M`/`D`), path, addition/deletion counts — with no card border, status rail, icon plate, or status pill; hover fill is the only row chrome, and a rolled-back change is struck through. Its hunks sit behind an expandable disclosure: every review card (inline and in the Review tab) is collapsed by default, and the user expands it on demand. The card remains after a Git commit, never becomes a bottom/global entry, and offers hash-guarded rollback without leaking into another session's transcript. | +| Message-scoped review snapshot | Each successful workspace Write/Edit tool row and each captured Bash mutation owns a compact review card with added/modified/deleted status and addition/deletion totals. The flat row contains a disclosure caret, Git-style status letter, path and counts; a rolled-back record is struck through. Inline cards and the unfiltered Review history default to collapsed. Clicking a file in the turn summary opens its filtered right-side Review view with the matching diffs expanded; repeated navigation reopens collapsed target records. Historical evidence survives a Git commit, and per-snapshot rollback remains hash-guarded and session-owned. The chat summary lists paths without nesting duplicate diff cards or creating a global rollback action. | ### 4.5 Accessibility @@ -2005,11 +2097,29 @@ Renderer: `apps/desktop/src/components/Markdown.tsx` + `apps/desktop/src/lib/shi - **Links**: plain click previews in the work panel; modified click keeps `target="_blank"` so main routes through `shell.openExternal` after the http(s)/mailto allowlist (D330); in-window navigation stays blocked. -- **Long transcript behavior**: `.thread-scroll` sets `overflow-anchor: none` - (pinned-follow owns the scroll position), `.message-row` and - `.tool-activity-group` use `content-visibility: auto` with an intrinsic size so - far-offscreen rows skip layout and paint, and offscreen Mermaid diagrams defer - loading and layout until they approach the viewport. +- **Long transcript behavior**: `.thread-scroll` lays out mounted `.message-row` + and `.tool-activity-group` elements instead of skipping them at the 140px + estimate. That estimate collapses when a folded row reaches the viewport and + shifts session entry, jump-to-latest, and a long upward scroll. The bounded + window is what limits how many rows stay mounted. `.thread-scroll` keeps + native anchoring disabled. A committed history insertion preserves the + reading row while retaining any user scrolling during the pending request, + including a page that finishes while the pane is hidden. Entry, completed-session + revalidation, and jump-to-latest commit current content without showing an older + deferred snapshot; only streaming may defer. The loading boundary keeps identical + idle/loading geometry. Visible pinned panes position the committed projection in + layout, including the canonical live tail selected when leaving a reading range; + composer draft and reserve updates also complete before painting a revealed + pane. Offscreen Mermaid diagrams defer loading and layout until they approach + the viewport. +- **Underfilled partial history**: when a latest page folds to less than one + viewport while older history is still available, unused vertical space stays + above its content. The last row therefore sits at the existing composer + reserve before and during automatic older-page loading, not partway up the + screen despite a zero scroll offset. Once the transcript is exhausted, a + short conversation returns to the ordinary top-aligned layout. Search ranges + use normal reading layout. Overflowing histories have no added blank + scrollable region. - **Bounded first commit**: activating a session whose history exceeds the initial mount budget mounts only the newest entries in that commit, with a spacer holding the remaining scroll height, and expands to the steady-state @@ -2210,7 +2320,7 @@ twice. completion transitions never reverse those states. - A failed child row remains error-hued and reports its failure in the row header, but its payload is not auto-expanded. The containing group settles as - `Processed for {elapsed}` with an issue count even when a later tool recovered. + `Processed for {elapsed}` with an icon-only issue marker even when a later tool recovered. Expansion uses a short height/opacity transition and keeps collapsed content inert. - Running updates replace the latest partial output in place. Bash's cumulative `details.output` partial result is rendered through the stdout channel, while diff --git a/docs/spec/04-ux/09-interaction-patterns.md b/docs/spec/04-ux/09-interaction-patterns.md index 9f651d6e4a..efd09b453b 100644 --- a/docs/spec/04-ux/09-interaction-patterns.md +++ b/docs/spec/04-ux/09-interaction-patterns.md @@ -822,27 +822,35 @@ may be retained while exactly one workspace supplies the visible shell context. - Both modes give each loaded assistant turn one whole-process disclosure. It contains thinking, tools, hosted searches and intermediate progress text; the trailing answer, assistant errors and stopped trailing text remain outside it. + A marked `steering: true` supplement stays inside the same process. Unmarked + queued **Send now** prompts remain new turns. - A contiguous activity segment receives a group disclosure only when it has at least two mode-visible items. Progress text ends the segment, a singleton uses its item disclosure directly, and compact-hidden thinking does not create a redundant group. Existing Task topology remains separate. -- Detailed starts active and completed whole processes open. Its active ordinary - group starts open and closes when it completes only if untouched; completed - groups otherwise start closed. Compact starts the process and groups closed, - except an untouched active process with any recorded failed/denied tool remains - open through recovery and closes on completion. -- In Detailed, leaf auto-open applies only when the literal final item of the last - activity group is an eligible tool-call or hosted-search row. Failed/denied - items stay closed, and a final thinking item never causes a backward scan. - Compact keeps every item payload closed and hides reasoning text/excerpts while - retaining its active thinking indicator. -- Activating a process, group or item header toggles only that level. Closing a +- Processes start expanded while active. Completion resets them to collapsed, + including after active nested clicks, keyboard interaction or failed/denied + calls, by using a new process identity. The header shows an icon-only + failure/issue marker whenever the process has issues, including while folded. + Reopening a completed process and new search reveals remain effective. The header updates + elapsed time once per second while active and shows the tool count. The loaded + initiating user's timestamp supplies the start when available; user bubbles + and completed assistant turns show that wall-clock time in the hover action + chrome, in the current locale. The chrome hides at rest and when the pointer + leaves the window. +- Detailed starts the active ordinary group open and closes it when it completes + only if untouched; completed groups otherwise start closed. Compact starts + nested groups closed. In Detailed, leaf auto-open applies only when the + literal final item of the last activity group is an eligible tool-call or + hosted-search row. Failed/denied items stay closed, and a final thinking item + never causes a backward scan. Compact keeps every item payload closed and + hides reasoning text/excerpts while retaining its active thinking indicator. +- Activating a nested group or item header toggles only that level. Closing a parent preserves child state, reopening restores it, and sibling groups remain independent. Opening a parent is never an expand-all action. -- A manual item action claims its group and process as user-owned without toggling - them. Streaming and completion cannot reopen a manual close or close around - content the user opened, focused or selected. Choices survive mode changes, - singleton-to-group growth and remounts while the retained session pane lives. +- A manual item action claims its group as user-owned without toggling it. + Nested choices survive mode changes, singleton-to-group growth and remounts + while the retained session pane lives. Whole-process completion still folds. - Search/navigation opens the process and activity group that own the named message, once per reveal request. Item-level targeting is not part of this change. Compact reasoning requires an explicit switch to Detailed. Closing @@ -1505,10 +1513,57 @@ This does not prevent state changes — it makes them instant. ### 10.4 Programmatic scrolling -- Session activation uses an immediate layout-phase bottom position so the - first visible frame is already stable at the latest record. -- Jump-to-latest and minimap navigation use smooth scrolling only when the OS - has not requested reduced motion. +- Session activation measures folded row height in the same layout phase as + its immediate bottom pin, so the first frame is already at the real bottom. + It does not defer that pin to a later frame or traverse the history. + Once a first/reveal commit selects the current snapshot, urgent updates keep + that projection until deferred rendering catches up; an older deferred snapshot + must not temporarily replace it and clamp the restored scroll position. + Completed-session hydration and revalidation select the current snapshot too; + only streaming work may use a deferred projection. A visible pinned pane is + positioned in the layout commit of its rendered messages, not against raw + messages whose DOM has not mounted yet. + A partial latest page can fold below one viewport even when many physical + records were loaded. Its unused space belongs above the content, keeping the + last row at the composer reserve from the first commit and while older pages + load. `scrollTop === max === 0` alone does not prove visual bottom alignment. + Once a retained pane has a partial live history, keep this alignment after its + final page too; explicit search windows and initially complete short chats + retain their ordinary layout. Overflowing history has no permanent spacer. +- The main transcript jump-to-latest commits the canonical live projection and + positions its folded thinking/process height before paint, including return + from a search window or ordinary paged history. It cannot consume navigation + against the obsolete reading-window DOM, even when that old window is already + at its own bottom. Same-message jumps still receive a layout commit. Minimap + navigation alone may use smooth scrolling when reduced motion is not requested. +- Mounted transcript rows are laid out. Skipping them leaves the 140px + estimate, which collapses when a folded row reaches the viewport and shifts + entry, jump-to-latest, and a long upward scroll. The bounded window, not + that skip, limits how many rows stay mounted. +- `.thread-scroll` keeps native anchoring disabled: pinned follow, explicit + reading anchors, and history insertion must have one scroll-position owner. +- Upward input releases follow without writing a new scroll offset. The + history-loading boundary reserves the same translated text geometry while + idle and loading, including wrapped labels. +- A history insertion restores its reading anchor only when the corresponding + rendered window commits, not when raw message count changes ahead of deferred + rendering. The anchor compensates layout displacement, never wheel movement + made while the page was pending. No-op, failed, or superseded page requests + cannot leave an anchor for an unrelated later update. +- A manual disclosure temporarily owns reading geometry without cancelling a + pending page read. Its title restores before prepend correction; continued + user scrolling hands the achieved geometry back to the original page anchor. + Neither transition starts a replacement read or applies insertion twice. +- Retained panes save the latest laid-out user scroll position, ignore hidden + zero-offset events, and restore it before revealing the pane. Hiding suspends + an in-flight history anchor rather than cancelling the still-running page read. + A page committed while hidden applies its insertion correction once the pane + has layout again, after restoring the retained offset and before paint. +- Session draft replacement and composer-height publication finish in the + layout phase. A newly revealed short transcript must not reserve the previous + session's composer height for its first visible frame. + Settings-hidden retained chat keeps its last positive measured reserve; a + hidden zero-height measurement cannot overwrite it before chat is revealed. - Turn-start following uses an immediate layout-phase update and then a frame-coalesced instant follow; it does not start overlapping smooth-scroll animations for token groups. @@ -1521,11 +1576,11 @@ This does not prevent state changes — it makes them instant. layout changes (composer height, indicator rows) arrive after the fact and look like an upward gesture; because they have no preceding input they are ignored and follow mode is preserved. -- Resize observers never synchronously measure every transcript row from their - callback. The one synchronous action they may take is the bottom re-pin of a - pinned, visible transcript (a single `scrollTo`), because a frame requested - from inside the callback lands after the current frame has already painted - the grown content unpinned (D287). +- Resize observers do not repeatedly measure all mounted rows. A visible pinned + transcript may synchronously re-pin its bottom; a held disclosure may restore + its title and refresh the existing pending anchor's geometry. These corrections + run before paint: a frame requested inside the callback would paint the grown + content uncorrected first and snap it back in the following frame (D287). ## 11. Acceptance criteria diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 44249df78f..7e63c35dfa 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -5741,10 +5741,19 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. 5. Repeat with an `ops` payload whose ranges overlap. 6. If the task uses a dedicated worktree outside the advertised workspace, verify its guarded Bash edit and resulting `git diff`. + 7. Hold a workspace mutation guard, queue another mutation, then release the + workspace while class capacity remains occupied. Repeat with 64 mixed + workspace/permit waiters, attempt another waiting call, and drop waiters + during workspace and permit acquisition. - **Expected**: - Read/search calls may overlap, but only one `Write`/`Edit` executes for a session at a time; queued mutations do not consume another global mutation slot while waiting. + - Workspace and permit waits share one 30-second admission deadline and one + 64-call queue. Excess waiting calls return `HOST_OVERLOADED`; immediately + available calls still run. Workspace waiters do not reserve global tool + capacity. Dropped or timed-out admission futures release queue slots, + partial permits, and workspace guards; subsequent calls can run normally. - The stale-tag edit fails without changing the file and returns `EDIT_TAG_MISMATCH` carrying the live tag and current content at the anchors; the overlapping-range payload fails with `EDIT_RANGE_INVALID` @@ -10625,6 +10634,48 @@ This test plan spec is accepted when: history is loaded and mounted. 8. Confirm the continuation disappears once nothing earlier remains, and that a short completed conversation that fits one viewport still shows no rail. + 9. Use native wheel input for the first small upward movement in a barely + overflowing folded conversation. Hold the older-page read open, continue + scrolling, then complete it. Repeat with a no-op page and a failed page. + Loading status must not add height; insertion must preserve the movement + made while waiting rather than restore the request-time screen position. + 10. Switch away after manual scrolling and reveal the retained pane. Switch + between short sessions with empty and multiline composer drafts; measure + the first committed composer reserve and the visible answer across frames. + The reserve must match the destination composer, and the reading offset + must be the latest user position rather than the last programmatic pin. + 11. Start an older-page read, keep scrolling, then hide and reveal the same + retained pane while the read is pending. Complete it after reveal, then + repeat with completion while hidden. The original read must retain its + anchor without duplicate reads; inspect the first reveal commit and every + subsequent frame for reading-row displacement. + 12. Load a tail beginning partway through a turn, then prepend its missing user + and earlier process content. The answer keeps its viewport position even + when page settlement triggers an urgent render before deferred rendering + catches up. Do not transiently restore the shorter, old projection. + 13. Expand a process while its older-page read is pending. Complete the page + once with the title still held and once after further native scrolling. + Preserve the title/reading row before paint, with one original read and + no second insertion correction after the ownership handoff. + 14. Hydrate an initially empty pane and revalidate a short cached completed + conversation with a longer snapshot. Inspect the first synchronous commit, + not only settled frames: the selected newest message must be present and + bottom-aligned immediately. + 15. Navigate to an older search range using the reading runtime, then click the + actual return-to-latest control. Repeat with shorter and longer canonical + live tails, idle and running sessions, and ordinary wheel-triggered paging. + Assert message identity and bottom alignment on the first commit and every + following frame; the bottom of an obsolete range is not a valid latest view. + 16. Open a partial tail whose folded tool-heavy turns underfill a tall + 858-by-1166 conversation pane. Hold older-page reads pending, then resolve + a still-underfilled page and an overflowing page. Measure the last row's + bottom relative to the real composer dock's top from the first commit: + an idle transcript without trailing cards keeps the existing 16px gap + (within pixel rounding), not a large blank area below the answer. + Repeat with another page still available, on retained hide/reveal, and + across composer draft heights. No hidden or duplicate reads may start. + A complete short conversation remains top-aligned. This test must fail + even when a misplaced underfilled pane reports scrollTop == max == 0. - **Expected**: Mounted transcript rows stay bounded by the window rather than growing with how far back the user scrolled, so the recorded row count and heap usage after paging far back stay close to the values recorded before it. @@ -10643,8 +10694,16 @@ This test plan spec is accepted when: - **Acceptance**: C (conversation), H (diagnostics), Quality - **Milestone**: M6+ - **Status**: Unit-covered (`transcript-window.test.mjs`, - `conversation-minimap.test.mjs`, `interaction-performance.test.mjs`); rendered - desktop journey and the low-memory Windows measurement pending + `conversation-minimap.test.mjs`, `interaction-performance.test.mjs`). Isolated + renderer coverage: `node scripts/e2e-transcript-scroll.mjs` uses the built app + CSS, native Chromium wheel input, gated page responses, retained `SessionPane` + components, and the real docked `Composer`. It does not launch the production + app, call a provider, or access user sessions. The installed desktop journey + and low-memory Windows measurement remain manual acceptance. + The underfilled-entry fixture also runs with `PI_SCROLL_PROBE_SCENARIO=underfilled`, + `PI_SCROLL_PROBE_WIDTH=858`, and `PI_SCROLL_PROBE_HEIGHT=1166` at DPR 1 and 1.25. + It verifies actual browser viewport dimensions and the first-commit composer + gap; the full native-wheel suite retains its default viewport. #### E2E-160: Dragging the window across displays keeps the dropped position @@ -14384,33 +14443,145 @@ plugin-form fixtures in an isolated temporary directory at runtime. - **Preconditions:** A turn with progress paragraph A, multiple searches plus thinking, progress paragraph B, multiple commands plus thinking, and a final answer; Detailed and Compact display modes; legacy message-level transcript - search targets. -- **Steps:** Review the nested disclosure path in Detailed, including independent - group/item toggles, parent close/reopen, a singleton segment, literal-final-item - leaf selection, failure/denial/recovery, retained-pane remounts and a legacy - search reveal. Repeat in Compact and with permission/question/plan/goal action - cards, a stopped partial answer, an assistant error and delegated child work. + search targets; a marked steering supplement and an unmarked queued Send now + prompt. +- **Steps:** Stream the turn; finish it; expand/collapse its process; review the + nested disclosure path in Detailed, including independent group/item toggles, + parent close/reopen, a singleton segment, literal-final-item leaf selection, + failure/denial/recovery, retained-pane remounts and a legacy search reveal. + Repeat in Compact and with permission/question/plan/goal action cards, a + stopped partial answer, an assistant error and delegated child work. During an + active task, deliver one or multiple marked steering messages, then continue + tools and finish. Repeat before the first assistant output, after history + reload, with an attachment, and with a separate unmarked queued task. - **Expected:** Both modes use one whole-process disclosure and leave the final answer, assistant errors, stopped trailing text and pending actions outside it. - Detailed starts active/completed processes open; the active multi-item group is - open and an untouched group closes on completion. Compact starts processes and - groups closed, hides reasoning, and keeps payloads closed; an untouched active - process with a recorded failed/denied tool stays open through recovery and closes - on completion. Singletons have no group. Detailed auto-opens only an eligible - literal final tool/search item of the last activity group; it does not scan past - thinking, and failed/denied leaves stay closed. Parent/child/sibling states remain - independent, pane-owned user choices survive updates, mode changes and remounts, - and renderer restart reapplies defaults. Search reveals the process and activity - group that own the named message once per request; item-level targeting is not - part of this change, and Compact reasoning requires an - explicit switch to Detailed. Saved mode survives restart and a missing/unknown - setting resolves to Detailed. -- **Validation scope for the 2026-09-20 change:** Nested disclosure and activity - group presentation only; precise item-level transcript search targeting is out - of scope and keeps the existing message-level search behavior. + Both modes expand the active process and reset it to collapsed at completion, + including after active nested interaction or failed/denied calls. Completed + manual reopening survives updates; search reveals the process and activity + group that own the named message once per request. The header's failure/issue + marker remains icon-only while folded or open, with its count in the accessible + name. The header stays content-sized in both states while details use the band. + Detailed auto-opens only an eligible + literal final tool/search item of the last activity group; it does not scan + past thinking, and failed/denied leaves stay closed. The active multi-item + group is open and an untouched group closes on completion. Compact starts + nested groups closed, hides reasoning, and keeps payloads closed. Singletons + have no group. Parent/child/sibling nested states remain independent, and + pane-owned nested choices survive updates, mode changes and remounts. + Renderer restart reapplies defaults. Item-level targeting is not part of this + change, and Compact reasoning requires an explicit switch to Detailed. Saved + mode survives restart and a missing/unknown setting resolves to Detailed. + User and completed assistant messages display a localized time in the hover + action chrome; process elapsed time includes the loaded initiating user's + timestamp and falls back safely when the user row or a valid timestamp is + unavailable. Marked supplements remain ordered inside one active process and + do not fold preceding work on arrival. Task completion folds the supplemental + bubbles and both work segments together. + Completion preserves the mounted process children while resetting only the + parent disclosure choice. Reopening retains the nested user choices. + Reopening/search reveals their text and files; final response and file summary + stay singular. Unmarked new tasks, including queued **Send now** prompts, and + missing legacy markers do not merge; unloaded task history is not guessed. +- **Automation:** `test:e2e:transcript` covers the mounted renderer interactions, + settings control and unchanged-group performance. `test:e2e:transcript-disclosure` + covers scroll anchoring; `test:e2e:theme-surfaces` covers the shared theme + controls. Isolated Host `settings.set/get` checks verify both modes across + process restart and preservation during unrelated partial settings writes. + Renderer fixtures alone do not prove settings persistence. +- **Validation scope for the 2026-09-20 nested-disclosure change:** Nested + disclosure and activity group presentation only; precise item-level transcript + search targeting is out of scope and keeps the existing message-level search + behavior. - **Specs:** 04-ux/06-settings-ia, 04-ux/08-component-spec, 04-ux/09-interaction-patterns; ADR turn-process-and-thinking-display. +### E2E-CHAT-task-recorded-files + +- **Preconditions:** A loaded assistant turn with successful Write/Edit review + snapshots, repeated edits to a path, and a final reply. Exercise 3, 4, 5, 6, + and 21 unique paths. Verify the summary at normal and 320px width: a rounded + outer border encloses the padded header and separated file list; file names + remain vertically centered in their hover rows without horizontal overflow. +- **Steps:** Complete the task, inspect its count heading and file list, reveal + more paths and click one file. Verify the right Review panel opens its expanded + diffs, then request guarded rollback there. Repeat + with a conflict, rolled-back evidence, failed/scratch tools, a shell-only turn, + binary/oversized evidence and another user round. Re-render saved messages and + restart the host after a native Bash output and Write/Edit diff each exceed + 64KiB. Read with `contentLimit: 64 * 1024`, as the desktop does, and compare + the real frontend turn-file projection with its pre-restart result. Verify + that full uncapped evidence is unchanged and capped rollback states update. + Include a Task whose delegate performs native Write/Edit, followed by parent + Bash writes to source and `.gradle` caches. Resume the delegate in a later + user turn and deliver a late record for the original Task. + Switch between sessions and inspect a bounded transcript window. Repeat file + clicks on either side of a compaction checkpoint, in ordinary loaded history + and a search reading range, with a subagent detail currently foregrounded. + Include a 15-file summary, a +0/-0 record, conflict and successful rollback, + and a delayed rollback response after switching sessions. Also hold an older + page or search read pending during successful rollback, release its stale + response, then retry: loading must clear, rolled-back evidence must remain, + and the newer read must complete without being overwritten by the old one. + Open a history or search range that cuts a completed turn. On the first + synchronous commit, assert that the file frame, known paths, numeric file + count, cumulative line totals, and accessible labels reflect the loaded records. + Files can open their exact persisted Review records without another page. + Process content stays expandable with its current duration and tool count. + As more pages arrive, counts and duration may update to include the new records; + remounting or revisiting must show the currently loaded values immediately. +- **Expected:** The final answer and recorded-file summary remain outside the + process disclosure. The summary deduplicates snapshots and counts paths; + Up to five files are fully visible with no expansion control. Above five, + exactly three are initially visible; show-more reports the remaining count, + expansion shows all files, and collapse restores the first three. + edit totals are cumulative operation totals, not a net Git diff. Rolled-back + records remain inspectable and do not contribute to active totals. Existing + review controls retain conflict protection and disabled/unavailable states. + Other rounds, scratch writes and unsuccessful Write/Edit add no records. + Captured Bash changes are included even after a nonzero exit; complete no-op + shell activity has no empty file card. The chat has no scope/disclaimer subtitle; + zero-record turns show no summary. Historical snapshots stay independent of + workspace/Git state. Clicking another file changes the sidebar selection; + repeated clicks reopen its details. Selection stays within the originating + session and turn, including when another turn edited the same path. The chat + itself never expands nested diff cards from the file summary. + Store admission and Review resolution use the same visual turn boundaries and + loaded range as the summary; valid historical clicks open Review instead of + silently failing or leaving a subagent detail on top. Confirmed rollback updates + the historical record and summary without replacing the canonical live tail. + Pending session changes reject navigation; stale rollback results cannot update + a different session or redirect the work panel. + Delegate source edits remain in their original Task turn, even when resumed + process cards combine history. Review opens their original snapshots and + persists guarded rollback after restart. Bash `.gradle` records are excluded + from both new captures and old display records; explicit Edit/Write and + unrelated binary assets remain. Orphan delegate messages do not acquire a + turn through adjacency, and changed evidence invalidates memoized summaries. +- **Automation:** `apps/desktop/test/turn-file-summary.test.mjs` covers projection; + the mounted transcript fixture covers expansion, rollback state, and the + failed-Write/scratch-Write/Bash-copy/final-answer user path. `test:e2e:shell-review` + runs an isolated native host and real shell to copy three scratch files, + verify pre-existing unchanged files are excluded, capture a write followed by + nonzero exit, reload persisted multi-file evidence, roll back a single snapshot + without changing siblings, and reject a conflict after a later external edit. + Rust tests cover bounds, links, ignore rules and same-workspace serialization. + The native scenario also executes delegated Write/Edit, captures a real source + write alongside root and nested `.gradle` mutations, reloads parent links, + and verifies delegated rollback persistence and conflict protection. A Rust + fixture fills `.gradle` beyond the scan's file budget and verifies that the + source mutation remains completely captured and independently reversible. + Performance regressions also cover unchanged post-scan file bodies not being + retained and metadata-only file summaries not reading diff hunks. + `scripts/e2e/turn-file-review-navigation.tsx` mounts the production transcript, + store action, work panel, and Review tab together for checkpoint/history/search + selection and rollback; only external snapshot/rollback RPCs are mocked. + The navigation fixture also covers first-commit numeric file totals, duration, + and tool counts in partial history and search ranges. Existing mounted process + tests continue to cover disclosure, search, and active progress behavior. +- **Specs:** 04-ux/08-component-spec; ADR 0043. +- **Status:** Task-candidate validation; no live provider or user data required. + ### E2E-RPC-unicode-separators - **Preconditions:** Built host-core, shared package and agent runtime; isolated diff --git a/package.json b/package.json index 8f984e363c..be8ba24c4b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:e2e:composer-autocomplete": "node scripts/e2e-composer-autocomplete.mjs", "test:e2e:composer-paste": "node scripts/e2e-composer-paste.mjs", "test:e2e:transcript": "node scripts/e2e-transcript-render.mjs", + "test:e2e:transcript-scroll": "node scripts/e2e-transcript-scroll.mjs", "test:e2e:copy-tex": "node scripts/e2e-copy-tex.mjs", "test:e2e:transcript-disclosure": "node scripts/e2e-transcript-disclosure-anchor.mjs", "test:e2e:images": "node scripts/e2e-image-generation.mjs && node scripts/e2e-image-generation-ui.mjs && node scripts/e2e-image-chat.mjs", @@ -66,7 +67,8 @@ "test:e2e:session-completion": "node scripts/e2e-session-completion.mjs", "test:e2e:remote-host": "node scripts/e2e-remote-host.mjs", "test:e2e:shortcut-settings": "node scripts/test-shortcut-settings.mjs", - "test:e2e:dialog-overflow": "node scripts/test-dialog-overflow.mjs" + "test:e2e:dialog-overflow": "node scripts/test-dialog-overflow.mjs", + "test:e2e:shell-review": "node scripts/e2e-shell-review.mjs" }, "devDependencies": { "@biomejs/biome": "^2.5.13", diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 9aa50c52f5..e94e5725ba 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -359,6 +359,23 @@ export const de = { "reviewChangeShow": "{{status}} Änderungen für {{path}} anzeigen ({{additions}} Ergänzungen, {{deletions}} Löschungen)", "reviewChangeHide": "{{status}} Änderungen für {{path}} ausblenden ({{additions}} Ergänzungen, {{deletions}} Löschungen)", "reviewChangeCounts": "{{additions}} Ergänzungen, {{deletions}} Löschungen", + "turnFilesTitle": "Aufgezeichnete Dateiänderungen", + "turnFilesEdited_one": "{{count}} Datei in dieser Runde bearbeitet", + "turnFilesEdited_other": "{{count}} Dateien in dieser Runde bearbeitet", + "turnFilesCount_one": "1 Datei", + "turnFilesCount_other": "{{count}} Dateien", + "turnFilesEditTotals": "Bearbeitungssummen", + "turnFilesEditTotalsLabel": "Bearbeitungssummen: {{additions}} Ergänzungen, {{deletions}} Löschungen", + "turnFilesScope": "Aufgezeichnete Dateiänderungen · aktive Summen sind kumulativ, nicht netto", + "turnFilesPartial": "Die Shell-Erfassung war unvollständig; weitere Dateiänderungen können fehlen", + "turnFilesUnavailable": "Einige ältere Shell-Ausführungen haben keine Dateiänderungen aufgezeichnet", + "turnFilesSubagentExcluded": "Änderungen von Unteragenten sind ausgeschlossen", + "turnFilesPartialOnly": "Die Erfassung der Dateiänderungen war unvollständig; es sind keine aufgezeichneten Pfade verfügbar.", + "turnFilesUnavailableOnly": "Für diese ältere Shell-Ausführung sind keine aufgezeichneten Änderungen verfügbar.", + "turnFilesSubagentOnly": "In diesem geladenen Zug sind keine aufgezeichneten Dateiänderungen des Hauptagenten verfügbar.", + "turnFilesShowMore_one": "1 weitere Datei anzeigen", + "turnFilesShowMore_other": "{{count}} weitere Dateien anzeigen", + "turnFilesShowLess": "Weniger anzeigen", "toolFailed": "Fehlgeschlagen", "toolDenied": "Abgelehnt", "toolCompleted": "Fertig", @@ -1148,7 +1165,7 @@ sklm: { "linkOpenTargetWorkpanel": "Arbeitsbereich-Browser", "linkOpenTargetExternal": "Standard-Betriebssystem-Browser", "thinkingDisplayMode": "Denkprozess anzeigen", - "thinkingDisplayModeDesc": "Der Modus Detailliert zeigt den Fortschritt in einem ausklappbaren Prozess mit verschachtelten Tool-Gruppen und dem Denkprozess. Der Modus Kompakt startet mit eingeklapptem Prozess und blendet den Denkprozess aus.", + "thinkingDisplayModeDesc": "Beide Modi zeigen den Prozess während der Ausführung geöffnet und klappen ihn am Aufgabenende zu. Detailliert hält verschachtelte Tool-Gruppen und Begründungen aufklappbar; Kompakt blendet abgeschlossene Begründungen aus.", "thinkingDisplayDetailed": "Detailliert (Standard)", "thinkingDisplayCompact": "Kompakt", "contextUsageDisplay": "Kontextnutzung-Anzeige", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 0b741126e4..a75a3e0ef5 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -366,6 +366,23 @@ export const en = { reviewChangeShow: "Show {{status}} changes for {{path}} ({{additions}} additions, {{deletions}} deletions)", reviewChangeHide: "Hide {{status}} changes for {{path}} ({{additions}} additions, {{deletions}} deletions)", reviewChangeCounts: "{{additions}} additions, {{deletions}} deletions", + turnFilesTitle: "Recorded file changes", + turnFilesEdited_one: "Edited {{count}} file this turn", + turnFilesEdited_other: "Edited {{count}} files this turn", + turnFilesCount_one: "1 file", + turnFilesCount_other: "{{count}} files", + turnFilesEditTotals: "Edit totals", + turnFilesEditTotalsLabel: "Edit totals: {{additions}} additions, {{deletions}} deletions", + turnFilesScope: "Recorded file changes · active totals are cumulative, not net", + turnFilesPartial: "Shell capture was partial; additional file changes may not be shown", + turnFilesUnavailable: "Some older shell runs did not record file changes", + turnFilesSubagentExcluded: "subagent changes are excluded", + turnFilesPartialOnly: "File change capture was partial; no recorded paths are available.", + turnFilesUnavailableOnly: "Recorded changes are unavailable for this older shell run.", + turnFilesSubagentOnly: "No main-agent recorded file changes are available in this loaded turn.", + turnFilesShowMore_one: "Show 1 more file", + turnFilesShowMore_other: "Show {{count}} more files", + turnFilesShowLess: "Show less", toolFailed: "Failed", toolDenied: "Denied", toolCompleted: "Done", @@ -1160,7 +1177,7 @@ sklm: { linkOpenTargetWorkpanel: "Work panel browser", linkOpenTargetExternal: "Default OS browser", thinkingDisplayMode: "Thinking display mode", - thinkingDisplayModeDesc: "Detailed shows progress in an expandable process with nested tool groups and reasoning. Compact starts the process collapsed and hides reasoning.", + thinkingDisplayModeDesc: "Both modes expand the process while running and collapse it when the task ends. Detailed keeps nested tool groups and reasoning available on expansion; Compact hides completed reasoning.", thinkingDisplayDetailed: "Detailed (default)", thinkingDisplayCompact: "Compact", contextUsageDisplay: "Context usage readout", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 62befdb847..c29aed4c6e 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -359,6 +359,23 @@ export const es = { "reviewChangeShow": "Mostrar {{status}} cambios para {{path}} ({{additions}} adiciones, {{deletions}} eliminaciones)", "reviewChangeHide": "Ocultar {{status}} cambios para {{path}} ({{additions}} adiciones, {{deletions}} eliminaciones)", "reviewChangeCounts": "{{additions}} adiciones, {{deletions}} eliminaciones", + "turnFilesTitle": "Cambios de archivos registrados", + "turnFilesEdited_one": "{{count}} archivo editado en este turno", + "turnFilesEdited_other": "{{count}} archivos editados en este turno", + "turnFilesCount_one": "1 archivo", + "turnFilesCount_other": "{{count}} archivos", + "turnFilesEditTotals": "Totales de edición", + "turnFilesEditTotalsLabel": "Totales de edición: {{additions}} adiciones, {{deletions}} eliminaciones", + "turnFilesScope": "Cambios de archivos registrados · los totales activos son acumulados, no netos", + "turnFilesPartial": "La captura del shell fue parcial; pueden faltar otros cambios de archivos", + "turnFilesUnavailable": "Algunas ejecuciones antiguas del shell no registraron cambios de archivos", + "turnFilesSubagentExcluded": "se excluyen los cambios de subagentes", + "turnFilesPartialOnly": "La captura de cambios de archivos fue parcial; no hay rutas registradas disponibles.", + "turnFilesUnavailableOnly": "Los cambios registrados no están disponibles para esta ejecución antigua del shell.", + "turnFilesSubagentOnly": "No hay cambios de archivos registrados por el agente principal en este turno cargado.", + "turnFilesShowMore_one": "Mostrar 1 archivo más", + "turnFilesShowMore_other": "Mostrar {{count}} archivos más", + "turnFilesShowLess": "Mostrar menos", "toolFailed": "Fallido", "toolDenied": "Denegado", "toolCompleted": "Hecho", @@ -1148,7 +1165,7 @@ sklm: { "linkOpenTargetWorkpanel": "Navegador del panel de trabajo", "linkOpenTargetExternal": "Navegador predeterminado del sistema", "thinkingDisplayMode": "Visualización del razonamiento", - "thinkingDisplayModeDesc": "Detallado muestra el progreso en un proceso expandible con grupos de herramientas anidados y razonamiento. Compacto inicia el proceso contraído y oculta el razonamiento.", + "thinkingDisplayModeDesc": "Ambos modos expanden el proceso durante la ejecución y lo contraen al terminar la tarea. Detallado conserva grupos de herramientas anidados y el razonamiento al expandir; Compacto oculta el razonamiento completado.", "thinkingDisplayDetailed": "Detallado (predeterminado)", "thinkingDisplayCompact": "Compacto", "contextUsageDisplay": "Lectura del uso del contexto", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 9a34e41160..dc85bde0c8 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -359,6 +359,23 @@ export const fr = { "reviewChangeShow": "Afficher {{status}} modifications pour {{path}} ({{additions}} ajouts, {{deletions}} suppressions)", "reviewChangeHide": "Masquer {{status}} modifications pour {{path}} ({{additions}} ajouts, {{deletions}} suppressions)", "reviewChangeCounts": "{{additions}} ajouts, {{deletions}} suppressions", + "turnFilesTitle": "Modifications de fichiers enregistrées", + "turnFilesEdited_one": "{{count}} fichier modifié pendant ce tour", + "turnFilesEdited_other": "{{count}} fichiers modifiés pendant ce tour", + "turnFilesCount_one": "1 fichier", + "turnFilesCount_other": "{{count}} fichiers", + "turnFilesEditTotals": "Totaux des modifications", + "turnFilesEditTotalsLabel": "Totaux des modifications : {{additions}} ajouts, {{deletions}} suppressions", + "turnFilesScope": "Modifications de fichiers enregistrées · totaux actifs cumulés, pas nets", + "turnFilesPartial": "La capture du shell était partielle ; d'autres modifications peuvent manquer", + "turnFilesUnavailable": "Certaines anciennes exécutions du shell n'ont pas enregistré les modifications de fichiers", + "turnFilesSubagentExcluded": "les modifications des sous-agents sont exclues", + "turnFilesPartialOnly": "La capture des modifications était partielle ; aucun chemin enregistré n'est disponible.", + "turnFilesUnavailableOnly": "Les modifications enregistrées ne sont pas disponibles pour cette ancienne exécution du shell.", + "turnFilesSubagentOnly": "Aucune modification de fichier enregistrée par l'agent principal n'est disponible dans ce tour chargé.", + "turnFilesShowMore_one": "Afficher 1 fichier de plus", + "turnFilesShowMore_other": "Afficher {{count}} fichiers de plus", + "turnFilesShowLess": "Afficher moins", "toolFailed": "Échec", "toolDenied": "Refusé", "toolCompleted": "Terminé", @@ -1148,7 +1165,7 @@ sklm: { "linkOpenTargetWorkpanel": "Navigateur du panneau de travail", "linkOpenTargetExternal": "Navigateur par défaut du système", "thinkingDisplayMode": "Affichage du raisonnement", - "thinkingDisplayModeDesc": "Le mode détaillé affiche la progression dans un processus dépliable avec des groupes d'outils imbriqués et le raisonnement. Le mode compact démarre avec le processus replié et masque le raisonnement.", + "thinkingDisplayModeDesc": "Les deux modes déploient le processus pendant l’exécution et le replient à la fin de la tâche. Détaillé conserve les groupes d'outils imbriqués et le raisonnement en dépliant ; Compact masque le raisonnement terminé.", "thinkingDisplayDetailed": "Détaillé (par défaut)", "thinkingDisplayCompact": "Compact", "contextUsageDisplay": "Affichage de l'usage du contexte", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index e98bec760b..440b56bb57 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -368,6 +368,23 @@ export const ko = { reviewChangeShow: "{{path}}의 {{status}} 변경 사항 표시 (추가 {{additions}}개, 삭제 {{deletions}}개)", reviewChangeHide: "{{path}}의 {{status}} 변경 사항 숨기기 (추가 {{additions}}개, 삭제 {{deletions}}개)", reviewChangeCounts: "추가 {{additions}}개, 삭제 {{deletions}}개", + turnFilesTitle: "기록된 파일 변경", + turnFilesEdited_one: "이번 차례에 파일 {{count}}개 편집", + turnFilesEdited_other: "이번 차례에 파일 {{count}}개 편집", + turnFilesCount_one: "파일 1개", + turnFilesCount_other: "파일 {{count}}개", + turnFilesEditTotals: "편집 합계", + turnFilesEditTotalsLabel: "편집 합계: 추가 {{additions}}개, 삭제 {{deletions}}개", + turnFilesScope: "기록된 파일 변경 · 활성 합계는 순변경이 아닌 누적값", + turnFilesPartial: "셸 캡처가 일부만 완료되어 다른 파일 변경이 표시되지 않을 수 있음", + turnFilesUnavailable: "일부 이전 셸 실행에서는 파일 변경을 기록하지 않음", + turnFilesSubagentExcluded: "하위 에이전트 변경은 제외됨", + turnFilesPartialOnly: "파일 변경 캡처가 일부만 완료되어 기록된 경로가 없습니다.", + turnFilesUnavailableOnly: "이 이전 셸 실행의 기록된 변경을 사용할 수 없습니다.", + turnFilesSubagentOnly: "불러온 이 턴에는 주 에이전트가 기록한 파일 변경이 없습니다.", + turnFilesShowMore_one: "파일 1개 더 보기", + turnFilesShowMore_other: "파일 {{count}}개 더 보기", + turnFilesShowLess: "간단히 보기", toolFailed: "실패", toolDenied: "거부됨", toolCompleted: "완료", @@ -1158,7 +1175,7 @@ sklm: { linkOpenTargetWorkpanel: "작업 패널 브라우저", linkOpenTargetExternal: "시스템 기본 브라우저", thinkingDisplayMode: "사고 과정 표시 모드", - thinkingDisplayModeDesc: "상세 모드는 중첩된 도구 그룹과 사고 과정을 포함한 진행 상황을 펼칠 수 있는 처리 과정에 표시합니다. 간결 모드는 처리 과정을 접힌 상태로 시작하고 사고 과정을 숨깁니다.", + thinkingDisplayModeDesc: "두 모드 모두 실행 중에는 과정을 펼치고 작업이 끝나면 접습니다. 상세는 펼쳐서 볼 수 있는 중첩 도구 그룹과 추론을 유지하고, 간결은 완료된 추론을 숨깁니다.", thinkingDisplayDetailed: "상세 (기본값)", thinkingDisplayCompact: "간결", contextUsageDisplay: "컨텍스트 사용량 표시", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index efe71b9313..02067ee6f5 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -368,6 +368,23 @@ export const tr = { reviewChangeShow: "{{path}} için {{status}} değişikliklerini göster ({{additions}} ekleme, {{deletions}} silme)", reviewChangeHide: "{{path}} için {{status}} değişikliklerini gizle ({{additions}} ekleme, {{deletions}} silme)", reviewChangeCounts: "{{additions}} ekleme, {{deletions}} silme", + turnFilesTitle: "Kaydedilen dosya değişiklikleri", + turnFilesEdited_one: "Bu turda {{count}} dosya düzenlendi", + turnFilesEdited_other: "Bu turda {{count}} dosya düzenlendi", + turnFilesCount_one: "1 dosya", + turnFilesCount_other: "{{count}} dosya", + turnFilesEditTotals: "Düzenleme toplamları", + turnFilesEditTotalsLabel: "Düzenleme toplamları: {{additions}} ekleme, {{deletions}} silme", + turnFilesScope: "Kaydedilen dosya değişiklikleri · etkin toplamlar net değil birikimlidir", + turnFilesPartial: "Kabuk yakalaması kısmi kaldı; ek dosya değişiklikleri gösterilmeyebilir", + turnFilesUnavailable: "Bazı eski kabuk çalıştırmaları dosya değişikliklerini kaydetmedi", + turnFilesSubagentExcluded: "alt ajan değişiklikleri hariç tutulur", + turnFilesPartialOnly: "Dosya değişikliği yakalaması kısmi kaldı; kaydedilmiş yol yok.", + turnFilesUnavailableOnly: "Bu eski kabuk çalıştırması için kaydedilen değişiklikler kullanılamıyor.", + turnFilesSubagentOnly: "Yüklenen bu turda ana ajanın kaydedilmiş dosya değişikliği yok.", + turnFilesShowMore_one: "1 dosya daha göster", + turnFilesShowMore_other: "{{count}} dosya daha göster", + turnFilesShowLess: "Daha az göster", toolFailed: "Başarısız", toolDenied: "Reddedildi", toolCompleted: "Bitti", @@ -1148,7 +1165,7 @@ sklm: { linkOpenTargetWorkpanel: "Çalışma paneli tarayıcısı", linkOpenTargetExternal: "Varsayılan sistem tarayıcısı", thinkingDisplayMode: "Düşünme görünümü", - thinkingDisplayModeDesc: "Ayrıntılı mod ilerlemeyi, iç içe araç grupları ve düşünmeyle birlikte genişletilebilir bir işlemde gösterir. Sade mod işlemi daraltılmış olarak başlatır ve düşünmeyi gizler.", + thinkingDisplayModeDesc: "Her iki mod da işlem sürerken süreci açar ve görev bitince kapatır. Ayrıntılı mod, açıldığında iç içe araç grupları ve akıl yürütmeyi gösterir; Sade mod tamamlanan akıl yürütmeyi gizler.", thinkingDisplayDetailed: "Ayrıntılı (varsayılan)", thinkingDisplayCompact: "Sade", contextUsageDisplay: "Bağlam kullanımı göstergesi", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 07e8883322..c34f3b3e52 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -360,6 +360,23 @@ export const zhCN = { reviewChangeShow: "显示 {{path}} 的{{status}}改动(新增 {{additions}} 行,删除 {{deletions}} 行)", reviewChangeHide: "隐藏 {{path}} 的{{status}}改动(新增 {{additions}} 行,删除 {{deletions}} 行)", reviewChangeCounts: "新增 {{additions}} 行,删除 {{deletions}} 行", + turnFilesTitle: "已记录的文件改动", + turnFilesEdited_one: "本轮编辑 {{count}} 个文件", + turnFilesEdited_other: "本轮编辑 {{count}} 个文件", + turnFilesCount_one: "1 个文件", + turnFilesCount_other: "{{count}} 个文件", + turnFilesEditTotals: "编辑总计", + turnFilesEditTotalsLabel: "编辑总计:新增 {{additions}} 行,删除 {{deletions}} 行", + turnFilesScope: "已记录的文件改动 · 当前总计为累计编辑量,并非净差异", + turnFilesPartial: "命令行改动仅部分捕获,可能还有文件改动未显示", + turnFilesUnavailable: "部分旧版命令行记录未保存文件改动", + turnFilesSubagentExcluded: "不包含子智能体改动", + turnFilesPartialOnly: "文件改动仅部分捕获,暂无可用的已记录路径。", + turnFilesUnavailableOnly: "此旧版命令行记录没有可用的文件改动。", + turnFilesSubagentOnly: "本次已加载回合中没有主智能体记录的文件改动。", + turnFilesShowMore_one: "再显示 1 个文件", + turnFilesShowMore_other: "再显示 {{count}} 个文件", + turnFilesShowLess: "收起", toolFailed: "失败", toolDenied: "已拒绝", toolCompleted: "已完成", @@ -1134,7 +1151,7 @@ sklm: { linkOpenTargetWorkpanel: "工作区浏览器", linkOpenTargetExternal: "系统默认浏览器", thinkingDisplayMode: "思考过程展示模式", - thinkingDisplayModeDesc: "详细模式在可展开的处理过程中显示进度、嵌套工具分组和思考内容。精简模式默认折叠处理过程并隐藏思考内容。", + thinkingDisplayModeDesc: "两种模式都会在运行时展开处理过程,任务结束后统一收起。详细模式保留可展开查看的嵌套工具分组和推理内容,精简模式隐藏已完成的推理。", thinkingDisplayDetailed: "详细(默认)", thinkingDisplayCompact: "精简", contextUsageDisplay: "上下文用量读数", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index d1fe4b3a64..a6377007c3 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -360,6 +360,23 @@ export const zhTW = { reviewChangeShow: "顯示 {{path}} 的{{status}}改動(新增 {{additions}} 行,刪除 {{deletions}} 行)", reviewChangeHide: "隱藏 {{path}} 的{{status}}改動(新增 {{additions}} 行,刪除 {{deletions}} 行)", reviewChangeCounts: "新增 {{additions}} 行,刪除 {{deletions}} 行", + turnFilesTitle: "已記錄的檔案改動", + turnFilesEdited_one: "本輪編輯 {{count}} 個檔案", + turnFilesEdited_other: "本輪編輯 {{count}} 個檔案", + turnFilesCount_one: "1 個檔案", + turnFilesCount_other: "{{count}} 個檔案", + turnFilesEditTotals: "編輯總計", + turnFilesEditTotalsLabel: "編輯總計:新增 {{additions}} 行,刪除 {{deletions}} 行", + turnFilesScope: "已記錄的檔案改動 · 目前總計為累計編輯量,並非淨差異", + turnFilesPartial: "命令列改動僅部分擷取,可能還有檔案改動未顯示", + turnFilesUnavailable: "部分舊版命令列記錄未保存檔案改動", + turnFilesSubagentExcluded: "不包含子智慧體改動", + turnFilesPartialOnly: "檔案改動僅部分擷取,暫無可用的已記錄路徑。", + turnFilesUnavailableOnly: "此舊版命令列記錄沒有可用的檔案改動。", + turnFilesSubagentOnly: "本次已載入回合中沒有主智慧體記錄的檔案改動。", + turnFilesShowMore_one: "再顯示 1 個檔案", + turnFilesShowMore_other: "再顯示 {{count}} 個檔案", + turnFilesShowLess: "收起", toolFailed: "失敗", toolDenied: "已拒絕", toolCompleted: "已完成", @@ -1134,7 +1151,7 @@ sklm: { linkOpenTargetWorkpanel: "工作區瀏覽器", linkOpenTargetExternal: "系統預設瀏覽器", thinkingDisplayMode: "思考過程顯示模式", - thinkingDisplayModeDesc: "詳細模式在可展開的處理過程中顯示進度、巢狀工具群組和思考內容。精簡模式預設摺疊處理過程並隱藏思考內容。", + thinkingDisplayModeDesc: "兩種模式都會在執行時展開處理過程,任務結束後統一收起。詳細模式保留可展開查看的巢狀工具群組和推理內容,精簡模式隱藏已完成的推理。", thinkingDisplayDetailed: "詳細(預設)", thinkingDisplayCompact: "精簡", contextUsageDisplay: "上下文用量讀數", diff --git a/scripts/build-desktop-release-process.mjs b/scripts/build-desktop-release-process.mjs new file mode 100644 index 0000000000..b2fdeb6788 --- /dev/null +++ b/scripts/build-desktop-release-process.mjs @@ -0,0 +1,12 @@ +export function createPnpmInvocation(platform, builderArgs) { + return { + command: platform === "win32" ? "pnpm.cmd" : "pnpm", + args: ["exec", "electron-builder", ...builderArgs], + options: { + stdio: "inherit", + // Windows package managers are .cmd shims and require a shell when + // launched through Node's child_process API. + shell: platform === "win32", + }, + }; +} diff --git a/scripts/e2e-shell-review.mjs b/scripts/e2e-shell-review.mjs new file mode 100644 index 0000000000..aa057a345e --- /dev/null +++ b/scripts/e2e-shell-review.mjs @@ -0,0 +1,457 @@ +#!/usr/bin/env node +import { randomUUID } from "node:crypto"; +import { + existsSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { register } from "node:module"; +import { isDeepStrictEqual } from "node:util"; + +import { PROTOCOL_VERSION } from "../packages/shared/dist/protocol.js"; +import { assert, shortJson } from "./e2e/assert.mjs"; +import { withScenario } from "./e2e/fixture.mjs"; +import { configureSession, createSession } from "./e2e/session.mjs"; +import { resolveHostBinary } from "./e2e/host.mjs"; + +register(new URL("../apps/desktop/test/helpers/ts-import-hooks.mjs", import.meta.url)); +const { buildTranscriptEntries } = await import("../apps/desktop/src/lib/assistant-turns.ts"); +const { summarizeTurnFileChanges } = await import("../apps/desktop/src/lib/turn-file-summary.ts"); +const { reviewChangesFromMessages } = await import("../apps/desktop/src/lib/workspace-review.ts"); + +function summaryFor(messages) { + const entry = buildTranscriptEntries(messages).entries.find((item) => item.kind === "assistant-turn"); + assert(entry, "reloaded assistant turn missing"); + const summary = summarizeTurnFileChanges(entry); + return { + fileCount: summary.fileCount, + additions: summary.additions, + deletions: summary.deletions, + paths: summary.files.map((file) => file.path), + }; +} + +const configuredHost = process.env.PI_DESKTOP_HOST_BIN?.trim(); +if (!configuredHost) { + throw new Error("PI_DESKTOP_HOST_BIN is required for the shell review E2E"); +} + +const configuredPath = resolve(configuredHost); +const configuredCandidates = [configuredPath]; +if (process.platform === "win32" && !configuredPath.toLowerCase().endsWith(".exe")) { + configuredCandidates.push(`${configuredPath}.exe`); +} +const hostBinary = resolveHostBinary(); +assert( + configuredCandidates.includes(hostBinary), + `PI_DESKTOP_HOST_BIN did not resolve to the selected host: ${hostBinary}`, +); + +const tempBase = resolve(process.env.PI_SCRATCH_DIR || tmpdir()); +await mkdir(tempBase, { recursive: true }); +const suiteRoot = await mkdtemp(join(tempBase, "pi-shell-review-")); + +function quotePowerShell(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +function quotePosix(value) { + return `'${String(value).replaceAll("'", `'"'"'`)}'`; +} + +function shellQuote(value) { + return process.platform === "win32" + ? quotePowerShell(value) + : quotePosix(value); +} + +function assertCompleteCapture(content, expectedCount, label) { + assert(content?.root === "workspace", `${label}: root ${shortJson(content)}`); + assert( + content?.reviewCapture?.status === "complete", + `${label}: capture ${shortJson(content?.reviewCapture)}`, + ); + assert( + Array.isArray(content?.reviews) && content.reviews.length === expectedCount, + `${label}: reviews ${shortJson(content?.reviews)}`, + ); + return content.reviews; +} + +function reviewsByPath(reviews) { + return new Map(reviews.map((review) => [review.path, review])); +} + +function assertAdded(review, path) { + assert(review?.path === path, `missing added review ${path}: ${shortJson(review)}`); + assert(review.version === 1, `${path}: version ${review.version}`); + assert(review.operation === "write", `${path}: operation ${review.operation}`); + assert(review.status === "added", `${path}: status ${review.status}`); + assert(review.state === "active", `${path}: state ${review.state}`); + assert(review.additions === 1, `${path}: additions ${review.additions}`); + assert(review.deletions === 0, `${path}: deletions ${review.deletions}`); + assert(review.reversible === true, `${path}: not reversible`); + assert(typeof review.snapshotId === "string" && review.snapshotId.length > 0, `${path}: snapshot`); +} + +async function executeBash(host, sessionId, shell, command, toolCallId = randomUUID()) { + const result = await host.call( + "tools.execute", + { + sessionId, + toolCallId, + toolName: "Bash", + args: { command }, + mode: "agent", + expectedCommandShellId: shell.id, + expectedCommandShellDialect: shell.dialect, + timeoutMs: 15_000, + }, + 25_000, + ); + assert(result.toolCallId === toolCallId, `Bash identity: ${shortJson(result)}`); + assert(result.commandShellId === shell.id, `Bash shell: ${shortJson(result)}`); + return result; +} + +function copyCommand(sources) { + if (process.platform === "win32") { + return sources + .map(({ source, target }) => + `Copy-Item -LiteralPath ${shellQuote(source)} -Destination ${shellQuote(target)}`, + ) + .join("; "); + } + return sources + .map(({ source, target }) => `cp -- ${shellQuote(source)} ${shellQuote(target)}`) + .join(" && "); +} + +function renameDeleteCommand() { + if (process.platform === "win32") { + return [ + `Move-Item -LiteralPath ${shellQuote("rename-me.txt")} -Destination ${shellQuote("renamed.txt")}`, + `Remove-Item -LiteralPath ${shellQuote("delete-me.txt")}`, + ].join("; "); + } + return [ + `mv -- ${shellQuote("rename-me.txt")} ${shellQuote("renamed.txt")}`, + `rm -- ${shellQuote("delete-me.txt")}`, + ].join(" && "); +} + +function failureCommand() { + if (process.platform === "win32") { + return `Set-Content -LiteralPath ${shellQuote("reviewed/failure.txt")} -Value ${shellQuote("failed")}; exit 7`; + } + return `printf '%s\\n' ${shellQuote("failed")} > ${shellQuote("reviewed/failure.txt")}; exit 7`; +} + +function readOnlyCommand() { + if (process.platform === "win32") { + return `Get-Content -LiteralPath ${shellQuote("dirty.txt")} | Out-Null`; + } + return `cat -- ${shellQuote("dirty.txt")} >/dev/null`; +} + +function persistedReviews(detail, messageId) { + const message = detail?.session?.messages?.find((item) => item.id === messageId); + assert(message, `persisted tool message missing: ${shortJson(detail?.session?.messages)}`); + const details = message.toolResult?.details; + assert(details?.root === "workspace", `persisted root: ${shortJson(details ?? null)}`); + assert(details?.reviewCapture?.status === "complete", `persisted capture: ${shortJson(details ?? null)}`); + assert(Array.isArray(details?.reviews), `persisted reviews: ${shortJson(details ?? null)}`); + return details.reviews; +} + +async function appendNativeToolMessage(host, sessionId, toolName, toolCallId, args, result, parentToolCallId) { + const message = { + id: toolCallId, + role: "tool", + ...(parentToolCallId ? { parentToolCallId, agentName: "fixer" } : {}), + content: JSON.stringify(result.content, null, 2), + createdAt: new Date().toISOString(), + status: "complete", + toolName, + toolCallId, + toolStatus: result.ok ? "success" : "error", + toolArgs: args, + toolResult: { + content: [{ type: "text", text: JSON.stringify(result.content, null, 2) }], + details: result.content, + }, + }; + await host.call("session.appendMessage", { sessionId, message }); + return message; +} + +function assertReviewIdentity(actual, expected) { + for (const field of ["version", "snapshotId", "messageId", "path", "operation", "status", "state", "additions", "deletions", "reversible"]) { + assert(actual?.[field] === expected[field], `display lost review field ${field}`); + } + assert(Array.isArray(actual.hunks), "display review hunks lost their array shape"); +} + +async function scenario({ host, dataDir, workspace }) { + const toolCatalog = await host.call("tools.list"); + assert( + toolCatalog?.tools?.some((tool) => tool.name === "Bash"), + `Bash missing from tools.list: ${shortJson(toolCatalog)}`, + ); + + const shellCatalog = await host.call("commandShells.list"); + const shell = shellCatalog?.effective; + assert(shell?.available === true, `no effective shell: ${shortJson(shellCatalog)}`); + const expectedDialect = process.platform === "win32" ? "powershell" : "posix"; + assert(shell.dialect === expectedDialect, `shell dialect: ${shortJson(shellCatalog)}`); + assert( + shellCatalog.choices?.some( + (choice) => choice.id === shell.id && choice.dialect === shell.dialect && choice.available, + ), + `effective shell absent from choices: ${shortJson(shellCatalog)}`, + ); + + const created = await createSession(host, workspace, "Shell review E2E"); + const session = await configureSession(host, created, "agent", "auto"); + assert(session.permissionMode === "auto", `permission mode: ${shortJson(session)}`); + + const sessionScratch = join(dataDir, "scratch", session.id); + const reviewedDir = join(workspace, "reviewed"); + await mkdir(sessionScratch, { recursive: true }); + await mkdir(reviewedDir, { recursive: true }); + + const sources = [ + { name: "app.html", content: "\n" }, + { name: "app.css", content: "body { color: #123456; }\n" }, + { name: "app.js", content: 'console.log("review");\n' }, + ].map((file) => ({ + ...file, + source: join(sessionScratch, file.name), + target: `reviewed/${file.name}`, + })); + for (const file of sources) writeFileSync(file.source, file.content, "utf8"); + + writeFileSync(join(workspace, "dirty.txt"), "preexisting dirty content\n", "utf8"); + writeFileSync(join(workspace, "rename-me.txt"), "rename content\n", "utf8"); + writeFileSync(join(workspace, "delete-me.txt"), "delete content\n", "utf8"); + + const copyToolCallId = randomUUID(); + const verboseCopyCommand = copyCommand(sources) + (process.platform === "win32" + ? "; [Console]::Write(('x' * 80000))" + : "; printf '%80000s' ''"); + const copied = await executeBash( + host, + session.id, + shell, + verboseCopyCommand, + copyToolCallId, + ); + assert(copied.ok === true, `copy failed: ${shortJson(copied)}`); + assert(copied.content?.exitCode === 0, `copy exit: ${shortJson(copied.content)}`); + const copyReviews = assertCompleteCapture(copied.content, 3, "copy"); + const copyByPath = reviewsByPath(copyReviews); + for (const file of sources) assertAdded(copyByPath.get(file.target), file.target); + assert(!copyByPath.has("dirty.txt"), `unchanged dirty file captured: ${shortJson(copyReviews)}`); + + const noOp = await executeBash(host, session.id, shell, readOnlyCommand()); + assert(noOp.ok === true && noOp.content?.exitCode === 0, `no-op failed: ${shortJson(noOp)}`); + assertCompleteCapture(noOp.content, 0, "read-only no-op"); + + const failed = await executeBash(host, session.id, shell, failureCommand()); + assert(failed.ok === false, `nonzero shell stayed successful: ${shortJson(failed)}`); + assert(failed.content?.exitCode === 7, `nonzero exit changed: ${shortJson(failed)}`); + const failedReviews = assertCompleteCapture(failed.content, 1, "nonzero write"); + assertAdded(failedReviews[0], "reviewed/failure.txt"); + + const moved = await executeBash(host, session.id, shell, renameDeleteCommand()); + assert(moved.ok === true, `rename/delete failed: ${shortJson(moved)}`); + const movedByPath = reviewsByPath(assertCompleteCapture(moved.content, 3, "rename/delete")); + assert(movedByPath.get("rename-me.txt")?.status === "deleted", shortJson(moved.content)); + assert(movedByPath.get("renamed.txt")?.status === "added", shortJson(moved.content)); + assert(movedByPath.get("delete-me.txt")?.status === "deleted", shortJson(moved.content)); + + const message = await appendNativeToolMessage( + host, session.id, "Bash", copyToolCallId, + { command: verboseCopyCommand }, copied, + ); + assert(persistedReviews(await host.call("session.get", { id: session.id }), message.id).length === 3, "reviews missing before restart"); + + const largeMessages = []; + let writeTag; + for (const toolName of ["Write", "Edit"]) { + const largeText = `${toolName}:` + "large recorded diff ".repeat(5000); + const args = toolName === "Write" + ? { path: "reviewed/large.txt", content: largeText } + : { path: "reviewed/large.txt", tag: writeTag, ops: `PUT 1.=1:\n+${largeText}` }; + const toolCallId = randomUUID(); + const result = await host.call("tools.execute", { + sessionId: session.id, toolCallId, toolName, args, mode: "agent", + }); + assert(result.ok === true && result.content?.review, `${toolName} review producer failed: ${shortJson(result)}`); + writeTag = result.content.tag; + const saved = await appendNativeToolMessage(host, session.id, toolName, toolCallId, args, result); + largeMessages.push({ message: saved, review: result.content.review }); + } + + const finalMessage = { + id: randomUUID(), role: "assistant", content: "Finished the file updates.", + createdAt: new Date().toISOString(), status: "complete", + }; + await host.call("session.appendMessage", { sessionId: session.id, message: finalMessage }); + const liveSummary = summaryFor([message, ...largeMessages.map((item) => item.message), finalMessage]); + assert(liveSummary.fileCount === 4, "native workflow did not produce four distinct files"); + + await host.restart(PROTOCOL_VERSION); + const limitedOptions = { id: session.id, contentLimit: 64 * 1024 }; + const displayed = await host.call("session.get", limitedOptions); + assert(isDeepStrictEqual(summaryFor(displayed.session.messages), liveSummary), "capped reopen changed the rendered turn file summary"); + const afterRestart = persistedReviews(displayed, message.id); + assert(afterRestart.length === 3, `reviews missing after restart: ${shortJson(afterRestart)}`); + for (const expected of copyReviews) { + assertReviewIdentity(reviewsByPath(afterRestart).get(expected.path), expected); + } + for (const { message: saved, review: expected } of largeMessages) { + const loaded = displayed.session.messages.find((item) => item.id === saved.id); + assert(loaded?.toolResult?.details?.root === "workspace", "display lost workspace root"); + assertReviewIdentity(loaded.toolResult.details.review, expected); + assert(JSON.stringify(loaded.toolResult).length < JSON.stringify(saved.toolResult).length, "large result was not bounded for display"); + const full = (await host.call("session.get", { id: session.id })).session.messages.find((item) => item.id === saved.id); + assert(isDeepStrictEqual(full.toolResult, saved.toolResult), "display projection changed persisted evidence"); + } + + const jsReview = reviewsByPath(afterRestart).get("reviewed/app.js"); + writeFileSync(join(workspace, "reviewed", "app.js"), "user changed after tool\n", "utf8"); + const conflict = await host.call("review.rollback", { + sessionId: session.id, + snapshotId: jsReview.snapshotId, + }); + assert(conflict.status === "conflict", `expected rollback conflict: ${shortJson(conflict)}`); + assert( + readFileSync(join(workspace, "reviewed", "app.js"), "utf8") === "user changed after tool\n", + "conflict rollback overwrote user content", + ); + + const cssReview = reviewsByPath(afterRestart).get("reviewed/app.css"); + const rollback = await host.call("review.rollback", { + sessionId: session.id, + snapshotId: cssReview.snapshotId, + }); + assert(rollback.status === "rolledBack", `rollback failed: ${shortJson(rollback)}`); + assert(!existsSync(join(workspace, "reviewed", "app.css")), "rolled-back add still exists"); + assert(existsSync(join(workspace, "reviewed", "app.html")), "sibling HTML was removed"); + assert(existsSync(join(workspace, "reviewed", "app.js")), "sibling JS was removed"); + assert(readFileSync(join(workspace, "dirty.txt"), "utf8") === "preexisting dirty content\n", "dirty file changed"); + + const finalReviews = reviewsByPath( + persistedReviews(await host.call("session.get", limitedOptions), message.id), + ); + assert(finalReviews.get("reviewed/app.css")?.state === "rolledBack", shortJson([...finalReviews.values()])); + assert(finalReviews.get("reviewed/app.html")?.state === "active", shortJson([...finalReviews.values()])); + assert(finalReviews.get("reviewed/app.js")?.state === "active", shortJson([...finalReviews.values()])); + await delegatedEditsScenario(host, workspace, shell); +} + +async function delegatedEditsScenario(host, workspace, shell) { + const created = await createSession(host, workspace, "Delegated source review E2E"); + const session = await configureSession(host, created, "agent", "auto"); + const taskCallId = randomUUID(); + const append = (message) => host.call("session.appendMessage", { + sessionId: session.id, + message: { createdAt: new Date().toISOString(), status: "complete", ...message }, + }); + await append({ id: randomUUID(), role: "user", content: "Update the source and build it." }); + await append({ + id: taskCallId, role: "tool", content: "Started fixer", toolName: "Task", + toolCallId: taskCallId, toolStatus: "success", toolArgs: { agent: "fixer" }, + }); + await mkdir(join(workspace, "src"), { recursive: true }); + writeFileSync(join(workspace, "src/Main.java"), "before\n"); + const read = await host.call("tools.execute", { + sessionId: session.id, toolCallId: randomUUID(), toolName: "Read", + args: { path: "src/Main.java" }, mode: "agent", + }); + assert(read.ok && read.content?.tag, "native read did not return the edit anchor"); + const delegated = []; + for (const [toolName, args] of [ + ["Write", { path: "src/Feature.java", content: "feature\n" }], + ["Edit", { path: "src/Main.java", tag: read.content.tag, ops: "PUT 1.=1:\n+after" }], + ]) { + const toolCallId = randomUUID(); + const result = await host.call("tools.execute", { + sessionId: session.id, toolCallId, toolName, args, mode: "agent", + }); + assert(result.ok && result.content?.review, `delegated ${toolName} missing native review`); + const message = await appendNativeToolMessage( + host, session.id, toolName, toolCallId, args, result, taskCallId, + ); + delegated.push({ message, review: result.content.review }); + } + // Cache writes and a real source write occur in the same native shell interval. + const cacheDirs = [".gradle/8.4/fileHashes", "module/.gradle"]; + for (const dir of cacheDirs) { + await mkdir(join(workspace, dir), { recursive: true }); + writeFileSync(join(workspace, dir, "state.lock"), Buffer.from([0, 1])); + } + const binarySource = join(workspace, "reviewed/cache-source.bin"); + writeFileSync(binarySource, Buffer.from([0, 2])); + const command = copyCommand([ + ...cacheDirs.map((dir) => ({ source: binarySource, target: `${dir}/state.lock` })), + { source: join(workspace, "src/Feature.java"), target: "src/Shell.java" }, + ]); + const built = await executeBash(host, session.id, shell, command); + const shellReviews = assertCompleteCapture(built.content, 1, "source with Gradle caches"); + assert(shellReviews[0].path === "src/Shell.java", "cache displaced actual shell edit"); + await appendNativeToolMessage(host, session.id, "Bash", built.toolCallId, { command }, built); + await append({ id: randomUUID(), role: "assistant", content: "Source updated and built." }); + + await host.restart(PROTOCOL_VERSION); + const options = { id: session.id, contentLimit: 64 * 1024 }; + const displayed = (await host.call("session.get", options)).session.messages; + const summary = summaryFor(displayed); + assert(isDeepStrictEqual([...summary.paths].sort(), ["src/Feature.java", "src/Main.java", "src/Shell.java"]), "delegated source edits missing after restart"); + assert(summary.additions === 3 && summary.deletions === 1, "delegated line counts lost"); + const reviewEntries = reviewChangesFromMessages(displayed); + assert(reviewEntries.length === 3, "Review panel and turn summary disagree"); + for (const { message, review } of delegated) { + const loaded = displayed.find((item) => item.id === message.id); + assert(loaded?.parentToolCallId === taskCallId, "delegated parent identity lost on reload"); + assertReviewIdentity(loaded?.toolResult?.details?.review, review); + assert(reviewEntries.some((entry) => entry.message.id === message.id && entry.change.snapshotId === review.snapshotId), "Review navigation lost delegated identity"); + } + const feature = delegated.find((item) => item.review.path === "src/Feature.java").review; + const rollback = await host.call("review.rollback", { sessionId: session.id, snapshotId: feature.snapshotId }); + assert(rollback.status === "rolledBack", "parent session cannot roll back delegated snapshot"); + assert(!existsSync(join(workspace, "src/Feature.java")), "delegated add was not rolled back"); + const main = delegated.find((item) => item.review.path === "src/Main.java").review; + writeFileSync(join(workspace, "src/Main.java"), "later user edit\n"); + const conflict = await host.call("review.rollback", { sessionId: session.id, snapshotId: main.snapshotId }); + assert(conflict.status === "conflict", "delegated rollback failed to guard newer contents"); + assert(readFileSync(join(workspace, "src/Main.java"), "utf8") === "later user edit\n", "delegated rollback overwrote user contents"); + await host.restart(PROTOCOL_VERSION); + const reloaded = (await host.call("session.get", options)).session.messages; + assert(reviewChangesFromMessages(reloaded).find((entry) => entry.change.snapshotId === feature.snapshotId)?.change.state === "rolledBack", "delegated rollback state not persisted"); +} + +try { + await withScenario( + "e2e-shell-review", + scenario, + hostBinary, + suiteRoot, + PROTOCOL_VERSION, + ); + console.log("PASS e2e-shell-review"); +} catch (error) { + console.error("FAIL e2e-shell-review:", error?.stack || error); + process.exitCode = 1; +} finally { + await rm(suiteRoot, { + recursive: true, + force: true, + maxRetries: 8, + retryDelay: 100, + }); +} diff --git a/scripts/e2e-transcript-render.mjs b/scripts/e2e-transcript-render.mjs index 8d6a8c3ed9..14e47ae9ed 100644 --- a/scripts/e2e-transcript-render.mjs +++ b/scripts/e2e-transcript-render.mjs @@ -154,7 +154,7 @@ app.whenReady().then(async () => { stream.on("data", (data) => { output += data; }); - const timeout = setTimeout(() => child.kill("SIGKILL"), 45_000); + const timeout = setTimeout(() => child.kill("SIGKILL"), 90_000); let code; try { code = await new Promise((resolve, reject) => { diff --git a/scripts/e2e-transcript-scroll.mjs b/scripts/e2e-transcript-scroll.mjs new file mode 100644 index 0000000000..8a7e494057 --- /dev/null +++ b/scripts/e2e-transcript-scroll.mjs @@ -0,0 +1,341 @@ +#!/usr/bin/env node +/** Production React/CSS scroll regression in an isolated, offscreen Chromium. */ +import { execFile, spawn } from "node:child_process"; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { resolveElectronBinary } from "./e2e/boot.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const scratch = resolve(process.env.PI_SCRATCH_DIR || tmpdir()); +const marker = "SCROLL_PROBE "; +const wheelChannel = "transcript-scroll-probe:wheel"; +const timeoutMs = 90_000; +const execFileAsync = promisify(execFile); + +async function copyBuiltStyles(temp) { + const renderer = process.env.PI_SCROLL_RENDERER_DIR + ? resolve(process.env.PI_SCROLL_RENDERER_DIR) + : join(root, "apps/desktop/out/renderer"); + const htmlPath = join(renderer, "index.html"); + let html; + try { + html = await readFile(htmlPath, "utf8"); + } catch (error) { + throw new Error(`Missing built renderer: ${htmlPath}. Run pnpm build:js first.`, { cause: error }); + } + const links = [...html.matchAll(/]*>/gi)] + .map(([tag]) => ({ + rel: tag.match(/\brel\s*=\s*["']([^"']+)["']/i)?.[1], + href: tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1], + })) + .filter(({ rel }) => rel?.toLowerCase().split(/\s+/).includes("stylesheet")); + if (!links.length) { + throw new Error(`No built stylesheets linked by ${htmlPath}. Run pnpm build:js first.`); + } + const styles = []; + for (const { href } of links) { + if (!href || /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href)) { + throw new Error(`Expected a local built stylesheet in ${htmlPath}: ${href}`); + } + const asset = decodeURIComponent(href.split(/[?#]/)[0]).replace(/^\/+/, ""); + const source = resolve(renderer, asset); + const localPath = relative(renderer, source); + if (isAbsolute(localPath) || localPath.split(sep).includes("..") || !localPath.endsWith(".css")) { + throw new Error(`Stylesheet escapes the built renderer or is not CSS: ${href}`); + } + try { + const css = await readFile(source, "utf8"); + if (!css.trim()) throw new Error("Stylesheet is empty"); + } catch (error) { + throw new Error(`Missing or empty built stylesheet: ${source}. Run pnpm build:js first.`, { cause: error }); + } + styles.push({ source, localPath }); + } + // Keep CSS-relative images, fonts, and other assets alongside the real CSS. + try { + await cp(join(renderer, "assets"), join(temp, "assets"), { recursive: true }); + } catch (error) { + throw new Error(`Missing or unreadable built assets in ${renderer}. Run pnpm build:js first.`, { cause: error }); + } + return Promise.all(styles.map(async ({ source, localPath }) => { + const target = join(temp, localPath); + await mkdir(dirname(target), { recursive: true }); + await cp(source, target); + const href = "./" + localPath.split(sep).map(encodeURIComponent).join("/"); + return ``; + })); +} + +async function writeFixture(temp, styles) { + const require = createRequire(join(root, "packages/agent-runtime/package.json")); + const { build } = require("esbuild"); + await build({ + entryPoints: [join(root, "scripts/e2e/transcript-scroll.tsx")], + outfile: join(temp, "renderer.js"), + bundle: true, + platform: "browser", + format: "iife", + jsx: "automatic", + define: { "process.env.NODE_ENV": '"production"' }, + // Geometry comes from the linked current app build, not a CSS test shim. + loader: { ".css": "empty" }, + alias: { + "@pi-desktop/i18n": join(root, "packages/i18n/src/index.ts"), + react: join(root, "apps/desktop/node_modules/react"), + "react-dom": join(root, "apps/desktop/node_modules/react-dom"), + }, + nodePaths: [join(root, "apps/desktop/node_modules")], + }); + await writeFile(join(temp, "index.html"), ` + + + +Transcript scroll regression${styles.join("\n")} +
`); + // This is the only renderer bridge. No production preload, host, or API. + await writeFile(join(temp, "preload.cjs"), ` +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("scrollProbeDriver", { + wheel: async ({ x, y, deltaY }) => { + await ipcRenderer.invoke(${JSON.stringify(wheelChannel)}, { x, y, deltaY }); + }, +}); +`); + await writeFile(join(temp, "main.cjs"), String.raw` +const { app, BrowserWindow, ipcMain } = require("electron"); +const { mkdirSync } = require("node:fs"); +const path = require("node:path"); +const wheelChannel = ${JSON.stringify(wheelChannel)}; +app.setName("transcript-scroll-probe"); +app.setAppUserModelId("net.aiuo.pi-desktop.transcript-scroll-probe"); +app.setPath("temp", __dirname); +const dpr = Number(process.env.PI_SCROLL_PROBE_DPR || 1); +if (!Number.isFinite(dpr) || dpr < 0.5 || dpr > 4) throw new Error("Invalid probe DPR"); +const width = Number(process.env.PI_SCROLL_PROBE_WIDTH || 1100); +const height = Number(process.env.PI_SCROLL_PROBE_HEIGHT || 760); +if (![width, height].every(value => Number.isInteger(value) && value >= 320 && value <= 4096)) { + throw new Error("Invalid probe viewport dimensions"); +} +const scenario = process.env.PI_SCROLL_PROBE_SCENARIO || "all"; +if (!["all", "underfilled"].includes(scenario)) throw new Error("Invalid probe scenario"); +for (const name of ["userData", "sessionData", "logs", "crashDumps"]) { + const directory = path.join(__dirname, name); + mkdirSync(directory, { recursive: true }); + app.setPath(name, directory); +} +let window; +let viewport = null; +let finished = false; +function finish(result) { + if (finished) return; + const output = "SCROLL_PROBE " + JSON.stringify({ ...result, scenario, requestedDpr: dpr, viewport }) + "\n"; + finished = true; + ipcMain.removeHandler(wheelChannel); + if (window && !window.isDestroyed() && !window.webContents.isDestroyed() && window.webContents.debugger.isAttached()) { + window.webContents.debugger.detach(); + } + // Flush the result before Electron terminates its renderer process tree. + process.stdout.write(output, () => { + app.exit(result.ok === true ? 0 : 1); + }); +} +function fail(error) { + finish({ ok: false, error: error?.stack || String(error) }); +} +process.on("uncaughtException", fail); +process.on("unhandledRejection", fail); +app.whenReady().then(async () => { + window = new BrowserWindow({ + width, + height, + useContentSize: true, + show: false, + webPreferences: { + offscreen: true, + backgroundThrottling: false, + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + preload: path.join(__dirname, "preload.cjs"), + }, + }); + const contents = window.webContents; + contents.on("console-message", (event) => console.error("Renderer: " + event.message)); + contents.on("preload-error", (_event, _path, error) => fail(error)); + contents.on("render-process-gone", (_event, details) => fail(new Error("Renderer exited: " + JSON.stringify(details)))); + window.on("closed", () => { if (!finished) fail(new Error("Probe window closed before completion")); }); + contents.setWindowOpenHandler(() => ({ action: "deny" })); + contents.on("will-navigate", (event) => event.preventDefault()); + contents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); + contents.session.setPermissionCheckHandler(() => false); + contents.debugger.attach("1.3"); + ipcMain.handle(wheelChannel, async (event, input) => { + if (event.sender !== contents || event.senderFrame !== contents.mainFrame) { + throw new Error("Wheel input is only allowed from the probe's main frame"); + } + const { x, y, deltaY } = input || {}; + if (![x, y, deltaY].every(Number.isFinite) || x < 0 || x >= width || y < 0 || y >= height) { + throw new Error("Wheel input requires finite viewport x/y and deltaY"); + } + // CDP dispatches trusted input in the view's pre-zoom coordinates. Convert + // the fixture's CSS coordinates/delta without replacing native scrolling. + const inputScale = contents.getZoomFactor(); + await contents.debugger.sendCommand("Input.dispatchMouseEvent", { + type: "mouseWheel", x: x * inputScale, y: y * inputScale, + deltaX: 0, deltaY: deltaY * inputScale, + }); + }); + await window.loadFile(path.join(__dirname, "index.html")); + // Windows offscreen Electron can ignore force-device-scale-factor, while CDP + // device emulation can crash its render widget. Native page zoom changes the + // real raster scale; resize the backing window to keep the CSS viewport fixed. + const baseDpr = await contents.executeJavaScript("devicePixelRatio"); + if (!Number.isFinite(baseDpr) || baseDpr <= 0) throw new Error("Invalid native renderer DPR: " + baseDpr); + const zoom = dpr / baseDpr; + contents.setZoomFactor(zoom); + window.setContentSize(Math.round(width * zoom), Math.round(height * zoom)); + viewport = await contents.executeJavaScript( + "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve({" + + "dpr: devicePixelRatio, width: innerWidth, height: innerHeight" + + "}))))" + ); + console.error("Probe viewport: " + JSON.stringify(viewport)); + if (!Number.isFinite(viewport.dpr) || Math.abs(viewport.dpr - dpr) > 0.001 || + viewport.width !== width || viewport.height !== height) { + throw new Error("Renderer scale/viewport mismatch: " + JSON.stringify(viewport)); + } + const result = await contents.executeJavaScript( + "(async () => {" + + "const links = Array.from(document.querySelectorAll('link[rel=stylesheet]'));" + + "if (!links.length || links.some(link => !link.sheet)) throw new Error('Built app CSS failed to load');" + + "if (typeof globalThis.transcriptScrollProbe !== 'function') throw new Error('Fixture must expose globalThis.transcriptScrollProbe()');" + + "await document.fonts.ready;" + + "return globalThis.transcriptScrollProbe(" + JSON.stringify(scenario) + ");" + + "})()" + ); + if (!result || typeof result.ok !== "boolean") { + throw new Error("transcriptScrollProbe() must return an object with a boolean ok"); + } + if (Math.abs(result.dpr - dpr) > 0.001 || !Number.isFinite(result.dpr)) { + throw new Error("Renderer DPR does not match requested scale: " + result.dpr + " != " + dpr); + } + finish(result); +}).catch(fail); +`); +} + +async function killChildTree(child) { + if (!child.pid) return; + if (process.platform === "win32") { + // Never kill by image name: another Desktop or Electron may be running. + await execFileAsync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { + windowsHide: true, + timeout: 10_000, + }); + } else { + try { + // The detached child owns this process group, not the caller's group. + process.kill(-child.pid, "SIGKILL"); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + } +} + +async function runElectron(electronBinary, temp) { + const env = { ...process.env, PI_SCRATCH_DIR: scratch, TMP: temp, TEMP: temp, TMPDIR: temp }; + delete env.ELECTRON_RUN_AS_NODE; + const child = spawn(electronBinary, [join(temp, "main.cjs")], { + cwd: temp, + env, + detached: process.platform !== "win32", + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (data) => { stdout = (stdout + data).slice(-4 * 1024 * 1024); }); + child.stderr.on("data", (data) => { stderr = (stderr + data).slice(-64 * 1024); }); + const closed = new Promise((resolve) => { + child.once("error", (error) => resolve({ error: String(error) })); + child.once("close", (code, signal) => resolve({ code, signal })); + }); + let timer; + const outcome = await Promise.race([ + closed, + new Promise((resolve) => { timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs); }), + ]); + clearTimeout(timer); + if (outcome.timedOut) { + try { + await killChildTree(child); + } catch (error) { + stderr += "\nCould not terminate the probe process tree: " + String(error); + child.kill("SIGKILL"); + } + // Even a failed tree kill must not leave the runner waiting on inherited pipes. + let closeTimer; + const exit = await Promise.race([ + closed, + new Promise((resolve) => { closeTimer = setTimeout(() => resolve({}), 5_000); }), + ]); + clearTimeout(closeTimer); + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); + return { ...exit, timedOut: true, stdout, stderr }; + } + return { ...outcome, stdout, stderr }; +} + +let temp; +let execution; +let result = { ok: false }; +try { + await mkdir(scratch, { recursive: true }); + temp = await mkdtemp(join(scratch, "transcript-scroll-")); + const styles = await copyBuiltStyles(temp); + await writeFixture(temp, styles); + const { electronBinary } = resolveElectronBinary(root); + execution = await runElectron(electronBinary, temp); + const lines = execution.stdout.split(/\r?\n/).filter((line) => line.startsWith(marker)); + if (lines.length === 1) { + const parsed = JSON.parse(lines[0].slice(marker.length)); + if (!parsed || typeof parsed.ok !== "boolean") throw new Error("Invalid SCROLL_PROBE result"); + result = parsed; + } + if (execution.timedOut) throw new Error(`Transcript scroll probe timed out after ${timeoutMs}ms`); + if (execution.error) throw new Error(`Electron launch failed: ${execution.error}`); + if (lines.length !== 1) throw new Error(`Expected one SCROLL_PROBE result, got ${lines.length} (exit=${execution.code})`); + if (execution.code !== 0) throw new Error(`Electron probe failed (exit=${execution.code}, signal=${execution.signal})`); + if (result.ok !== true) throw new Error("Transcript scroll regression failed; see probe result"); +} catch (error) { + result = { ...result, ok: false, runnerError: error?.stack || String(error) }; + process.exitCode = 1; + console.error(result.runnerError); + if (execution?.stderr) console.error("Electron stderr:\n" + execution.stderr); + if (execution?.stdout) console.error("Electron stdout:\n" + execution.stdout); +} finally { + console.log(marker + JSON.stringify(result)); + const log = join(scratch, (temp ? basename(temp) : `transcript-scroll-${Date.now()}-${process.pid}`) + ".json"); + try { + await writeFile(log, JSON.stringify({ ...result, runner: execution }, null, 2) + "\n"); + console.error("Transcript scroll result: " + log); + } catch (error) { + console.error("Could not retain transcript scroll result: " + String(error)); + } + if (temp) { + try { + await rm(temp, { recursive: true, force: true, maxRetries: 6, retryDelay: 150 }); + } catch (error) { + // Windows may hold profile files briefly; never replace the probe failure. + console.error(`Could not clean probe scratch directory ${temp}: ${error}`); + } + } +} diff --git a/scripts/e2e/transcript-disclosure-anchor.tsx b/scripts/e2e/transcript-disclosure-anchor.tsx index f57f15ce78..fddb3a9860 100644 --- a/scripts/e2e/transcript-disclosure-anchor.tsx +++ b/scripts/e2e/transcript-disclosure-anchor.tsx @@ -118,7 +118,7 @@ function TranscriptFixture({ messages, process = false }: { messages: UiMessage[
{entry?.kind === "assistant-turn" - ? + ? : }
@@ -276,10 +276,16 @@ globalThis.transcriptDisclosureProbe = async () => { const processTitle = processContainer.querySelector(".turn-process > button"); assert(processScroller && processTitle, "process fixture did not render"); await settle(); + assert( + processTitle.getAttribute("aria-expanded") === "false", + "detailed completed process starts collapsed", + ); + processTitle.click(); + await settle(); const processBefore = geometry(processScroller, processTitle); assert( processTitle.getAttribute("aria-expanded") === "true", - "detailed should start the process open", + "detailed process can be opened", ); processTitle.click(); await settle(); diff --git a/scripts/e2e/transcript-disclosure-page.tsx b/scripts/e2e/transcript-disclosure-page.tsx new file mode 100644 index 0000000000..16c97a1932 --- /dev/null +++ b/scripts/e2e/transcript-disclosure-page.tsx @@ -0,0 +1,87 @@ +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { I18nextProvider } from "react-i18next"; +import type { i18n } from "i18next"; +import type { UiMessage } from "@pi-desktop/shared"; +import { ChatTranscript } from "../../apps/desktop/src/features/chat/transcript/ChatTranscript"; +import { TranscriptDisclosureProvider } from "../../apps/desktop/src/features/chat/transcript/disclosure"; + +const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); +async function frames(count: number) { for (let i = 0; i < count; i++) await frame(); } + +/** A disclosure may borrow reading ownership, but it cannot cancel the store read. */ +export async function verifyDisclosureDuringPage(i18n: i18n, recent: UiMessage[], older: UiMessage[]) { + const failures: string[] = []; + const reports: unknown[] = []; + let trustedWheels = 0; + const recordWheel = (event: WheelEvent) => { if (event.isTrusted) trustedWheels++; }; + for (const keepScrolling of [false, true]) { + const host = document.createElement("div"); + host.style.cssText = "position:absolute;inset:0;display:flex;flex-direction:column;min-height:0;--composer-dock-height:180px"; + document.body.append(host); + host.addEventListener("wheel", recordWheel, { passive: true, capture: true }); + const root = createRoot(host); + let messages = recent; + let hasMoreBefore = true; + let finishPage: (() => void) | undefined; + let pageCalls = 0; + const loadOlder = () => { + pageCalls++; + if (finishPage) return Promise.resolve(); + return new Promise((resolve) => { finishPage = resolve; }); + }; + const render = () => flushSync(() => root.render( + + + , + )); + try { + render(); + await frames(16); + const scroller = host.querySelector(".thread-scroll")!; + const wheel = async (deltaY: number) => { + const rect = scroller.getBoundingClientRect(); + await globalThis.scrollProbeDriver.wheel({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, deltaY }); + }; + await wheel(200 - scroller.scrollTop); + await frames(12); + await wheel(-120); + await frames(14); + if (!finishPage) throw new Error("disclosure-page read did not start"); + const viewport = scroller.getBoundingClientRect(); + const title = [...host.querySelectorAll(".turn-process > .tool-activity-header")].find((element) => { + const rect = element.getBoundingClientRect(); + return rect.top > viewport.top && rect.bottom < viewport.bottom; + }); + if (!title) throw new Error("no visible process title for pending page"); + flushSync(() => title.click()); + await frames(16); + if (title.getAttribute("aria-expanded") !== "true") throw new Error("pending page process did not expand"); + if (keepScrolling) { await wheel(-24); await frames(12); } + const target = keepScrolling + ? [...host.querySelectorAll("[data-message-id]")].find((element) => { + const rect = element.getBoundingClientRect(); + return rect.bottom > viewport.top + 1 && rect.top < viewport.bottom; + }) + : title; + if (!target) throw new Error("pending page reading anchor missing"); + const before = target.getBoundingClientRect().top; + messages = [...older, ...recent]; + hasMoreBefore = false; + render(); + finishPage(); + const tops = [target.getBoundingClientRect().top]; + for (let i = 0; i < 16; i++) { await frame(); tops.push(target.getBoundingClientRect().top); } + const shift = Math.max(...tops.map((top) => Math.abs(top - before))); + if (pageCalls !== 1) failures.push(`disclosure restarted ${pageCalls} pending page reads`); + reports.push({ keepScrolling, pageCalls, before, tops, shift }); + if (!target.isConnected || shift > 2) failures.push(`page after disclosure (continued scroll=${keepScrolling}) shifted ${shift}px`); + } finally { + flushSync(() => root.unmount()); + host.removeEventListener("wheel", recordWheel, true); + host.remove(); + } + } + return { name: "disclosure-during-page", failures, reports, trustedWheels }; +} diff --git a/scripts/e2e/transcript-entry.tsx b/scripts/e2e/transcript-entry.tsx new file mode 100644 index 0000000000..f66b2e5c56 --- /dev/null +++ b/scripts/e2e/transcript-entry.tsx @@ -0,0 +1,86 @@ +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import type { i18n } from "i18next"; +import { I18nextProvider } from "react-i18next"; +import type { UiMessage } from "@pi-desktop/shared"; +import { Composer } from "../../apps/desktop/src/components/Composer"; +import { SessionPane } from "../../apps/desktop/src/components/SessionPane"; +import { useAppStore } from "../../apps/desktop/src/stores/app-store"; +import { writeComposerDraft, deleteComposerDraft } from "../../apps/desktop/src/lib/composer-draft-cache"; + +const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + +/** Real retained panes and docked composer, with no host or provider calls. */ +export async function verifyShortSessionEntry(i18n: i18n, messages: UiMessage[]) { + const original = useAppStore.getState(); + const ids = ["scroll-entry-long-draft", "scroll-entry-empty-draft"]; + const host = document.createElement("div"); + host.style.cssText = "position:absolute;inset:0;display:flex;flex-direction:column;min-height:0"; + document.body.append(host); + const originalReserve = document.documentElement.style.getPropertyValue("--composer-dock-height"); + const errors: unknown[] = []; + const root = createRoot(host, { onUncaughtError: (error) => errors.push(String(error)) }); + let active = ids[0]; + let retained = [active]; + const render = () => flushSync(() => root.render( + +
+
+ {retained.map((id) => )} +
+ +
+
, + )); + const sample = () => { + const pane = host.querySelector(`[data-session-pane="${active}"]`)!; + const scroll = pane?.querySelector(".thread-scroll"); + const tail = [...pane?.querySelectorAll(".thread-content > .message-row") ?? []].at(-1); + if (!scroll || !tail) throw new Error(`entry pane missing: ${errors.join(", ")}`); + const dock = host.querySelector(".composer-dock")!; + return { top: scroll.scrollTop, max: scroll.scrollHeight - scroll.clientHeight, + tailTop: tail.getBoundingClientRect().top, dock: dock.getBoundingClientRect().height, + reserve: document.documentElement.style.getPropertyValue("--composer-dock-height") }; + }; + const reports: unknown[] = []; + const failures: string[] = []; + try { + writeComposerDraft(ids[0], { text: Array.from({ length: 9 }, (_, i) => `Draft line ${i}`).join("\n"), fileReferences: [] }); + writeComposerDraft(ids[1], { text: "", fileReferences: [] }); + useAppStore.setState({ + activeSessionId: active, messages, retainedSessionIds: retained, + retainedTranscripts: { [ids[0]]: messages, [ids[1]]: messages }, + sessions: ids.map((id) => ({ id, title: id, messageCount: messages.length, + mode: "agent", permissionMode: "ask", thinkingLevel: "off", + createdAt: "2026-09-22T00:00:00Z", updatedAt: "2026-09-22T00:00:00Z" })), + }); + render(); + for (let i = 0; i < 16; i++) await frame(); + for (const next of [ids[1], ids[0], ids[1]]) { + active = next; + retained = [next, ...retained.filter((id) => id !== next)]; + flushSync(() => useAppStore.setState({ activeSessionId: next, messages, retainedSessionIds: retained })); + render(); + const samples = [sample()]; + const reserve = Number.parseFloat(samples[0].reserve); + if (!Number.isFinite(reserve) || Math.abs(reserve - Math.round(samples[0].dock)) > 1) { + failures.push(`short pane ${next}: first commit reserved ${samples[0].reserve} for a ${samples[0].dock}px composer`); + } + for (let i = 0; i < 16; i++) { await frame(); samples.push(sample()); } + const visible = samples.slice(1); + const range = Math.max(...visible.map((s) => s.tailTop)) - Math.min(...visible.map((s) => s.tailTop)); + const bottomMiss = Math.max(...visible.map((s) => Math.abs(s.max - s.top))); + if (range > 2 || bottomMiss > 2) failures.push(`short pane ${next}: visible movement ${range}px, bottom miss ${bottomMiss}px`); + reports.push({ next, range, bottomMiss, samples }); + } + if (errors.length) failures.push(`short entry render errors: ${errors.join(", ")}`); + return { name: "short-session-with-composer", failures, reports }; + } finally { + flushSync(() => root.unmount()); + host.remove(); + ids.forEach(deleteComposerDraft); + if (originalReserve) document.documentElement.style.setProperty("--composer-dock-height", originalReserve); + else document.documentElement.style.removeProperty("--composer-dock-height"); + useAppStore.setState(original, true); + } +} diff --git a/scripts/e2e/transcript-navigation.tsx b/scripts/e2e/transcript-navigation.tsx new file mode 100644 index 0000000000..15d571ca94 --- /dev/null +++ b/scripts/e2e/transcript-navigation.tsx @@ -0,0 +1,124 @@ +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { I18nextProvider } from "react-i18next"; +import type { i18n } from "i18next"; +import type { SessionSummary, UiMessage } from "@pi-desktop/shared"; +import { SessionPane } from "../../apps/desktop/src/components/SessionPane"; +import { useAppStore } from "../../apps/desktop/src/stores/app-store"; +import { createTranscriptReadingRuntime } from "../../apps/desktop/src/stores/runtime/transcript-reading-runtime"; + +const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); +async function frames(count: number) { for (let i = 0; i < count; i++) await frame(); } + +/** Navigation must position the selected projection, not a stale reading window. */ +export async function verifyTranscriptNavigation(i18n: i18n, short: UiMessage[], long: UiMessage[], historical: UiMessage[]) { + const original = useAppStore.getState(); + const failures: string[] = []; + const reports: unknown[] = []; + const host = document.createElement("div"); + host.style.cssText = "position:absolute;inset:0;display:flex;flex-direction:column;min-height:0;--composer-dock-height:180px"; + document.body.append(host); + const root = createRoot(host); + const id = "scroll-navigation-probe"; + let trustedWheels = 0; + const onWheel = (event: WheelEvent) => { if (event.isTrusted) trustedWheels++; }; + host.addEventListener("wheel", onWheel, { passive: true, capture: true }); + const summary: SessionSummary = { id, title: id, messageCount: long.length, + mode: "agent", permissionMode: "ask", thinkingLevel: "off", + createdAt: "2026-09-22T00:00:00Z", updatedAt: "2026-09-22T00:00:00Z" }; + const runtime = createTranscriptReadingRuntime({ get: useAppStore.getState, set: useAppStore.setState }, async () => ({ + session: { ...summary, messages: historical, messageStart: 0, messageEnd: historical.length, + hasMoreBefore: false, hasMoreAfter: true }, + })); + let instance = 0; + let visible = true; + const render = () => flushSync(() => root.render( + , + )); + const scroller = () => host.querySelector(".thread-scroll")!; + const sample = (latestId: string) => { + const scroll = scroller(); + return { top: scroll.scrollTop, max: Math.max(0, scroll.scrollHeight - scroll.clientHeight), + latestMounted: Boolean(host.querySelector(`[data-message-id="${CSS.escape(latestId)}"]`)), + veil: Boolean(host.querySelector(".transcript-settle-veil")) }; + }; + const record = async (name: string, messages: UiMessage[]) => { + const latestId = messages.at(-1)!.id; + const samples = [sample(latestId)]; + for (let i = 0; i < 20; i++) { await frame(); samples.push(sample(latestId)); } + const miss = Math.max(...samples.map((item) => Math.abs(item.max - item.top))); + if (miss > 2) failures.push(`${name}: first-commit/frame bottom miss ${miss}px`); + if (samples.some((item) => !item.latestMounted)) failures.push(`${name}: stale snapshot shown before selected latest message`); + reports.push({ name, miss, samples }); + }; + try { + useAppStore.setState({ ...runtime.actions, activeSessionId: id, messages: [], + sessions: [summary], retainedSessionIds: [id], retainedTranscripts: {}, + sessionHistory: {}, transcriptViews: {}, runningSessions: {}, isRunning: false }); + for (const messages of [short, long]) { + instance++; + flushSync(() => useAppStore.setState({ messages: [], transcriptViews: {} })); + render(); + await frames(4); + flushSync(() => useAppStore.setState({ messages })); + await record(`cold-entry-${messages.length}`, messages); + } + // A cached completed tail can be replaced by its fuller revalidated snapshot. + instance++; + flushSync(() => useAppStore.setState({ messages: short, transcriptViews: {} })); + render(); + await frames(20); + flushSync(() => useAppStore.setState({ messages: long })); + await record("idle-revalidation", long); + for (const running of [false, true]) { + for (const live of [long, short]) { + flushSync(() => useAppStore.setState({ messages: live, runningSessions: { [id]: running }, isRunning: running })); + await frames(8); + await runtime.actions.navigateTranscript({ sessionId: id, messageId: historical[0].id, query: "" }); + await frames(20); + if (!useAppStore.getState().transcriptViews[id]?.focus) throw new Error("navigation fixture did not enter a historical reading window"); + const jump = host.querySelector(".jump-latest-btn"); + if (!jump) throw new Error("historical reading window has no return-to-latest control"); + flushSync(() => jump.click()); + if (useAppStore.getState().transcriptViews[id]) failures.push("return-to-latest did not release historical view"); + await record(`history-to-latest-${running ? "running" : "idle"}-${live.length}`, live); + } + } + + flushSync(() => useAppStore.setState({ runningSessions: {}, isRunning: false, + sessionHistory: { [id]: { messageStart: historical.length, hasMoreBefore: true } } })); + const wheel = async (deltaY: number) => { + const rect = scroller().getBoundingClientRect(); + await globalThis.scrollProbeDriver.wheel({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, deltaY }); + }; + await wheel(200 - scroller().scrollTop); + await frames(10); + await wheel(-120); + await frames(16); + const paged = useAppStore.getState().transcriptViews[id]; + if (!paged || paged.focus || paged.loading || paged.messages[0]?.id !== historical[0].id) { + throw new Error("ordinary native history paging did not complete"); + } + const pageJump = host.querySelector(".jump-latest-btn"); + if (!pageJump) throw new Error("paged transcript has no return-to-latest control"); + flushSync(() => pageJump.click()); + await record("paged-to-latest", short); + + // Updating a retained pinned pane and revealing it in the same commit must + // not paint the old deferred frame or a clamped middle offset. + visible = false; + render(); + await frames(3); + flushSync(() => useAppStore.setState({ messages: long })); + visible = true; + render(); + await record("retained-latest-reveal", long); + if (trustedWheels !== 2) failures.push(`navigation fixture received ${trustedWheels} native wheels instead of 2`); + return { name: "transcript-navigation", failures, reports, trustedWheels }; + } finally { + host.removeEventListener("wheel", onWheel, true); + flushSync(() => root.unmount()); + host.remove(); + useAppStore.setState(original, true); + } +} diff --git a/scripts/e2e/transcript-render.tsx b/scripts/e2e/transcript-render.tsx index cbbb9d886d..190beabd7d 100644 --- a/scripts/e2e/transcript-render.tsx +++ b/scripts/e2e/transcript-render.tsx @@ -1,3 +1,4 @@ +import { runTurnFileSummaryProbe } from "./turn-file-summary"; import { turnProcessProbe } from "./turn-process"; import { transcriptStatusProbe } from "./transcript-status"; import { createRoot } from "react-dom/client"; @@ -8,6 +9,7 @@ import { en } from "@pi-desktop/i18n"; import type { AgentActivity, UiMessage } from "@pi-desktop/shared"; import { AssistantTurn } from "../../apps/desktop/src/features/chat/transcript/AssistantTurn"; import { ChatTranscript } from "../../apps/desktop/src/features/chat/transcript/ChatTranscript"; +import { TranscriptDisclosureProvider } from "../../apps/desktop/src/features/chat/transcript/disclosure"; import { buildTranscriptEntries } from "../../apps/desktop/src/lib/assistant-turns"; import { useAppStore } from "../../apps/desktop/src/stores/app-store"; @@ -53,7 +55,7 @@ globalThis.transcriptRenderProbe = async () => { flushSync(() => root.render( - + , ), ); @@ -230,6 +232,7 @@ globalThis.transcriptRenderProbe = async () => { taskLifecycleUpdated: true, taskTimingUpdated: true, turnProcess: await turnProcessProbe(), + turnFiles: await runTurnFileSummaryProbe(), textUpdateDurationMs, }; } finally { @@ -272,24 +275,21 @@ globalThis.transcriptRuntimeSlotProbe = async () => { if (!passed) failures.push(message); }; const sessionId = "runtime-slot"; - const messages: UiMessage[] = [message("user", "user", "Inspect the workspace")]; + const messages: UiMessage[] = []; for (let index = 0; index < 8; index++) { messages.push( - message(`tool-${index}`, "tool", "done", { - toolName: "Bash", - toolCallId: `call-${index}`, - toolStatus: "success", - toolArgs: { command: `printf step-${index}` }, - toolResult: { details: { stdout: "done", exitCode: 0 } }, - }), - ); - messages.push( - message(`answer-${index}`, "assistant", `Finished step ${index}.`), + message(`user-${index}`, "user", `Inspect step ${index}`), + message( + `answer-${index}`, + "assistant", + `Finished step ${index}. This completed answer stays outside any live process.`, + ), ); } // A completed tool row does not finish the turn: the fallback remains until // the runtime reports the next phase or the turn reaches a terminal state. messages.push( + message("user-tail", "user", "Continue"), message("tool-tail", "tool", "done", { toolName: "Bash", toolCallId: "call-tail", @@ -369,11 +369,13 @@ globalThis.transcriptRuntimeSlotProbe = async () => { const paint = () => root.render( - + + + , ); const update = (activity: AgentActivity | undefined) => diff --git a/scripts/e2e/transcript-retained-page.tsx b/scripts/e2e/transcript-retained-page.tsx new file mode 100644 index 0000000000..80b81d1004 --- /dev/null +++ b/scripts/e2e/transcript-retained-page.tsx @@ -0,0 +1,234 @@ +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import type { i18n } from "i18next"; +import { I18nextProvider } from "react-i18next"; +import type { SessionSummary, UiMessage } from "@pi-desktop/shared"; +import { SessionPane } from "../../apps/desktop/src/components/SessionPane"; +import { useAppStore } from "../../apps/desktop/src/stores/app-store"; +import { createTranscriptReadingRuntime } from "../../apps/desktop/src/stores/runtime/transcript-reading-runtime"; + +type ReadSession = Parameters[1]; +type ReadResult = Awaited>; +const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); +async function frames(count: number) { for (let i = 0; i < count; i++) await frame(); } + +function RetainedPanes() { + const active = useAppStore((state) => state.activeSessionId); + const retained = useAppStore((state) => state.retainedSessionIds); + return
+
+ {retained.map((id) => )} +
+
; +} + +/** Only the external session read is gated; paging and view publication are production code. */ +export async function verifyRetainedPage( + i18n: i18n, + recent: UiMessage[], + older: UiMessage[], + tail: UiMessage[], + resolveWhileHidden: boolean, +) { + const original = useAppStore.getState(); + const id = `scroll-retained-page-${resolveWhileHidden ? "hidden" : "visible"}`; + const otherId = `${id}-other`; + const otherMessages: UiMessage[] = [{ + id: `${otherId}-message`, role: "user", content: "Another retained conversation", + createdAt: "2026-09-22T00:00:00Z", + }]; + const summaries: SessionSummary[] = [id, otherId].map((sessionId) => ({ + id: sessionId, title: sessionId, messageCount: sessionId === id ? recent.length + older.length * 2 : 1, + mode: "agent", permissionMode: "ask", thinkingLevel: "off", + createdAt: "2026-09-22T00:00:00Z", updatedAt: "2026-09-22T00:00:00Z", + })); + const host = document.createElement("div"); + host.style.cssText = "position:absolute;inset:0;display:flex;flex-direction:column;min-height:0;--composer-dock-height:180px"; + document.body.append(host); + const errors: string[] = []; + const failures: string[] = []; + const root = createRoot(host, { onUncaughtError: (error) => errors.push(String(error)) }); + const reads: { + sessionId: string; options: Parameters[1]; hidden: boolean; + finish: (result: ReadResult) => void; promise: Promise; + }[] = []; + const runtime = createTranscriptReadingRuntime( + { get: useAppStore.getState, set: useAppStore.setState }, + (sessionId, options) => { + let finish!: (result: ReadResult) => void; + const promise = new Promise((resolve) => { finish = resolve; }); + reads.push({ sessionId, options, hidden: useAppStore.getState().activeSessionId !== sessionId, finish, promise }); + return promise; + }, + ); + // Keep the store's production reconciliation subscription, replacing only + // its runtime's external read dependency rather than mocking loadTranscriptPage. + let trustedWheels = 0; + const onWheel = (event: WheelEvent) => { if (event.isTrusted) trustedWheels++; }; + host.addEventListener("wheel", onWheel, { passive: true, capture: true }); + const pane = () => { + const element = host.querySelector(`[data-session-pane="${id}"]`); + if (!element) throw new Error(`retained pane missing: ${errors.join(", ")}`); + return element; + }; + const scroller = () => { + const element = pane().querySelector(".thread-scroll"); + if (!element) throw new Error("retained pane scroller missing"); + return element; + }; + const wheel = async (deltaY: number) => { + const rect = scroller().getBoundingClientRect(); + await globalThis.scrollProbeDriver.wheel({ + x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2), deltaY, + }); + }; + const select = (sessionId: string) => flushSync(() => useAppStore.setState({ + activeSessionId: sessionId, messages: sessionId === id ? recent : otherMessages, + })); + try { + useAppStore.setState({ + ...runtime.actions, + activeSessionId: id, messages: recent, sessions: summaries, + retainedSessionIds: [id, otherId], retainedTranscripts: { [id]: recent, [otherId]: otherMessages }, + sessionHistory: { [id]: { messageStart: older.length * 2, hasMoreBefore: true } }, + transcriptViews: {}, runningSessions: {}, isRunning: false, + }); + flushSync(() => root.render()); + let previous = ""; + let stable = 0; + for (let attempt = 0; attempt < 180; attempt++) { + await frame(); + const scroll = scroller(); + const current = `${scroll.scrollTop}:${scroll.scrollHeight}:${scroll.clientHeight}`; + stable = current === previous && !pane().querySelector(".transcript-settle-veil") ? stable + 1 : 0; + if (stable >= 4) break; + previous = current; + } + if (stable < 4) throw new Error("retained pane never settled"); + if (reads.length) failures.push("retained pane paged before native history movement"); + await wheel(200 - scroller().scrollTop); + await frames(12); + if (Math.abs(scroller().scrollTop - 200) > 2) throw new Error("retained pane did not reach the history approach position"); + await wheel(-120); + await frames(14); + if (!reads[0]) throw new Error("retained pane did not request its older page"); + const pendingTop = scroller().scrollTop; + await wheel(-24); + await frames(12); + const furtherMovement = pendingTop - scroller().scrollTop; + if (Math.abs(furtherMovement - 24) > 2) failures.push(`pending page lost native movement (${furtherMovement}px)`); + if (useAppStore.getState().transcriptViews[id]?.loading !== "before") failures.push("older read was not pending during native movement"); + + const retainedPane = pane(); + const retainedScroller = scroller(); + const viewport = retainedScroller.getBoundingClientRect(); + const readingMessage = [...retainedPane.querySelectorAll("[data-message-id]")].find((node) => { + const rect = node.getBoundingClientRect(); + return node.closest("[data-scroll-owner]") === retainedScroller && + rect.bottom > viewport.top + 1 && rect.top < viewport.bottom - 1; + }); + const messageId = readingMessage?.getAttribute("data-message-id"); + if (!readingMessage || !messageId) throw new Error("retained pane has no visible message identity"); + const before = readingMessage.getBoundingClientRect().top - viewport.top; + let phase = "before-hide"; + const sample = () => { + const visible = pane().dataset.visible === "true"; + // Never force layout inside content-visibility:hidden to measure an anchor. + const scroll = visible ? scroller() : null; + const row = visible ? pane().querySelector(`[data-message-id="${CSS.escape(messageId)}"]`) : null; + if (visible && (!row || !scroll)) throw new Error(`reading message ${messageId} disappeared during ${phase}`); + return { + phase, visible, messageId, reads: reads.length, + offset: row && scroll ? row.getBoundingClientRect().top - scroll.getBoundingClientRect().top : null, + top: scroll?.scrollTop ?? null, + max: scroll ? scroll.scrollHeight - scroll.clientHeight : null, + jump: visible && Boolean(pane().querySelector(".jump-latest-btn")), + loading: useAppStore.getState().transcriptViews[id]?.loading ?? null, + }; + }; + const samples = [sample()]; + const sampleFrames = async (count: number) => { + for (let i = 0; i < count; i++) { await frame(); samples.push(sample()); } + }; + const publish = () => reads[0].finish({ session: { + ...summaries[0], messages: older, messageStart: older.length, messageEnd: older.length * 2, + // Leave an earlier edge available so "no hidden paging" is not vacuous. + hasMoreBefore: true, hasMoreAfter: true, + } }); + phase = "hidden-pending"; + select(otherId); + if (pane() !== retainedPane || pane().getAttribute("aria-hidden") !== "true" || !pane().inert) { + failures.push("switch did not retain and hide the original pane"); + } + samples.push(sample()); + await sampleFrames(6); + if (resolveWhileHidden) { + phase = "hidden-published"; + publish(); + await sampleFrames(8); + if (useAppStore.getState().transcriptViews[id]?.messages[0]?.id !== older[0].id) { + failures.push("hidden page did not publish through the reading runtime"); + } + } + phase = "reveal-commit"; + select(id); + samples.push(sample()); // The synchronous reveal commit, before waiting for a frame. + if (pane() !== retainedPane || scroller() !== retainedScroller) failures.push("reveal remounted the retained pane"); + phase = "reveal-frames"; + await sampleFrames(6); + if (!resolveWhileHidden) { + phase = "visible-published"; + publish(); + samples.push(sample()); + } + await sampleFrames(16); + const view = useAppStore.getState().transcriptViews[id]; + const expectedIds = [...older, ...recent].map((message) => message.id); + if (JSON.stringify(view?.messages.map((message) => message.id)) !== JSON.stringify(expectedIds) || view?.loading) { + failures.push("older page was not published exactly once into the production reading view"); + } + if (!pane().querySelector(`[data-message-id="${CSS.escape(older[0].id)}"]`)) failures.push("published older page never reached the pane DOM"); + if (useAppStore.getState().messages !== recent) failures.push("paging replaced the canonical live transcript"); + + // Content growth after reveal must still leave follow released. + const beforeGrowth = scroller().scrollHeight; + phase = "tail-growth-commit"; + flushSync(() => useAppStore.setState({ messages: [...recent, ...tail] })); + samples.push(sample()); + phase = "tail-growth-frames"; + await sampleFrames(16); + if (scroller().scrollHeight <= beforeGrowth) failures.push("follow check did not grow the live tail"); + const visibleSamples = samples.filter((entry) => entry.visible); + const shift = Math.max(...visibleSamples.map((entry) => Math.abs(entry.offset! - before))); + if (shift > 2) failures.push(`retained pending page (hidden completion=${resolveWhileHidden}) shifted ${messageId} by ${shift}px`); + if (visibleSamples.some((entry) => !entry.jump || entry.max! - entry.top! <= 48)) failures.push("retained paging resumed follow without user input"); + if (reads.length !== 1) failures.push(`retained pending page issued ${reads.length} external reads`); + if (reads.some((read) => read.hidden)) failures.push("a hidden retained pane requested history"); + const request = reads[0]; + if (request.sessionId !== id || request.options.messageBefore !== older.length * 2 || + request.options.messageLimit !== 100 || request.options.contentLimit !== 65_536) { + failures.push("older page did not use the production bounded read contract"); + } + if (trustedWheels !== 3) failures.push(`retained fixture received ${trustedWheels} trusted wheels instead of 3`); + if (errors.length) failures.push(`retained pane render errors: ${errors.join(", ")}`); + return { + name: "page-across-pane-reveal", resolveWhileHidden, failures, trustedWheels, + reads: reads.map(({ sessionId, options, hidden }) => ({ sessionId, options, hidden })), + furtherMovement, before, shift, samples, + }; + } finally { + try { + flushSync(() => root.unmount()); + } finally { + // Invalidate ownership before releasing even unexpected duplicate reads. + // Drain their continuations before restoring the original store/actions. + for (const sessionId of [id, otherId]) runtime.actions.returnToLatestTranscript(sessionId); + for (const read of reads) read.finish({ session: null }); + await Promise.all(reads.map((read) => read.promise)); + await Promise.resolve(); + host.removeEventListener("wheel", onWheel, true); + host.remove(); + useAppStore.setState(original, true); + } + } +} diff --git a/scripts/e2e/transcript-scroll.tsx b/scripts/e2e/transcript-scroll.tsx new file mode 100644 index 0000000000..cb4c21d836 --- /dev/null +++ b/scripts/e2e/transcript-scroll.tsx @@ -0,0 +1,347 @@ +import { createRoot, type Root } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { createInstance } from "i18next"; +import { I18nextProvider } from "react-i18next"; +import { en } from "@pi-desktop/i18n"; +import type { UiMessage } from "@pi-desktop/shared"; +import { ChatTranscript } from "../../apps/desktop/src/features/chat/transcript/ChatTranscript"; +import { TranscriptDisclosureProvider } from "../../apps/desktop/src/features/chat/transcript/disclosure"; +import { useAppStore } from "../../apps/desktop/src/stores/app-store"; +import { verifyShortSessionEntry } from "./transcript-entry"; +import { verifyRetainedPage } from "./transcript-retained-page"; +import { verifyDisclosureDuringPage } from "./transcript-disclosure-page"; +import { verifyTranscriptNavigation } from "./transcript-navigation"; +import { verifyUnderfilledEntry } from "./transcript-underfilled-entry"; + +// The preload dispatches Chromium input; DOM WheelEvent does not actually scroll. +declare global { + var scrollProbeDriver: { wheel(input: { x: number; y: number; deltaY: number }): Promise }; + var transcriptScrollProbe: (scenario?: "all" | "underfilled") => Promise; +} + +const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); +const createdAt = "2026-09-22T00:00:00.000Z"; +function history(start: number, count: number, paragraphs = 1): UiMessage[] { + return Array.from({ length: count }, (_, offset) => { + const index = start + offset; + return [ + { id: `user-${index}`, role: "user", content: `Request ${index}`, createdAt }, + { id: `thinking-${index}`, role: "assistant", content: "", thinking: "Reasoning before the tool. ".repeat(80), createdAt }, + { id: `tool-${index}`, role: "tool", content: "done", createdAt, + toolName: "Bash", toolCallId: `call-${index}`, toolStatus: "success", + toolArgs: { command: "echo done" }, toolResult: { details: { stdout: "done", exitCode: 0 } } }, + { id: `answer-${index}`, role: "assistant", createdAt, + content: Array.from({ length: paragraphs }, (_, line) => `Answer ${index}, paragraph ${line}. Folded history must keep its reading position.`).join("\n\n") }, + ] as UiMessage[]; + }).flat(); +} + +function scroller(host: HTMLElement) { + const element = host.querySelector(".thread-scroll"); + if (!element) throw new Error("transcript scroller missing"); + return element; +} +function visibleRow(host: HTMLElement) { + const viewport = scroller(host).getBoundingClientRect(); + const row = [...host.querySelectorAll(".thread-content > .message-row")].find((node) => { + const rect = node.getBoundingClientRect(); + return rect.bottom > viewport.top + 1 && rect.top < viewport.bottom - 1; + }); + if (!row) throw new Error("no visible transcript row"); + return row; +} +function geometry(host: HTMLElement) { + const el = scroller(host); + const tail = host.querySelector(".thread-content > .message-row:last-of-type"); + return { top: el.scrollTop, max: Math.max(0, el.scrollHeight - el.clientHeight), height: el.scrollHeight, + tailTop: tail?.getBoundingClientRect().top ?? 0, + rows: host.querySelectorAll(".thread-content > .message-row").length }; +} +async function frames(count: number) { for (let i = 0; i < count; i++) await frame(); } +async function settled(host: HTMLElement) { + let previous = ""; + let stable = 0; + for (let attempt = 0; attempt < 180; attempt++) { + await frame(); + const current = JSON.stringify(geometry(host)); + stable = current === previous && !host.querySelector(".transcript-settle-veil") ? stable + 1 : 0; + if (stable >= 4) return; + previous = current; + } + throw new Error("transcript never settled"); +} +async function wheel(host: HTMLElement, deltaY: number) { + const rect = scroller(host).getBoundingClientRect(); + await globalThis.scrollProbeDriver.wheel({ x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2), deltaY }); +} +async function approachHistory(host: HTMLElement) { + await wheel(host, 200 - scroller(host).scrollTop); + await frames(12); + if (Math.abs(scroller(host).scrollTop - 200) > 2) throw new Error("native wheel did not reach the history approach position"); +} + +export const probe = async (scenario: "all" | "underfilled" = "all") => { + const i18n = createInstance(); + await i18n.init({ lng: "en", resources: { en: { translation: en } }, interpolation: { escapeValue: false } }); + const initial = useAppStore.getState(); + useAppStore.setState({ settings: { defaultMode: "agent", theme: "light", enterToSend: true, + onboardingDismissed: true, thinkingDisplayMode: "detailed" } }); + const failures: string[] = []; + const reports: unknown[] = []; + let root: Root | undefined; + const host = document.createElement("div"); + host.style.cssText = "position:absolute;inset:0;display:flex;flex-direction:column;min-height:0;--composer-dock-height:180px"; + document.body.append(host); + let trustedWheels = 0; + let veilWheels = 0; + const onWheel = (event: WheelEvent) => { + if (event.isTrusted) { + trustedWheels++; + if (event.target instanceof Element && event.target.closest(".transcript-settle-veil")) veilWheels++; + } + }; + host.addEventListener("wheel", onWheel, { passive: true, capture: true }); + let props: { messages: UiMessage[]; hasMoreBefore?: boolean; onLoadOlder?: () => Promise; paneVisible?: boolean; readingWindow?: boolean }; + let session = 0; + function render() { + flushSync(() => root!.render( +
+ +
+
)); + } + function mount(next: typeof props) { + if (root) flushSync(() => root!.unmount()); + session++; + root = createRoot(host); + props = next; + render(); + } + try { + if (scenario === "underfilled") { + const result = await verifyUnderfilledEntry(i18n, history(90, 2, 5), history(70, 20, 3)); + return { ok: result.failures.length === 0, failures: result.failures, dpr: devicePixelRatio, reports: [result] }; + } + // Record short session entry from its first commit, not after the settle veil. + for (const count of [1, 3, 6]) { + mount({ messages: history(0, count, 3) }); + const samples = [geometry(host)]; + for (let i = 0; i < 16; i++) { await frame(); samples.push(geometry(host)); } + const visibleTops = samples.map((sample) => sample.tailTop); + const range = Math.max(...visibleTops) - Math.min(...visibleTops); + if (range > 2) failures.push(`short entry (${count} turns) moved its answer ${range}px`); + reports.push({ name: `short-entry-${count}`, range, samples }); + } + + let finishSmallPage: (() => void) | undefined; + let smallPageCalls = 0; + mount({ messages: history(20, 2, 3), hasMoreBefore: true, onLoadOlder: () => { + smallPageCalls++; + return new Promise((resolve) => { finishSmallPage = resolve; }); + } }); + await settled(host); + const smallRow = visibleRow(host); + const smallBefore = smallRow.getBoundingClientRect().top; + const smallGeometry = geometry(host); + await wheel(host, -8); + const smallSamples = []; + for (let i = 0; i < 16; i++) { + await frame(); + smallSamples.push({ top: scroller(host).scrollTop, rowTop: smallRow.getBoundingClientRect().top }); + } + const smallAfter = smallRow.getBoundingClientRect().top; + const smallShift = Math.max(...smallSamples.map((s) => Math.abs(s.rowTop - smallBefore + s.top - smallGeometry.top))); + if (smallShift > 2) failures.push(`first near-top wheel shifted reading row by an extra ${smallShift}px`); + if (smallPageCalls !== 1) failures.push(`near-top wheel did not request exactly one page (${smallPageCalls})`); + reports.push({ name: "first-near-top-wheel", smallBefore, smallAfter, smallShift, smallGeometry, smallSamples, after: geometry(host) }); + props = { ...props, hasMoreBefore: false }; + render(); + finishSmallPage?.(); + await frames(3); + + mount({ messages: history(0, 65) }); + const beforeVeilWheel = veilWheels; + await wheel(host, -120); + await settled(host); + if (veilWheels === beforeVeilWheel) failures.push("entry fixture did not deliver wheel to the settle veil"); + props = { ...props, messages: [...props.messages, ...history(65, 1, 8)] }; + render(); + await frames(16); + const afterVeil = geometry(host); + if (afterVeil.max - afterVeil.top > 2) failures.push(`wheel over entry veil released follow (${afterVeil.max - afterVeil.top}px gap)`); + reports.push({ name: "wheel-during-entry", veilWheels, afterVeil }); + + mount({ messages: history(0, 65) }); + await settled(host); + const el = scroller(host); + if (getComputedStyle(el).overflowAnchor !== "none") failures.push("native anchoring enabled"); + const first = geometry(host); + const nativeSamples = [first]; + const wheelAtStart = trustedWheels; + const frameDurations: number[] = []; + let previousFrame = await frame(); + await wheel(host, -120); + for (let i = 0; i < 20; i++) { + const now = await frame(); + frameDurations.push(now - previousFrame); + previousFrame = now; + nativeSamples.push(geometry(host)); + } + const firstMoved = first.top - geometry(host).top; + if (Math.abs(firstMoved - 120) > 2) failures.push(`first native wheel moved ${firstMoved}px instead of 120`); + if (nativeSamples.slice(1).some((sample, index) => sample.top > nativeSamples[index].top + 2)) failures.push("first native wheel reversed toward latest"); + if (trustedWheels === wheelAtStart) failures.push("native wheel was not delivered"); + reports.push({ name: "first-native-wheel", firstMoved, nativeSamples, frameDurations }); + + const travel: unknown[] = []; + for (let step = 0; step < 32; step++) { + const row = visibleRow(host); + const before = { ...geometry(host), rowTop: row.getBoundingClientRect().top }; + await wheel(host, -240); + const positions = [before.rowTop]; + for (let i = 0; i < 12; i++) { await frame(); positions.push(row.getBoundingClientRect().top); } + const after = { ...geometry(host), rowTop: row.getBoundingClientRect().top }; + const moved = after.rowTop - before.rowTop; + const asked = Math.min(240, before.top); + if (Math.abs(moved - asked) > 3) failures.push(`native travel ${step} moved reading row ${moved}px instead of ${asked}`); + if (positions.some((top, i) => top < (positions[i - 1] ?? top) - 2 || top > before.rowTop + asked + 3)) { + failures.push(`native travel ${step} jumped during an intermediate frame`); + } + travel.push({ step, moved, asked, before, after, positions }); + if (after.top <= 1 && after.rows >= 130) break; + } + reports.push({ name: "native-window-growth", travel }); + + // Revisit an interior reading position, not the clamped start or latest. + if (scroller(host).scrollTop < 120 || geometry(host).max - scroller(host).scrollTop < 120) { + await wheel(host, geometry(host).max / 2 - scroller(host).scrollTop); + await frames(12); + } + const retainedBefore = scroller(host).scrollTop; + props = { ...props, paneVisible: false }; + render(); + await frames(3); + props = { ...props, paneVisible: true }; + render(); + const revealedTop = scroller(host).scrollTop; + const revealSamples = [revealedTop]; + for (let i = 0; i < 4; i++) { await frame(); revealSamples.push(scroller(host).scrollTop); } + const retainedAfter = scroller(host).scrollTop; + if (revealSamples.some((top) => Math.abs(top - retainedBefore) > 2)) failures.push(`pane reveal restored ${revealedTop}/${retainedAfter} instead of ${retainedBefore}`); + reports.push({ name: "reading-pane-reveal", retainedBefore, revealedTop, retainedAfter, revealSamples }); + + // Hold the RPC open while the reader keeps moving, then publish a real page. + let pageCalls = 0; + let finishPage: (() => void) | undefined; + const recent = history(20, 10, 3); + mount({ messages: recent, hasMoreBefore: true, onLoadOlder: () => { + pageCalls++; + return new Promise((resolve) => { finishPage = resolve; }); + } }); + await settled(host); + await approachHistory(host); + await wheel(host, -120); + await frames(14); + if (pageCalls !== 1) failures.push(`expected one page request, got ${pageCalls}`); + await wheel(host, -32); + await frames(14); + const pageRow = visibleRow(host); + const pageBefore = pageRow.getBoundingClientRect().top; + props = { ...props, messages: [...history(10, 10, 3), ...recent], hasMoreBefore: false }; + render(); + finishPage?.(); + const pageTops = [pageRow.getBoundingClientRect().top]; + for (let i = 0; i < 16; i++) { await frame(); pageTops.push(pageRow.getBoundingClientRect().top); } + const pageShift = Math.max(...pageTops.map((top) => Math.abs(top - pageBefore))); + if (pageShift > 2) failures.push(`async page shifted reading row ${pageShift}px`); + reports.push({ name: "async-page-during-reading", pageCalls, pageBefore, pageTops, pageShift }); + + flushSync(() => root!.unmount()); + root = undefined; + for (const resolveWhileHidden of [false, true]) { + const retainedPage = await verifyRetainedPage(i18n, recent, history(10, 10, 3), history(30, 1, 8), resolveWhileHidden); + failures.push(...retainedPage.failures); + trustedWheels += retainedPage.trustedWheels; + reports.push(retainedPage); + } + + let finishPartialPage: (() => void) | undefined; + const wholeTurn = history(40, 1, 22); + const partialTurn = wholeTurn.slice(1); + mount({ messages: partialTurn, hasMoreBefore: true, onLoadOlder: () => { + return new Promise((resolve) => { finishPartialPage = resolve; }); + } }); + await settled(host); + await approachHistory(host); + await wheel(host, -120); + await frames(14); + if (!finishPartialPage) throw new Error("partial-turn page was not requested"); + const answerSelector = '[data-message-id="answer-40"]'; + const partialBefore = host.querySelector(answerSelector)!.getBoundingClientRect().top; + props = { ...props, hasMoreBefore: false, messages: [ + ...history(39, 1), wholeTurn[0], + { id: "early-progress-40", role: "assistant", content: "Earlier progress", createdAt }, + ...partialTurn, + ] }; + render(); + finishPartialPage(); + const partialTops = [host.querySelector(answerSelector)!.getBoundingClientRect().top]; + for (let i = 0; i < 16; i++) { + await frame(); + partialTops.push(host.querySelector(answerSelector)!.getBoundingClientRect().top); + } + const partialShift = Math.max(...partialTops.map((top) => Math.abs(top - partialBefore))); + if (partialShift > 2) failures.push(`partial-turn prepend moved answer ${partialShift}px`); + reports.push({ name: "partial-turn-prepend", partialBefore, partialTops, partialShift, + geometry: geometry(host), + }); + + // Explicit return to latest must supersede an in-flight history request. + let completeJumpPage: (() => void) | undefined; + mount({ messages: recent, hasMoreBefore: true, onLoadOlder: () => new Promise((resolve) => { completeJumpPage = resolve; }) }); + await settled(host); + await approachHistory(host); + await wheel(host, -120); + await frames(14); + const jumpButton = host.querySelector(".jump-latest-btn"); + if (!jumpButton) throw new Error("jump-to-latest button missing after native scrolling"); + jumpButton.click(); + const jumpSamples = [geometry(host)]; + for (let i = 0; i < 12; i++) { await frame(); jumpSamples.push(geometry(host)); } + if (jumpSamples.some((s) => Math.abs(s.top - s.max) > 2 || Math.abs(s.top - jumpSamples[0].top) > 2)) { + failures.push("jump-to-latest did not land immediately and remain stable"); + } + props = { ...props, messages: [...history(10, 10, 3), ...recent], hasMoreBefore: false }; + render(); + completeJumpPage?.(); + await frames(16); + const jump = geometry(host); + if (Math.abs(jump.max - jump.top) > 2) failures.push("late page took ownership after jump to latest"); + reports.push({ name: "jump-during-page", jump, jumpSamples }); + + flushSync(() => root!.unmount()); + root = undefined; + const shortEntry = await verifyShortSessionEntry(i18n, history(0, 3, 3)); + failures.push(...shortEntry.failures); + reports.push(shortEntry); + const disclosurePage = await verifyDisclosureDuringPage(i18n, recent, history(10, 10, 3)); + failures.push(...disclosurePage.failures); + trustedWheels += disclosurePage.trustedWheels; + reports.push(disclosurePage); + const navigation = await verifyTranscriptNavigation(i18n, history(70, 4, 3), history(60, 30, 3), history(0, 8, 3)); + failures.push(...navigation.failures); + trustedWheels += navigation.trustedWheels; + reports.push(navigation); + const underfilled = await verifyUnderfilledEntry(i18n, history(90, 2, 5), history(70, 20, 3)); + failures.push(...underfilled.failures); + reports.push(underfilled); + + return { ok: failures.length === 0, failures, trustedWheels, dpr: devicePixelRatio, reports }; + } finally { + if (root) flushSync(() => root!.unmount()); + host.removeEventListener("wheel", onWheel, true); + host.remove(); + useAppStore.setState({ settings: initial.settings }); + } +}; +globalThis.transcriptScrollProbe = probe; diff --git a/scripts/e2e/transcript-underfilled-entry.tsx b/scripts/e2e/transcript-underfilled-entry.tsx new file mode 100644 index 0000000000..4dfb9bcfce --- /dev/null +++ b/scripts/e2e/transcript-underfilled-entry.tsx @@ -0,0 +1,225 @@ +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import type { i18n } from "i18next"; +import { I18nextProvider } from "react-i18next"; +import type { SessionSummary, UiMessage } from "@pi-desktop/shared"; +import { Composer } from "../../apps/desktop/src/components/Composer"; +import { SessionPane } from "../../apps/desktop/src/components/SessionPane"; +import { useAppStore } from "../../apps/desktop/src/stores/app-store"; +import { createTranscriptReadingRuntime } from "../../apps/desktop/src/stores/runtime/transcript-reading-runtime"; +import { deleteComposerDraft, readComposerDraft, writeComposerDraft } from "../../apps/desktop/src/lib/composer-draft-cache"; + +const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); +type ReadSession = Parameters[1]; +type ReadResult = Awaited>; + +/** Exercise real pane/composer/reader wiring; only the asynchronous session read is gated. */ +export async function verifyUnderfilledEntry(i18n: i18n, tail: UiMessage[], older: UiMessage[]) { + const original = useAppStore.getState(); + const style = document.documentElement.style; + const reserve = style.getPropertyValue("--composer-dock-height"); + const priority = style.getPropertyPriority("--composer-dock-height"); + const failures: string[] = []; + const reports: unknown[] = []; + const cases = ["single-page-overflow", "multi-page-overflow", "exhausted-underfill", + "hidden-pending-reveal", "draft-switch-entry", "complete-short"] as const; + try { + for (const name of cases) { + const id = `underfilled-entry-${name}`; + const awayId = `${id}-away`; + const drafts = [id, awayId].map((key) => ({ key, value: readComposerDraft(key) })); + const paged = name !== "complete-short"; + const tiny = (suffix: string): UiMessage[] => [ + { id: `${id}-${suffix}-user`, role: "user", content: "Earlier request", createdAt: tail[0].createdAt }, + { id: `${id}-${suffix}-answer`, role: "assistant", content: "Earlier answer.", createdAt: tail[0].createdAt }, + ]; + const messages = tail.map((message) => ({ ...message, id: `${id}-${message.id}` })); + const large = older.map((message) => ({ ...message, id: `${id}-${message.id}` })); + const multiple = name === "multi-page-overflow" || name === "hidden-pending-reveal"; + // Leave a third genuine page unread: overflow, not exhaustion, must stop auto-paging. + const pages = !paged ? [] : multiple ? [tiny("near"), large, tiny("oldest")] + : name === "exhausted-underfill" ? [tiny("only")] : [large]; + let messageStart = pages.reduce((sum, page) => sum + page.length, 0); + let expected = messages; + let visible = true; + let mounted = name !== "draft-switch-entry"; + const host = document.createElement("div"); + // Fixed pane geometry remains useful at the default viewport; the runner can + // additionally prove an actual 858x1166 CSS viewport at either native scale. + host.style.cssText = "position:absolute;left:0;top:0;width:858px;height:1166px;display:flex;flex-direction:column;min-height:0"; + document.body.append(host); + const root = createRoot(host); + const reads: { sessionId: string; options: Parameters[1]; + finish: (result: ReadResult) => void; promise: Promise }[] = []; + const summary: SessionSummary = { id, title: id, messageCount: messages.length + messageStart, + mode: "agent", permissionMode: "ask", thinkingLevel: "off", + createdAt: "2026-09-23T00:00:00Z", updatedAt: "2026-09-23T00:00:00Z" }; + const awayMessages = tiny("away"); + const runtime = createTranscriptReadingRuntime({ get: useAppStore.getState, set: useAppStore.setState }, (sessionId, options) => { + let finish!: (result: ReadResult) => void; + const promise = new Promise((resolve) => { finish = resolve; }); + reads.push({ sessionId, options, finish, promise }); + return promise; + }); + const check = (condition: boolean, message: string) => { + if (!condition) failures.push(`${name}: ${message}`); + }; + const render = (before?: () => void) => flushSync(() => { + before?.(); + root.render( +
+
+ {mounted && } + +
+ +
+
); + }); + const sample = (phase: string) => { + const pane = host.querySelector(`[data-session-pane="${id}"]`)!; + if (!visible) return { phase, visible, top: 0, max: 0, rowBottom: 0, + dockTop: 0, gap: 0, height: 0, + tailPresent: Boolean(pane.querySelector(`[data-message-id="${messages.at(-1)!.id}"]`)), + reads: reads.length }; + const scroll = pane.querySelector(".thread-scroll")!; + const row = [...pane.querySelectorAll(".thread-content > .message-row")].at(-1)!; + const dock = host.querySelector(".composer-dock")!; + const rowBottom = row.getBoundingClientRect().bottom; + return { phase, visible, top: scroll.scrollTop, max: scroll.scrollHeight - scroll.clientHeight, + rowBottom, dockTop: dock.getBoundingClientRect().top, + gap: dock.getBoundingClientRect().top - rowBottom, height: scroll.clientHeight, + tailPresent: Boolean(pane.querySelector(`[data-message-id="${messages.at(-1)!.id}"]`)), + reads: reads.length }; + }; + const samples: ReturnType[] = []; + const capture = (phase: string) => { const value = sample(phase); samples.push(value); return value; }; + const frames = async (count: number, phase: string) => { + for (let i = 0; i < count; i++) { await frame(); capture(phase); } + }; + const verifyMessages = () => { + const view = useAppStore.getState().transcriptViews[id]; + check(view?.messageStart === messageStart, `reader cursor ${view?.messageStart} != ${messageStart}`); + check(view?.hasMoreBefore === (messageStart > 0), "reader exhaustion does not match cursor"); + check(JSON.stringify(view?.messages.map((message) => message.id)) === JSON.stringify(expected.map((message) => message.id)), + "loaded history identity/order differs from the real page results"); + }; + const deliver = async (index: number) => { + const request = reads[index]; + if (!request) throw new Error(`missing gated read ${index + 1}`); + check(request.sessionId === id && request.options.messageBefore === messageStart, + `read ${index + 1} did not request the genuine preceding cursor ${messageStart}`); + const page = pages[index]; + const end = messageStart; + messageStart -= page.length; + expected = [...page, ...expected]; + request.finish({ session: { ...summary, messages: page, messageStart, + messageEnd: end, hasMoreBefore: messageStart > 0, hasMoreAfter: true } }); + await request.promise; + await frames(16, `page-${index + 1}`); + verifyMessages(); + }; + try { + writeComposerDraft(id, { text: "", fileReferences: [] }); + writeComposerDraft(awayId, { text: name === "draft-switch-entry" ? "A retained multiline draft.\n".repeat(12) : "", fileReferences: [] }); + useAppStore.setState({ ...runtime.actions, activeSessionId: mounted ? id : awayId, + messages: mounted ? messages : awayMessages, sessions: [summary, { ...summary, id: awayId }], + retainedSessionIds: [id, awayId], retainedTranscripts: { [id]: messages, [awayId]: awayMessages }, + sessionHistory: { [id]: { messageStart, hasMoreBefore: paged }, [awayId]: { messageStart: 0, hasMoreBefore: false } }, + transcriptViews: {}, runningSessions: {}, pendingPermissions: {}, pendingAsks: {}, planningStates: {}, isRunning: false }); + render(); + let tallDockHeight = 0; + if (!mounted) { + for (let i = 0; i < 8; i++) await frame(); + tallDockHeight = host.querySelector(".composer-dock")!.getBoundingClientRect().height; + check(reads.length === 0, "unmounted partial session paged before entry"); + mounted = true; + render(() => useAppStore.setState({ activeSessionId: id, messages })); + } + capture("first-commit"); + await frames(16, "pending"); + check(samples[0].max === 0, "fixture must initially underfill"); + if (name === "draft-switch-entry") { + const height = host.querySelector(".composer-dock")!.getBoundingClientRect().height; + check(tallDockHeight > height + 40, "draft switch did not materially shrink the real composer"); + } + check(reads.length === (paged ? 1 : 0), `expected ${paged ? 1 : 0} read while first gate is pending, got ${reads.length}`); + if (name === "hidden-pending-reveal") { + visible = false; + render(() => useAppStore.setState({ activeSessionId: awayId, messages: awayMessages })); + capture("hidden-commit"); + await frames(8, "hidden-pending"); + check(reads.length === 1, "hidden pending pane issued another read"); + await deliver(0); + check(reads.length === 1, "hidden pane continued paging after a pending result"); + visible = true; + render(() => useAppStore.setState({ activeSessionId: id, messages })); + capture("reveal-commit"); + check(samples.at(-1)!.max === 0, "revealed first earlier page must still underfill"); + await frames(16, "revealed-pending"); + check(reads.length === 2, "reveal did not resume exactly one earlier read"); + await deliver(1); + } else if (paged) { + await deliver(0); + if (multiple) { + check(samples.at(-1)!.max === 0, "first earlier page must still underfill"); + check(reads.length === 2, "underfilled first page did not automatically request exactly one next page"); + await frames(8, "second-pending"); + check(reads.length === 2, "second pending gate was requested more than once"); + await deliver(1); + } + } + await frames(8, "idle"); + const final = samples.at(-1)!; + if (paged) { + const completesHistory = name === "single-page-overflow" || name === "exhausted-underfill"; + check(name === "exhausted-underfill" ? final.max === 0 : final.max >= 100, + "final geometry did not reach the intended underfill/overflow state"); + check(reads.length === (multiple ? 2 : 1), "paging continued after overflow or exhaustion"); + const visibleSamples = samples.filter((item) => item.visible); + if (completesHistory) { + const first = host.querySelector(`[data-session-pane="${id}"] .thread-content > .message-row`)!; + check(first.getBoundingClientRect().top <= 100, "exhausted short conversation lost ordinary top alignment"); + reports.push({ name, reads: reads.length, messageStart, samples }); + } else { + const worstGap = Math.max(...visibleSamples.map((item) => Math.abs(item.gap - 16))); + check(worstGap <= 2, `dock-to-answer gap departed from 16±2px by ${worstGap}px`); + const drift = Math.max(...visibleSamples.map((item) => Math.abs(item.rowBottom - final.rowBottom))); + check(drift <= 2, `underfilled tail moved ${drift}px while older history loaded`); + reports.push({ name, drift, worstGap, reads: reads.length, messageStart, samples }); + } + } else { + const first = host.querySelector(`[data-session-pane="${id}"] .thread-content > .message-row`)!; + check(first.getBoundingClientRect().top <= 100, "complete short conversation lost ordinary top alignment"); + reports.push({ name, reads: reads.length, samples }); + } + check(samples.every((item) => item.tailPresent), "the actual final answer disappeared during paging"); + } catch (error) { + failures.push(`${name}: ${error instanceof Error ? error.message : String(error)}`); + reports.push({ name, samples, reads: reads.length }); + } finally { + try { + flushSync(() => root.unmount()); + } finally { + runtime.actions.returnToLatestTranscript(id); + for (const read of reads) read.finish({ session: null }); + await Promise.all(reads.map((read) => read.promise)); + await Promise.resolve(); + for (const { key, value } of drafts) { + deleteComposerDraft(key); + if (value) writeComposerDraft(key, value, value.workspacePath); + } + host.remove(); + useAppStore.setState(original, true); + if (reserve) style.setProperty("--composer-dock-height", reserve, priority); + else style.removeProperty("--composer-dock-height"); + } + } + } + } finally { + useAppStore.setState(original, true); + if (reserve) style.setProperty("--composer-dock-height", reserve, priority); + else style.removeProperty("--composer-dock-height"); + } + return { name: "underfilled-tail-entry", failures, reports }; +} diff --git a/scripts/e2e/turn-file-review-navigation.tsx b/scripts/e2e/turn-file-review-navigation.tsx new file mode 100644 index 0000000000..2344d53b88 --- /dev/null +++ b/scripts/e2e/turn-file-review-navigation.tsx @@ -0,0 +1,160 @@ +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { I18nextProvider } from "react-i18next"; +import type { i18n } from "i18next"; +import type { ReviewChange, ReviewRollbackStatus, UiMessage } from "@pi-desktop/shared"; +import { WorkPanel } from "../../apps/desktop/src/components/workpanel/WorkPanel"; +import { ChatTranscript } from "../../apps/desktop/src/features/chat/transcript/ChatTranscript"; +import { useTranscriptView } from "../../apps/desktop/src/hooks/use-transcript-view"; +import { useAppStore } from "../../apps/desktop/src/stores/app-store"; + +const assert = (value: unknown, message: string): void => { + if (!value) throw new Error(message); +}; +const painted = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); +const sessionId = "review-navigation-a"; +const path = "PI-Desktop-worktrees/task-turn-summary/scripts/e2e-transcript-scroll.mjs"; +const zeroPath = "docs/spec/recorded.md"; +const row = (id: string, role: UiMessage["role"], extra: Partial = {}): UiMessage => ({ + id, role, content: id, createdAt: "2026-09-20T00:00:00.000Z", ...extra, +}); +const change = (snapshotId: string, filePath: string): ReviewChange => ({ + version: 1, snapshotId, messageId: snapshotId, path: filePath, + operation: "edit", status: "modified", state: "active", additions: 48, deletions: 24, + hunks: [{ header: "@@ -1 +1 @@", lines: [{ type: "add", text: `persisted ${snapshotId}` }] }], + reversible: true, +}); +const edit = (id: string, reviews: ReviewChange[]): UiMessage => row(id, "tool", { + toolName: "Bash", toolStatus: "success", + toolResult: { details: { root: "workspace", reviews, reviewCapture: { status: "complete" } } }, +}); +const files = [ + ...Array.from({ length: 13 }, (_, index) => change(`extra-${index}`, `src/extra-${index}.ts`)), + { ...change("zero-lines", zeroPath), additions: 0, deletions: 0, hunks: [], reversible: false }, + change("selected", path), +]; +const history = [ + row("user", "user"), edit("earlier", [change("before-checkpoint", path)]), + row("checkpoint", "assistant"), edit("after-checkpoint", files), + row("answer", "assistant", { status: "complete" }), +]; +const live = [row("later-user", "user"), edit("later", [change("later-same-path", path)]), row("later-answer", "assistant")]; + +function Fixture() { + const activeSessionId = useAppStore((state) => state.activeSessionId)!; + const view = useTranscriptView(activeSessionId); + const open = useAppStore((state) => state.workPanelOpen); + const subagentPanel = useAppStore((state) => state.subagentPanel); + return
+
+ +
+ {open || subagentPanel ? : null} +
; +} + +/** Real transcript -> summary -> real store actions -> ReviewTab; only IPC is fake. */ +export async function runTurnFileReviewNavigationProbe(i18n: i18n) { + const previousState = useAppStore.getState(); + const previousBridge = window.piDesktop; + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + let status: ReviewRollbackStatus = "conflict"; + let completeRollback: (() => void) | undefined; + let deferRollback = false; + const requests: { sessionId: string; snapshotId: string }[] = []; + try { + window.piDesktop = { + ...previousBridge!, + invoke: async (_channel, input) => { + const request = input as { sessionId: string; snapshotId: string }; + requests.push(request); + if (deferRollback) await new Promise((resolve) => { completeRollback = resolve; }); + return { ok: true, data: { status, snapshotId: request.snapshotId } }; + }, + on: () => () => undefined, + }; + for (const mode of ["compaction", "history", "search", "partial-history"] as const) { + status = "conflict"; + flushSync(() => { + useAppStore.setState({ activeSessionId: undefined }); + useAppStore.setState({ + activeSessionId: sessionId, selectingSessionId: undefined, isRunning: false, + retainedSessionIds: [sessionId], runningSessions: {}, + messages: mode === "compaction" ? history : live, + sessionCompactions: { [sessionId]: [{ id: "mark", throughMessageId: "checkpoint", generation: 1, summaryTokens: 1, summarized: true }] }, + transcriptViews: mode === "compaction" ? {} : { [sessionId]: { + messages: mode === "partial-history" ? history.slice(3) : history, + messageStart: mode === "partial-history" ? 3 : 0, + hasMoreBefore: mode === "partial-history", hasMoreAfter: true, + focus: mode === "search" ? { sessionId, messageId: "answer", query: "answer", requestId: 1 } : null, loading: null, + } }, + workPanelOpen: false, workPanelTabs: [], activeWorkPanelTabId: null, workPanelContexts: {}, + subagentPanel: null, + }); + root.render(); + }); + const summary = container.querySelector('[data-message-id="answer"]')?.closest(".assistant-turn") + ?.querySelector(".turn-file-summary"); + assert(summary, `${mode}: first-commit file frame waited for unrelated history`); + const heading = summary!.querySelector(".turn-file-summary-heading strong")?.textContent ?? ""; + assert(heading === "本轮编辑 15 个文件", `${mode}: loaded file count missing on first commit`); + assert(summary!.querySelector(".turn-file-summary-totals")?.getAttribute("aria-label") === + i18n.t("chat.turnFilesEditTotalsLabel", { additions: 14 * 48, deletions: 14 * 24 }), + `${mode}: loaded line totals missing on first commit`); + const processHeader = summary!.closest(".assistant-turn")?.querySelector(".turn-process > .tool-activity-header"); + assert(processHeader?.querySelector(".tool-activity-label")?.textContent === + i18n.t("chat.processedFor", { time: "0s" }), `${mode}: loaded duration missing on first commit`); + assert(processHeader?.querySelector(".tool-activity-count")?.textContent === + i18n.t("chat.processTools", { count: 1 }), `${mode}: loaded tool count missing on first commit`); + assert(summary!.querySelectorAll(".turn-file-summary-file-header").length === 3, `${mode}: collapse threshold changed`); + const click = (filePath: string) => { + const button = Array.from(summary!.querySelectorAll(".turn-file-summary-file-header")) + .find((element) => element.querySelector(".turn-file-summary-path")?.textContent === filePath); + assert(button, `${mode}: missing path ${filePath}`); + flushSync(() => button!.click()); + }; + click(path); + await painted(); + const review = () => container.querySelector(".review-scroll"); + assert(container.querySelector("[data-testid='work-panel']"), `${mode}: summary click did not open right Review panel`); + assert(review()?.textContent?.includes("persisted selected"), `${mode}: exact expanded diff missing`); + assert(!review()?.textContent?.includes("before-checkpoint") && !review()?.textContent?.includes("later-same-path"), `${mode}: same path leaked across turns`); + assert(review()?.querySelectorAll(".review-change-card").length === 1, `${mode}: unrelated records leaked`); + const rollback = () => review()?.querySelector(".review-change-rollback"); + rollback()?.click(); + await painted(); + assert(review()?.querySelector(".review-change-rollback-note.is-warning"), `${mode}: rollback conflict hidden`); + assert(!review()?.querySelector('[data-state="rolledBack"]'), `${mode}: conflict incorrectly changed evidence`); + status = "rolledBack"; + rollback()?.click(); + await painted(); + assert(review()?.querySelector('[data-state="rolledBack"]'), `${mode}: rollback did not update selected reading-range evidence`); + assert(requests.at(-1)?.sessionId === sessionId && requests.at(-1)?.snapshotId === "selected", `${mode}: wrong rollback target`); + flushSync(() => useAppStore.getState().toggleSubagentPanel("delegate")); + assert(!review(), `${mode}: subagent surface should replace Review before navigation`); + click(zeroPath); + await painted(); + assert(review()?.querySelector(".review-change-card-body"), `${mode}: +0/-0 record did not open`); + assert(useAppStore.getState().subagentPanel === null, `${mode}: explicit file navigation retained subagent surface`); + assert(!rollback(), `${mode}: nonreversible record exposes rollback`); + } + // An async response belongs to its captured session, never the new active one. + flushSync(() => useAppStore.setState({ activeSessionId: sessionId, messages: history })); + deferRollback = true; + const pending = useAppStore.getState().rollbackWorkspaceChange("after-checkpoint", "selected"); + const otherMessages = [edit("after-checkpoint", [change("selected", path)])]; + flushSync(() => useAppStore.setState({ activeSessionId: "review-navigation-b", messages: otherMessages, transcriptViews: {}, workPanelOpen: false })); + completeRollback!(); + await pending; + assert(useAppStore.getState().messages === otherMessages, "late rollback changed another session's evidence"); + return { ok: true, cases: ["compaction", "history", "search", "partial-history", "subagent-surface", "late-rollback"] }; + } finally { + flushSync(() => root.unmount()); + useAppStore.setState(previousState, true); + window.piDesktop = previousBridge; + container.remove(); + } +} diff --git a/scripts/e2e/turn-file-summary.tsx b/scripts/e2e/turn-file-summary.tsx new file mode 100644 index 0000000000..84b46c999f --- /dev/null +++ b/scripts/e2e/turn-file-summary.tsx @@ -0,0 +1,452 @@ +import { runTurnFileReviewNavigationProbe } from "./turn-file-review-navigation"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { createInstance } from "i18next"; +import { I18nextProvider } from "react-i18next"; +import { zhCN } from "@pi-desktop/i18n"; +import type { ReviewChange, UiMessage } from "@pi-desktop/shared"; +import { WorkPanel } from "../../apps/desktop/src/components/workpanel/WorkPanel"; +import { TurnFileSummary } from "../../apps/desktop/src/features/chat/transcript/TurnFileSummary"; +import { buildTranscriptEntries } from "../../apps/desktop/src/lib/assistant-turns"; +import { reviewChangesFromMessage } from "../../apps/desktop/src/lib/workspace-review"; +import { useAppStore } from "../../apps/desktop/src/stores/app-store"; +import { switchWorkPanelSession } from "../../apps/desktop/src/stores/slices/work-panel-slice"; + +function assert(value: unknown, message: string): asserts value { + if (!value) throw new Error(message); +} + +const painted = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + +const message = ( + id: string, + role: UiMessage["role"], + content: string, + extra: Partial = {}, +): UiMessage => ({ + id, + role, + content, + createdAt: "2026-09-20T00:00:00.000Z", + ...extra, +}); + +function review( + snapshotId: string, + path: string, + additions: number, + text = `created ${path}`, +): ReviewChange { + return { + version: 1, + snapshotId, + messageId: snapshotId.startsWith("session-b") ? "session-b-copy" : "summary-copy", + path, + operation: "write", + status: "added", + state: "active", + additions, + deletions: 0, + hunks: [ + { + header: "@@ -0,0 +1 @@", + lines: [{ type: "add", text }], + }, + ], + reversible: true, + }; +} + +function screenshotWorkflowMessages(): UiMessage[] { + return [ + message("summary-copy", "tool", "copied", { + toolName: "Bash", + toolStatus: "success", + toolResult: { + details: { + root: "workspace", + exitCode: 0, + reviews: [ + review("snapshot-html", "index.html", 20), + review("snapshot-css", "styles.css", 12), + review("snapshot-js", "app.js", 8), + ], + reviewCapture: { status: "complete" }, + }, + }, + }), + message("summary-answer", "assistant", "Done", { status: "complete" }), + message("next-user", "user", "Change HTML again"), + message("later-html", "tool", "changed", { + toolName: "Edit", + toolStatus: "success", + toolResult: { + details: { + root: "workspace", + review: { + ...review("snapshot-html-later", "index.html", 1, "later turn html"), + messageId: "later-html", + }, + }, + }, + }), + message("later-answer", "assistant", "Changed again", { status: "complete" }), + ]; +} + +function sessionBMessages(): UiMessage[] { + return [ + message("session-b-copy", "tool", "copied", { + toolName: "Bash", + toolStatus: "success", + toolResult: { + details: { + root: "workspace", + exitCode: 0, + reviews: [review("session-b-css", "styles.css", 2, "session b css")], + reviewCapture: { status: "complete" }, + }, + }, + }), + message("session-b-answer", "assistant", "Done", { status: "complete" }), + ]; +} + +function Fixture({ turnId }: { turnId: string }) { + const messages = useAppStore((state) => state.messages); + const sessionId = useAppStore((state) => state.activeSessionId); + const workPanelOpen = useAppStore((state) => state.workPanelOpen); + const entry = buildTranscriptEntries(messages).entries.find( + (item) => item.kind === "assistant-turn" && item.id === turnId, + ); + assert(entry?.kind === "assistant-turn", `assistant turn ${turnId} missing`); + return ( +
+
+ +
+ {workPanelOpen ? : null} +
+ ); +} + +/** Mounted component probe; the transcript runner may register this directly. */ +export async function runTurnFileSummaryProbe() { + const i18n = createInstance(); + await i18n.init({ + lng: "zh-CN", + resources: { "zh-CN": { translation: zhCN } }, + interpolation: { escapeValue: false }, + }); + const navigation = await runTurnFileReviewNavigationProbe(i18n); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const previousState = useAppStore.getState(); + const previousBridge = window.piDesktop; + const sessionA = "turn-file-summary-a"; + const sessionB = "turn-file-summary-b"; + const render = (turnId: string) => { + flushSync(() => + root.render( + + + , + ), + ); + }; + const summaryButton = (path: string) => { + const button = Array.from( + container.querySelectorAll(".turn-file-summary-file-header"), + ).find((candidate) => candidate.textContent?.includes(path)); + assert(button, `${path} summary navigation missing`); + return button; + }; + + try { + window.piDesktop = { + invoke: async (_channel, input) => ({ + ok: true, + data: { + status: "rolledBack", + snapshotId: (input as { snapshotId: string }).snapshotId, + }, + }), + on: () => () => undefined, + channels: previousBridge?.channels ?? ({} as NonNullable["channels"]), + platform: "win32", + }; + useAppStore.setState({ + activeSessionId: sessionA, + isRunning: false, + messages: screenshotWorkflowMessages(), + workPanelOpen: false, + workPanelTabs: [], + activeWorkPanelTabId: null, + workPanelContexts: {}, + }); + render("summary-copy"); + + assert(container.textContent?.includes("本轮编辑 3 个文件"), "exact summary title missing"); + assert(!container.querySelector(".turn-file-summary-note"), "scope disclaimer still rendered"); + assert(!container.querySelector(".turn-file-summary .review-change-card"), "chat contains nested review diff"); + for (const count of [3, 4, 5, 6, 21]) { + const files = Array.from({ length: count }, (_, index) => + review(`count-${count}-${index}`, `file-${index}.ts`, 1), + ); + flushSync(() => useAppStore.setState({ messages: [ + message("summary-copy", "tool", "created files", { + toolName: "Bash", + toolStatus: "success", + toolResult: { details: { root: "workspace", reviews: files } }, + }), + message("summary-answer", "assistant", "Done", { status: "complete" }), + ] })); + const rows = () => container.querySelectorAll(".turn-file-summary-file-header").length; + const toggle = () => container.querySelector(".turn-file-summary-more"); + assert(rows() === (count > 5 ? 3 : count), `${count} files: incorrect initial visible count`); + if (count <= 5) { + assert(!toggle(), `${count} files must not show a collapse control`); + } else { + const more = toggle(); + assert(more?.textContent === i18n.t("chat.turnFilesShowMore", { count: count - 3 }), "remaining file count is wrong"); + flushSync(() => more.click()); + assert(rows() === count && toggle()?.getAttribute("aria-expanded") === "true", "expand must show every file"); + flushSync(() => toggle()?.click()); + assert(rows() === 3 && toggle()?.getAttribute("aria-expanded") === "false", "collapse must restore the first three files"); + } + } + flushSync(() => useAppStore.setState({ messages: screenshotWorkflowMessages() })); + const fileSummary = container.querySelector( + ".turn-file-summary", + ); + const summaryHeader = container.querySelector( + ".turn-file-summary-header", + ); + const summaryIcon = container.querySelector( + ".turn-file-summary-icon", + ); + const summaryList = container.querySelector( + ".turn-file-summary-list", + ); + const summaryTitle = container.querySelector( + ".turn-file-summary-heading strong", + ); + const summaryTotals = container.querySelector( + ".turn-file-summary-totals", + ); + const firstFile = container.querySelector( + ".turn-file-summary-file-header", + ); + const firstFilePath = container.querySelector( + ".turn-file-summary-path", + ); + assert( + fileSummary && + summaryHeader && + summaryIcon && + summaryList && + summaryTitle && + summaryTotals && + firstFile && + firstFilePath, + "summary layout regions missing", + ); + await document.fonts.ready; + for (const width of [640, 320]) { + fileSummary.style.width = `${width}px`; + await painted(); + const summaryStyle = getComputedStyle(fileSummary); + const headerStyle = getComputedStyle(summaryHeader); + const iconStyle = getComputedStyle(summaryIcon); + const listStyle = getComputedStyle(summaryList); + const fileStyle = getComputedStyle(firstFile); + const pathStyle = getComputedStyle(firstFilePath); + const headerRect = summaryHeader.getBoundingClientRect(); + const iconRect = summaryIcon.getBoundingClientRect(); + const titleRect = summaryTitle.getBoundingClientRect(); + const totalsRect = summaryTotals.getBoundingClientRect(); + const fileRect = firstFile.getBoundingClientRect(); + const pathRect = firstFilePath.getBoundingClientRect(); + const lineHeight = Number.parseFloat(pathStyle.lineHeight); + assert(summaryStyle.borderTopWidth === "1px" && Number.parseFloat(summaryStyle.borderTopLeftRadius) > 0, "rounded file summary frame missing"); + assert( + summaryStyle.backgroundColor === "rgba(0, 0, 0, 0)", + "file summary still paints a tile background", + ); + assert( + headerRect.height >= 40 && headerRect.height <= 48, + "file summary header must preserve card padding", + ); + assert( + iconRect.width <= 16 && iconRect.height <= 16, + "file summary icon is still a heavy tile", + ); + assert( + iconStyle.backgroundColor === "rgba(0, 0, 0, 0)", + "file summary icon still paints a tile", + ); + assert( + headerStyle.borderBottomWidth === "0px" && + listStyle.borderTopWidth === "1px", + "header/list distinction must use only the subtle list divider", + ); + assert( + Math.abs( + titleRect.top + + titleRect.height / 2 - + (totalsRect.top + totalsRect.height / 2), + ) <= 1, + "summary totals must share the heading line", + ); + assert( + fileRect.top - headerRect.bottom >= 4, + "file list must stay distinct from the header", + ); + assert( + fileRect.height >= 27 && fileRect.height <= 30, + "file row is not native-compact", + ); + assert( + fileStyle.alignItems === "center", + "file hover row does not center its contents", + ); + assert( + Number.isFinite(lineHeight) && pathStyle.lineHeight !== "normal", + "file name line box is implicit", + ); + assert( + Math.abs(pathRect.height - lineHeight) <= 0.75, + "file name element does not expose its real line box", + ); + assert( + Math.abs( + pathRect.top + + pathRect.height / 2 - + (fileRect.top + fileRect.height / 2), + ) <= 0.5, + "file name line box is not vertically centered in the hover row", + ); + assert( + fileSummary.scrollWidth <= fileSummary.clientWidth + 1, + "file summary overflows a narrow viewport", + ); + } + fileSummary.style.removeProperty("width"); + + flushSync(() => summaryButton("styles.css").click()); + await painted(); + assert(container.querySelector("[data-testid='work-panel']"), "work panel did not open"); + assert(container.querySelector(".review-change-card-body"), "selected diff did not expand"); + assert(container.textContent?.includes("created styles.css"), "CSS snapshot hunk missing"); + assert(!container.textContent?.includes("created app.js"), "unrelated selected record rendered"); + + flushSync(() => useAppStore.getState().openNewWorkPanelTab()); + const launcherId = useAppStore.getState().activeWorkPanelTabId; + assert(launcherId, "launcher tab missing"); + flushSync(() => useAppStore.getState().activateWorkPanelTab("review")); + await painted(); + assert( + useAppStore.getState().workPanelContexts[sessionA]?.reviewSelection?.selectedPath === "styles.css", + "switching panel tabs lost the selected file", + ); + flushSync(() => useAppStore.getState().closeWorkPanelTab(launcherId)); + assert(container.querySelectorAll(".review-scroll .review-change-card").length === 1, "closing another tab cleared review selection"); + flushSync(() => useAppStore.getState().openNewWorkPanelTab()); + const replacementId = useAppStore.getState().activeWorkPanelTabId; + assert(replacementId, "replacement launcher missing"); + flushSync(() => useAppStore.getState().replaceWorkPanelTab(replacementId, { id: "new:replacement", kind: "new" })); + flushSync(() => useAppStore.getState().activateWorkPanelTab("review")); + await painted(); + assert(container.querySelectorAll(".review-scroll .review-change-card").length === 1, "replacing another tab cleared review selection"); + + const selectedHeader = container.querySelector( + ".review-scroll .review-change-card-header", + ); + assert(selectedHeader, "selected review header missing"); + flushSync(() => selectedHeader.click()); + assert(!container.querySelector(".review-change-card-body"), "selected record did not collapse"); + flushSync(() => summaryButton("styles.css").click()); + await painted(); + assert(container.querySelector(".review-change-card-body"), "repeat click did not reveal collapsed record"); + + const rollback = container.querySelector( + ".review-scroll .review-change-rollback", + ); + assert(rollback, "selected rollback action missing"); + rollback.click(); + await painted(); + const shellMessage = useAppStore.getState().messages.find((item) => item.id === "summary-copy"); + assert(shellMessage, "shell message missing after rollback"); + const states = Object.fromEntries( + reviewChangesFromMessage(shellMessage).map((change) => [change.snapshotId, change.state]), + ); + assert(states["snapshot-css"] === "rolledBack", "target snapshot was not rolled back"); + assert(states["snapshot-html"] === "active", "HTML sibling changed unexpectedly"); + assert(states["snapshot-js"] === "active", "JavaScript sibling changed unexpectedly"); + assert(container.querySelector('.review-change-card[data-state="rolledBack"]'), "rollback state not reflected in selected review"); + + flushSync(() => summaryButton("index.html").click()); + await painted(); + assert(container.textContent?.includes("created index.html"), "selected HTML hunk missing"); + assert(!container.textContent?.includes("later turn html"), "same path from another turn leaked in"); + + const sessionAMessages = useAppStore.getState().messages; + const toB = switchWorkPanelSession(useAppStore.getState(), sessionB); + useAppStore.setState({ + ...toB, + activeSessionId: sessionB, + messages: sessionBMessages(), + }); + render("session-b-copy"); + assert(!container.textContent?.includes("created index.html"), "session A selection leaked into session B"); + flushSync(() => summaryButton("styles.css").click()); + await painted(); + assert(container.textContent?.includes("session b css"), "session B selection did not open"); + + const toA = switchWorkPanelSession(useAppStore.getState(), sessionA); + useAppStore.setState({ + ...toA, + activeSessionId: sessionA, + messages: sessionAMessages, + }); + render("summary-copy"); + await painted(); + assert(container.textContent?.includes("created index.html"), "session A selection was not restored"); + assert(!container.textContent?.includes("session b css"), "session B selection leaked on return"); + + flushSync(() => useAppStore.getState().closeWorkPanelTab("review")); + assert(!useAppStore.getState().workPanelContexts[sessionA]?.reviewSelection, "closing review must clear its file selection"); + flushSync(() => useAppStore.getState().openWorkPanelTab({ id: "review", kind: "review" })); + await painted(); + assert(container.querySelectorAll(".review-scroll .review-change-card").length > 1, "reopened generic review must show session history"); + + useAppStore.setState({ + messages: [ + message("partial-shell", "tool", "failed", { + toolName: "Bash", + toolStatus: "error", + toolResult: { + details: { + root: "workspace", + exitCode: 1, + reviews: [], + reviewCapture: { status: "partial" }, + }, + }, + }), + message("partial-answer", "assistant", "Partial", { status: "complete" }), + ], + workPanelOpen: false, + }); + render("partial-shell"); + assert(!container.querySelector(".turn-file-summary"), "empty partial capture rendered a warning summary"); + + return { ok: true, navigation }; + } finally { + flushSync(() => root.unmount()); + useAppStore.setState(previousState, true); + window.piDesktop = previousBridge; + container.remove(); + } +} diff --git a/scripts/e2e/turn-process.tsx b/scripts/e2e/turn-process.tsx index ca3facb332..173f8fe6f5 100644 --- a/scripts/e2e/turn-process.tsx +++ b/scripts/e2e/turn-process.tsx @@ -3,12 +3,16 @@ import { flushSync } from "react-dom"; import { createInstance } from "i18next"; import { I18nextProvider } from "react-i18next"; import { en } from "@pi-desktop/i18n"; -import type { AppSettings, UiMessage } from "@pi-desktop/shared"; +import type { AppSettings, ReviewChange, UiMessage } from "@pi-desktop/shared"; +import { WorkPanel } from "../../apps/desktop/src/components/workpanel/WorkPanel"; import { AssistantTurn } from "../../apps/desktop/src/features/chat/transcript/AssistantTurn"; +import { TranscriptDisclosureProvider } from "../../apps/desktop/src/features/chat/transcript/disclosure"; +import { MessageRow } from "../../apps/desktop/src/features/chat/transcript/MessageRow"; import { ThinkingDisplayModeRow } from "../../apps/desktop/src/components/settings/ThinkingDisplayModeRow"; import { buildTranscriptEntries } from "../../apps/desktop/src/lib/assistant-turns"; import { TranscriptSearchContext } from "../../apps/desktop/src/lib/transcript-search-context"; import type { TranscriptSearchTarget } from "../../apps/desktop/src/lib/transcript-reading"; +import { reviewChangesFromMessage } from "../../apps/desktop/src/lib/workspace-review"; import { useAppStore } from "../../apps/desktop/src/stores/app-store"; function assert(value: unknown, message: string): asserts value { @@ -28,6 +32,65 @@ const message = ( ...extra, }); +const painted = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + +function review( + messageId: string, + snapshotId: string, + path: string, + additions: number, + deletions: number, + extra: Partial = {}, +): ReviewChange { + return { + version: 1, + messageId, + snapshotId, + path, + operation: "edit", + status: "modified", + state: "active", + additions, + deletions, + hunks: [ + { + header: "@@ -1,2 +1,4 @@", + lines: [ + { type: "del", text: "class Main {}" }, + { type: "add", text: "class Main {" }, + { type: "add", text: " static void run() {}" }, + { type: "add", text: "}" }, + ], + }, + ], + reversible: true, + ...extra, + }; +} + +function DelegatedEditReviewFixture({ sessionId }: { sessionId: string }) { + const messages = useAppStore((state) => state.messages); + const workPanelOpen = useAppStore((state) => state.workPanelOpen); + const entry = buildTranscriptEntries(messages).entries.find( + (item) => item.kind === "assistant-turn", + ); + assert(entry?.kind === "assistant-turn", "delegated edit turn missing"); + return ( +
+ + + + + + {workPanelOpen ? : null} +
+ ); +} + /** Real mounted React components: disclosure ownership, preferences and search. */ export async function turnProcessProbe() { const i18n = createInstance(); @@ -38,7 +101,8 @@ export async function turnProcessProbe() { }); const container = document.createElement("div"); document.body.append(container); - const initialSettings = useAppStore.getState().settings; + const initialState = useAppStore.getState(); + const previousBridge = window.piDesktop; const settings: AppSettings = { defaultMode: "agent", theme: "light", @@ -68,9 +132,26 @@ export async function turnProcessProbe() { flushSync(() => root.render( - - - + + + + + + , + ), + ); + assert(!errors.length, `React error: ${errors.map(String).join("; ")}`); + }; + const renderUserMessage = (userMessage: UiMessage) => { + flushSync(() => + root.render( + + , ), ); @@ -108,35 +189,173 @@ export async function turnProcessProbe() { responseDurationMs: 1000, }); const messages = [intro, read, progress, edit, answer]; + const deniedWrite = message("denied-write", "tool", "permission denied", { + toolName: "Write", + toolCallId: "write-call", + toolStatus: "denied", + toolArgs: { path: "src/example.ts", content: "updated" }, + }); + const recoveredBash = message("recovered-bash", "tool", "updated", { + toolName: "Bash", + toolCallId: "bash-call", + toolStatus: "success", + toolArgs: { command: "Set-Content src/example.ts updated" }, + }); + const recoveredAnswer = message( + "recovered-answer", + "assistant", + "The fallback completed the update.", + { status: "complete", createdAt: "2026-09-17T00:00:04.000Z" }, + ); + const activeRecovery = [intro, deniedWrite, recoveredBash]; + const completedRecovery = [...activeRecovery, recoveredAnswer]; + const steeringRoot = message("steering-root", "user", "Inspect the renderer", { + createdAt: "2026-09-17T00:00:00.000Z", + }); + const steering = message("steering-supplement", "user", "Also preserve the attachment", { + steering: true, + createdAt: "2026-09-17T00:00:02.500Z", + attachments: [{ kind: "file", name: "notes.txt", ref: "notes.txt" }], + }); + const activeSteered = [steeringRoot, intro, read, steering, edit]; + const completedSteered = [...activeSteered, answer]; + const preOutputSteered = [steeringRoot, steering, read, answer]; + const verifyCompletionLifecycle = ( + mode: "detailed" | "compact", + requestId: number, + ) => { + flushSync(() => + useAppStore.setState({ + settings: { ...settings, thinkingDisplayMode: mode }, + }), + ); + const key = `${mode}-completion`; + render(activeRecovery, true, null, key); + check( + header()?.getAttribute("aria-expanded") === "true" && visible(process()), + `${mode} active recovery process opens automatically`, + ); + const nestedRow = container.querySelector('[data-message-id="denied-write"]'); + const nestedHeader = container.querySelector( + '[data-message-id="denied-write"] .tool-row-header', + ); + const groupHeader = nestedRow?.closest(".process-activity-group") + ?.querySelector(":scope > .tool-activity-header"); + if (groupHeader?.getAttribute("aria-expanded") === "false") click(groupHeader); + click(nestedHeader); + check( + nestedHeader?.getAttribute("aria-expanded") === "true", + `${mode} active nested interaction expands tool details`, + ); + flushSync(() => nestedHeader?.focus()); + check( + document.activeElement === nestedHeader, + `${mode} nested tool owns focus before completion`, + ); + render(completedRecovery, false, null, key); + check( + header()?.getAttribute("aria-expanded") === "false" && !visible(process()), + `${mode} completion folds the entire interacted process`, + ); + check( + container.querySelector('[data-message-id="denied-write"]') === nestedRow, + `${mode} completion does not remount nested process children`, + ); + check( + document.activeElement === header(), + `${mode} completion returns nested focus to the process header`, + ); + const foldedMarker = header()?.querySelector(".turn-process-error"); + check( + Boolean(foldedMarker) && !(foldedMarker?.textContent || "").trim(), + `${mode} completed folded header keeps an icon-only failure marker`, + ); + check( + visible(container.querySelector('[data-message-id="recovered-answer"]')), + `${mode} recovered final answer remains outside the process`, + ); + click(header()); + check( + header()?.getAttribute("aria-expanded") === "true" && + Boolean(header()?.querySelector(".turn-process-error")) && + Boolean(process()?.querySelector('[data-message-id="denied-write"]')), + `${mode} reopening preserves the failure marker and tool details`, + ); + render( + [ + ...activeRecovery, + { ...recoveredAnswer, responseDurationMs: 1000 }, + ], + false, + null, + key, + ); + check( + header()?.getAttribute("aria-expanded") === "true" && visible(process()), + `${mode} completed manual reopen survives unrelated updates`, + ); + click(header()); + render( + completedRecovery, + false, + { + sessionId: "s", + messageId: "denied-write", + query: "permission", + requestId, + }, + key, + ); + check( + header()?.getAttribute("aria-expanded") === "true" && + visible(container.querySelector('[data-message-id="denied-write"]')), + `${mode} search reveals a completed folded process`, + ); + }; try { render(messages); check( container.querySelectorAll(".turn-process").length === 1, - "detailed wraps one process per turn", + "detailed groups one process per turn", ); check( - header()?.getAttribute("aria-expanded") === "true" && visible(process()), - "detailed starts the process open", + header()?.getAttribute("aria-expanded") === "false" && !visible(process()), + "detailed completed process starts collapsed", ); check( visible(container.querySelector('[data-message-id="answer"]')), - "final answer stays visible", + "final answer stays visible outside the process", ); + check( + !visible(container.querySelector('[data-message-id="progress"]')), + "completed progress starts folded", + ); + check( + header()?.textContent?.includes("2 tools"), + "detailed process counts reasoning, tools, and progress once", + ); + check( + header()?.textContent?.includes( + i18n.t("chat.processedFor", { time: "4s" }), + ), + "completed process header shows elapsed time", + ); + click(header()); check( visible(container.querySelector('[data-message-id="progress"]')), - "detailed keeps intermediate progress visible", + "detailed keeps completed progress recoverable", ); check( container.querySelector('[data-message-id="edit"]')?.classList.contains("open") === true, - "detailed opens the last tool", + "detailed opens the last tool inside the process", ); check( container.querySelector('[data-message-id="read"]')?.classList.contains("open") !== true, "detailed keeps earlier tools collapsed", ); check( - container.querySelectorAll(".tool-row").length === 3, - "detailed shows thinking and both tools in place", + process()?.querySelectorAll(".tool-row").length === 3, + "detailed keeps completed thinking and tools recoverable", ); render( [intro, { ...read, toolStatus: "error", isError: true }, answer], @@ -149,6 +368,73 @@ export async function turnProcessProbe() { "detailed keeps a last failed tool collapsed", ); + + render(activeSteered, true, null, "steering-lifecycle"); + check( + container.querySelectorAll(".turn-process").length === 1 && + header()?.getAttribute("aria-expanded") === "true", + "marked steering keeps earlier and later work in one active process", + ); + const readRow = container.querySelector('[data-message-id="read"]'); + const steeringRow = container.querySelector('[data-message-id="steering-supplement"]'); + const editRow = container.querySelector('[data-message-id="edit"]'); + check( + Boolean( + readRow && + steeringRow && + editRow && + (readRow.compareDocumentPosition(steeringRow) & + Node.DOCUMENT_POSITION_FOLLOWING) && + (steeringRow.compareDocumentPosition(editRow) & + Node.DOCUMENT_POSITION_FOLLOWING), + ), + "expanded process preserves work, steering bubble, continuation order", + ); + check( + steeringRow?.querySelector(".chat-file-chip")?.textContent?.includes("notes.txt") === true, + "embedded steering keeps user attachments", + ); + render(completedSteered, false, null, "steering-lifecycle"); + check( + header()?.getAttribute("aria-expanded") === "false" && + !visible(container.querySelector('[data-message-id="steering-supplement"]')) && + visible(container.querySelector('[data-message-id="answer"]')), + "completed steering process folds while the final answer stays outside", + ); + render( + completedSteered, + false, + { + sessionId: "s", + messageId: "steering-supplement", + query: "attachment", + requestId: 103, + }, + "steering-lifecycle", + ); + check( + header()?.getAttribute("aria-expanded") === "true" && + visible(container.querySelector('[data-message-id="steering-supplement"]')), + "search reveals a steering bubble inside the folded process", + ); + render(preOutputSteered, false, null, "steering-before-output"); + const steeringAnchor = container.querySelector( + '[data-minimap-id="steering-supplement"]', + ); + const assistantAnchor = container.querySelector('[data-minimap-id="answer"]'); + check( + steeringAnchor === null && + container.querySelectorAll('[data-minimap-id="answer"]').length === 1 && + visible(assistantAnchor) && + !visible(container.querySelector('[data-message-id="steering-supplement"]')), + "grouped steering has no hidden minimap anchor while the assistant anchor stays visible", + ); + renderUserMessage(steering); + check( + container.querySelectorAll('[data-minimap-id="steering-supplement"]').length === 1 && + visible(container.querySelector('[data-minimap-id="steering-supplement"]')), + "standalone leading steering keeps a visible minimap anchor", + ); const streaming = message("stream", "assistant", "Live text", { status: "streaming", }); @@ -161,7 +447,7 @@ export async function turnProcessProbe() { check( header()?.getAttribute("aria-expanded") === "true" && visible(container.querySelector('[data-message-id="stream"]')), - "detailed keeps streamed text visible after later tools", + "later tools move provisional text into the expanded process", ); render([intro, read, { ...answer, status: "aborted" }], false, null, "aborted"); check( @@ -185,11 +471,8 @@ export async function turnProcessProbe() { "failure remains visible", ); - flushSync(() => - useAppStore.setState({ - settings: { ...settings, thinkingDisplayMode: "compact" }, - }), - ); + verifyCompletionLifecycle("detailed", 101); + verifyCompletionLifecycle("compact", 102); render(messages, false, null, "compact-group"); check( container.querySelectorAll(".turn-process").length === 1, @@ -216,17 +499,23 @@ export async function turnProcessProbe() { process()?.querySelectorAll(".tool-row").length === 2, "compact expanding retains both tools without thinking", ); - render(messages, true, null, "compact-group"); - render(messages, false, null, "compact-group"); + render(messages, true, null, "compact-group-reset"); + click(container.querySelector('[data-message-id="read"] .tool-row-header')); + render(messages, false, null, "compact-group-reset"); check( - header()?.getAttribute("aria-expanded") === "true", - "manual disclosure survives active-to-complete transition", + header()?.getAttribute("aria-expanded") === "false" && !visible(process()), + "active-to-complete transition resets a previously opened process", ); render([intro, read], true, null, "live"); check( - header()?.getAttribute("aria-expanded") === "false", - "compact live process stays collapsed without a tool failure", + header()?.getAttribute("aria-expanded") === "true" && visible(process()), + "compact live process opens automatically", + ); + render(messages, false, null, "live"); + check( + header()?.getAttribute("aria-expanded") === "false" && !visible(process()), + "compact live process collapses after completion", ); render( messages, @@ -285,13 +574,16 @@ export async function turnProcessProbe() { render( [intro, { ...read, toolStatus: "error", isError: true }, answer], - true, + false, null, "tool-error", ); check( - header()?.getAttribute("aria-expanded") === "true" && visible(process()), - "an active tool failure opens an unclaimed process", + header()?.getAttribute("aria-expanded") === "false" && + !visible(process()) && + Boolean(header()?.querySelector(".turn-process-error")) && + !(header()?.querySelector(".turn-process-error")?.textContent || "").trim(), + "completed tool failures stay folded with an icon-only header marker", ); let saved: Partial | undefined; @@ -339,7 +631,7 @@ export async function turnProcessProbe() { Boolean(header()) && !container.textContent?.includes("hidden thinking words"), "compact live thinking has status without reasoning text", ); - click(header()); + check(header()?.getAttribute("aria-expanded") === "true", "compact live process starts expanded"); check( visible(container.querySelector(".thinking-compact")), "compact live thinking remains indicator-only when expanded", @@ -375,14 +667,286 @@ export async function turnProcessProbe() { Boolean(container.querySelector(".thinking")), "mode changes update mounted history", ); + check( + header()?.getAttribute("aria-expanded") === "true" && visible(process()), + "mode changes preserve a completed manual reopen", + ); check( messages[0].thinking === "reasoning detail", "presentation never deletes reasoning data", ); + + render(messages, false, null, "completed-timestamp"); + const assistantTime = container.querySelector( + ".assistant-turn .message-actions .message-timestamp", + ); + const assistantActions = container.querySelector( + ".assistant-turn .message-actions", + ); + const expectedEnd = new Intl.DateTimeFormat( + i18n.resolvedLanguage ?? i18n.language, + { dateStyle: "medium", timeStyle: "short" }, + ).format(new Date("2026-09-17T00:00:04.000Z")); + check( + assistantTime?.dateTime === "2026-09-17T00:00:04.000Z" && + assistantTime.textContent === expectedEnd && + Boolean(assistantActions?.contains(assistantTime)) && + getComputedStyle(assistantActions!).opacity === "0", + "completed assistant timestamp lives in hidden hover action chrome", + ); + document.documentElement.classList.add("pointer-outside"); + check( + getComputedStyle(assistantActions!).opacity === "0", + "assistant timestamp stays hidden when the pointer leaves the window", + ); + document.documentElement.classList.remove("pointer-outside"); + + const userTimestamp = "2026-09-17T13:45:00.000Z"; + renderUserMessage( + message("timestamp-user", "user", "Timestamped request", { + createdAt: userTimestamp, + }), + ); + const time = container.querySelector(".message-timestamp"); + const actions = container.querySelector(".message-actions"); + const bubble = container.querySelector(".message-bubble"); + const expectedTimestamp = new Intl.DateTimeFormat( + i18n.resolvedLanguage ?? i18n.language, + { dateStyle: "medium", timeStyle: "short" }, + ).format(new Date(userTimestamp)); + check( + time?.dateTime === userTimestamp && + time.textContent === expectedTimestamp && + Boolean(actions?.contains(time)) && + Boolean( + bubble && + (bubble.compareDocumentPosition(time) & + Node.DOCUMENT_POSITION_FOLLOWING), + ) && + getComputedStyle(actions!).opacity === "0", + "user timestamp is semantic, localized, and only in the hover action chrome", + ); + document.documentElement.classList.add("pointer-outside"); + check( + getComputedStyle(actions!).opacity === "0", + "user timestamp stays hidden when the pointer leaves the window", + ); + document.documentElement.classList.remove("pointer-outside"); + renderUserMessage( + message("invalid-timestamp", "user", "Invalid timestamp", { + createdAt: "not-a-date", + }), + ); + check( + !container.querySelector(".message-timestamp"), + "invalid user timestamp is omitted", + ); + const delegatedSessionId = "delegated-edit-summary"; + const delegatedTaskCallId = "task-call-delegated-edit"; + const delegatedEditMessageId = "delegated-edit-main"; + const delegatedSnapshotId = "snapshot-delegated-edit-main"; + const delegatedMessages: UiMessage[] = [ + message("delegated-user", "user", "Update the Java entry point"), + message("delegated-task", "tool", "Delegated successfully", { + toolName: "Task", + toolCallId: delegatedTaskCallId, + toolStatus: "success", + toolArgs: { + subagent_type: "fixer", + description: "Update Main.java", + prompt: "Apply the requested Java change.", + }, + toolResult: { + details: { delegationId: "delegated-run", status: "completed" }, + }, + }), + message(delegatedEditMessageId, "tool", "Updated src/Main.java", { + parentToolCallId: delegatedTaskCallId, + agentName: "fixer", + toolName: "Edit", + toolCallId: "delegated-native-edit-call", + toolStatus: "success", + toolArgs: { path: "src/Main.java" }, + toolResult: { + details: { + root: "workspace", + review: review( + delegatedEditMessageId, + delegatedSnapshotId, + "src/Main.java", + 3, + 1, + ), + }, + }, + }), + message("parent-gradle-build", "tool", "Build completed", { + toolName: "Bash", + toolCallId: "parent-gradle-build-call", + toolStatus: "success", + toolArgs: { command: ".\\gradlew.bat build" }, + toolResult: { + details: { + root: "workspace", + exitCode: 0, + review: review( + "parent-gradle-build", + "snapshot-gradle-current", + ".gradle/8.10/fileHashes/fileHashes.bin", + 0, + 0, + { binary: true }, + ), + reviews: [ + review( + "parent-gradle-build", + "snapshot-gradle-history", + "module/.gradle/buildOutputCleanup/cache.properties", + 1, + 1, + ), + ], + reviewCapture: { status: "complete" }, + }, + }, + }), + message("delegated-final", "assistant", "The Java change is complete.", { + status: "complete", + }), + message("delegated-resume-user", "user", "Continue the same delegate"), + message("delegated-resume-task", "tool", "Resumed successfully", { + toolName: "Task", + toolCallId: "task-call-delegated-resume", + toolStatus: "success", + toolArgs: { agent: "fixer", resume: "delegated-run" }, + toolResult: { + details: { delegationId: "delegated-resume-run", status: "completed" }, + }, + }), + message("delegated-resume-final", "assistant", "No further edits needed.", { + status: "complete", + }), + ]; + let rollbackInput: { sessionId: string; snapshotId: string } | undefined; + let rollbackTarget: { messageId: string; snapshotId: string } | undefined; + const rollbackWorkspaceChange = initialState.rollbackWorkspaceChange; + window.piDesktop = { + invoke: async (_channel, input) => { + rollbackInput = input as { sessionId: string; snapshotId: string }; + return { + ok: true, + data: { + status: "rolledBack", + snapshotId: rollbackInput.snapshotId, + }, + }; + }, + on: () => () => undefined, + channels: + previousBridge?.channels ?? + ({} as NonNullable["channels"]), + platform: "win32", + }; + flushSync(() => + useAppStore.setState({ + activeSessionId: delegatedSessionId, + isRunning: false, + messages: delegatedMessages, + workPanelOpen: false, + workPanelTabs: [], + activeWorkPanelTabId: null, + workPanelContexts: {}, + rollbackWorkspaceChange: async (messageId, snapshotId) => { + rollbackTarget = { messageId, snapshotId }; + return rollbackWorkspaceChange(messageId, snapshotId); + }, + }), + ); + flushSync(() => + root.render( + + + , + ), + ); + const delegatedSummary = container.querySelector( + ".turn-file-summary", + ); + const delegatedFile = container.querySelector( + ".turn-file-summary-file-header", + ); + check( + delegatedSummary?.getAttribute("aria-label") === + i18n.t("chat.turnFilesEdited", { count: 1 }) && + container.querySelectorAll(".turn-file-summary-file-header").length === 1, + "delegated native edit contributes the only summarized file", + ); + check( + delegatedFile?.querySelector(".turn-file-summary-path")?.textContent === + "src/Main.java" && + !delegatedSummary?.textContent?.includes(".gradle"), + "summary keeps the delegated source path and excludes parent Gradle caches", + ); + check( + delegatedSummary + ?.querySelector(".turn-file-summary-totals") + ?.getAttribute("aria-label") === + i18n.t("chat.turnFilesEditTotalsLabel", { additions: 3, deletions: 1 }) && + delegatedFile?.getAttribute("aria-label") === + `src/Main.java · ${i18n.t("chat.reviewChangeCounts", { + additions: 3, + deletions: 1, + })}`, + "delegated native edit reports its actual addition and deletion totals", + ); + click(delegatedFile); + await painted(); + const reviewSelection = + useAppStore.getState().workPanelContexts[delegatedSessionId]?.reviewSelection; + check( + useAppStore.getState().activeWorkPanelTabId === "review" && + reviewSelection?.selectedPath === "src/Main.java" && + reviewSelection.snapshotIds.length === 1 && + reviewSelection.snapshotIds[0] === delegatedSnapshotId, + "delegated summary click opens Review with the original snapshot", + ); + check( + container.querySelectorAll(".review-scroll .review-change-card").length === 1 && + container + .querySelector(".review-scroll .review-change-card-path") + ?.textContent?.includes("src/Main.java") === true, + "Review renders only the selected delegated native edit", + ); + const delegatedRollback = container.querySelector( + ".review-scroll .review-change-rollback", + ); + click(delegatedRollback); + await painted(); + const rolledBackDelegate = useAppStore + .getState() + .messages.find((item) => item.id === delegatedEditMessageId); + check( + rollbackTarget?.messageId === delegatedEditMessageId && + rollbackTarget.snapshotId === delegatedSnapshotId && + rollbackInput?.sessionId === delegatedSessionId && + rollbackInput.snapshotId === delegatedSnapshotId && + Boolean( + rolledBackDelegate && + reviewChangesFromMessage(rolledBackDelegate)[0]?.state === "rolledBack", + ), + "Review rollback preserves the delegated message id and snapshot id", + ); + check( + container.querySelector(".turn-file-summary-totals")?.getAttribute("aria-label") === + i18n.t("chat.turnFilesEditTotalsLabel", { additions: 0, deletions: 0 }), + "rollback updates the original turn summary after the delegate card moves to a resumed turn", + ); + return { ok: true, checks: notes }; } finally { flushSync(() => root.unmount()); - useAppStore.setState({ settings: initialSettings }); + useAppStore.setState(initialState, true); + window.piDesktop = previousBridge; container.remove(); } }