diff --git a/word-addin/e2e/chat-layout.spec.ts b/word-addin/e2e/chat-layout.spec.ts index 6ffbae8e1..668090762 100644 --- a/word-addin/e2e/chat-layout.spec.ts +++ b/word-addin/e2e/chat-layout.spec.ts @@ -449,6 +449,188 @@ test("keeps the submitted turn at 80px while Working becomes Completed", async ( ).toBeLessThanOrEqual(4); }); +test("keeps the pinned turn steady when a tall activity strip completes", async ({ + addin, + page, +}) => { + addin.seedToken(TOKEN); + await page.setViewportSize({ width: 420, height: 720 }); + + const firstResponse = Array.from( + { length: 30 }, + (_, index) => + `Existing response paragraph ${index + 1} makes the transcript tall enough to exercise a live anchored follow-up.`, + ).join("\n\n"); + await addin.mockChatStream([firstResponse]); + await addin.gotoTaskpane({ + documentText: "A contract body for the tall-activity completion test.", + }); + await addin.expectAuthedShell(); + + await page.getByPlaceholder("Ask Mike…").fill("Initial layout question"); + await page.getByRole("button", { name: "Send" }).click(); + await expect( + page.getByText("Existing response paragraph 30", { exact: false }), + ).toBeVisible(); + + // Controlled stream that opens with a document read plus a multi-step + // reasoning block, then finishes into prose on demand. The moment the strip + // flips from "Working" to "Completed in N steps" its open activity collapses + // — WebKit's scroll anchoring (which ignores `overflow-anchor: none`) + // responds to that descendant resize by adjusting scrollTop, which is + // exactly the jump this test pins down. Chromium honours overflow-anchor, + // so the assertion only bites under the webkit project. + await page.evaluate(() => { + type ControlledStreamWindow = Window & { + __WORD_TALL_STREAM_READY__?: boolean; + __WORD_TALL_STREAM_EMIT__?: (event: object) => void; + __WORD_TALL_STREAM_DONE__?: () => void; + }; + const controlledWindow = window as ControlledStreamWindow; + const nativeFetch = window.fetch.bind(window); + window.fetch = (input, init) => { + const request = input instanceof Request ? input : null; + const url = new URL(request?.url ?? String(input), window.location.href); + const method = (init?.method ?? request?.method ?? "GET").toUpperCase(); + if (url.pathname !== "/word-chat" || method !== "POST") { + return nativeFetch(input, init); + } + + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + const emit = (event: object): void => { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(event)}\n\n`), + ); + }; + emit({ + type: "doc_read_start", + filename: "Agreement.docx", + }); + for (let line = 1; line <= 50; line += 1) { + emit({ + type: "reasoning_delta", + text: `Considering clause ${line} of the agreement in careful detail before drafting.\n\n`, + }); + } + controlledWindow.__WORD_TALL_STREAM_EMIT__ = emit; + controlledWindow.__WORD_TALL_STREAM_DONE__ = () => { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }; + controlledWindow.__WORD_TALL_STREAM_READY__ = true; + }, + }); + + return Promise.resolve( + new Response(body, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }, + }), + ); + }; + }); + + const prompt = "Inspect the tall-activity completion"; + await page.getByPlaceholder("Ask Mike…").fill(prompt); + await page.getByRole("button", { name: "Send" }).click(); + await expect + .poll(() => + page.evaluate( + () => + !!(window as Window & { __WORD_TALL_STREAM_READY__?: boolean }) + .__WORD_TALL_STREAM_READY__, + ), + ) + .toBe(true); + + const anchoredMessage = page + .getByText(prompt, { exact: true }) + .locator("xpath=ancestor::*[@data-message-id][1]"); + const assistantTurn = anchoredMessage.locator( + "xpath=following-sibling::div[1]", + ); + await expect( + assistantTurn.getByRole("button", { name: "Working" }), + ).toBeVisible(); + await expect( + assistantTurn.getByText("Considering clause 50", { exact: false }), + ).toBeVisible(); + + const readPosition = async () => { + return anchoredMessage.evaluate((message) => { + let container = message.parentElement; + while (container) { + const overflowY = getComputedStyle(container).overflowY; + if (overflowY === "auto" || overflowY === "scroll") { + return { + userTop: message.getBoundingClientRect().top, + containerTop: container.getBoundingClientRect().top, + scrollTop: container.scrollTop, + }; + } + container = container.parentElement; + } + throw new Error("Scrollable chat transcript was not found."); + }); + }; + + // Let the pin scroll fully settle before sampling the streaming layout. + await page.waitForTimeout(900); + const workingPosition = await readPosition(); + + await page.evaluate(() => { + const controlledWindow = window as Window & { + __WORD_TALL_STREAM_EMIT__?: (event: object) => void; + __WORD_TALL_STREAM_DONE__?: () => void; + }; + controlledWindow.__WORD_TALL_STREAM_EMIT__?.({ + type: "reasoning_block_end", + }); + controlledWindow.__WORD_TALL_STREAM_EMIT__?.({ + type: "doc_read", + filename: "Agreement.docx", + }); + controlledWindow.__WORD_TALL_STREAM_EMIT__?.({ + type: "content_delta", + text: "The agreement review is complete.", + }); + controlledWindow.__WORD_TALL_STREAM_DONE__?.(); + }); + + await expect( + assistantTurn.getByRole("button", { name: /Completed in \d+ steps?/ }), + ).toBeVisible(); + await expect( + assistantTurn.getByText("The agreement review is complete."), + ).toBeVisible(); + await page.waitForTimeout(550); + + const completedPosition = await readPosition(); + console.log("WEBKIT_TALL_COMPLETION_DIAGNOSTIC", { + workingPosition, + completedPosition, + }); + // The pinned turn must sit on the container's 80px pin line while the tall + // strip streams, and must not move when completion collapses the strip. + expect( + Math.abs(workingPosition.userTop - (workingPosition.containerTop + 80)), + ).toBeLessThanOrEqual(4); + expect( + Math.abs(completedPosition.userTop - (completedPosition.containerTop + 80)), + ).toBeLessThanOrEqual(4); + expect( + Math.abs(completedPosition.userTop - workingPosition.userTop), + ).toBeLessThanOrEqual(4); + expect( + Math.abs(completedPosition.scrollTop - workingPosition.scrollTop), + ).toBeLessThanOrEqual(4); +}); + test("jumps straight to the current bottom and does not follow later stream growth", async ({ addin, page, @@ -590,13 +772,18 @@ test("jumps straight to the current bottom and does not follow later stream grow }, laterGrowth); await expect(scrollButton).toBeVisible(); + // WKWebView can briefly drag a bottom-resting scroller along with streamed + // growth before the engine-scroll corrector snaps it back, so assert the + // settled position rather than an instantaneous sample. + await expect + .poll(() => container.evaluate((element) => Math.round(element.scrollTop))) + .toBe(Math.round(atBottom.scrollTop)); const afterGrowth = await container.evaluate((element) => ({ scrollTop: element.scrollTop, bottomDistance: element.scrollHeight - element.scrollTop - element.clientHeight, })); console.log("WEBKIT_ARROW_DIAGNOSTIC", { atBottom, afterGrowth }); - expect(Math.abs(afterGrowth.scrollTop - atBottom.scrollTop)).toBeLessThan(2); expect(afterGrowth.bottomDistance).toBeGreaterThan(10); await page.evaluate(() => { diff --git a/word-addin/e2e/message-events.spec.ts b/word-addin/e2e/message-events.spec.ts index 6f3278edf..98e7f514f 100644 --- a/word-addin/e2e/message-events.spec.ts +++ b/word-addin/e2e/message-events.spec.ts @@ -68,12 +68,14 @@ test.describe("Word assistant message events", () => { filename: "Agreement.docx", documentId: "document-1", status: "reading", + key: expect.any(String), }, { type: "doc_read", filename: "Agreement.docx", documentId: "document-2", status: "read", + key: expect.any(String), }, ]); }); @@ -91,14 +93,23 @@ test.describe("Word assistant message events", () => { events = appendAssistantContent(events, "The agreement has three risks."); expect(events).toEqual([ - { type: "content", text: "I’ll inspect the document." }, + { + type: "content", + text: "I’ll inspect the document.", + key: expect.any(String), + }, { type: "doc_read", filename: "Agreement.docx", documentId: "document-1", status: "read", + key: expect.any(String), + }, + { + type: "content", + text: "The agreement has three risks.", + key: expect.any(String), }, - { type: "content", text: "The agreement has three risks." }, ]); }); @@ -114,14 +125,23 @@ test.describe("Word assistant message events", () => { type: "reasoning", text: "Inspect the agreement.", isStreaming: true, + key: expect.any(String), }, ]); events = finishAssistantReasoning(events); events = appendAssistantContent(events, "The agreement is valid."); expect(events).toEqual([ - { type: "reasoning", text: "Inspect the agreement." }, - { type: "content", text: "The agreement is valid." }, + { + type: "reasoning", + text: "Inspect the agreement.", + key: expect.any(String), + }, + { + type: "content", + text: "The agreement is valid.", + key: expect.any(String), + }, ]); }); @@ -199,8 +219,9 @@ test.describe("Word assistant message events", () => { filename: "Complete.docx", documentId: "complete", status: "read", + key: expect.any(String), }, - { type: "content", text: "Done" }, + { type: "content", text: "Done", key: expect.any(String) }, ]); }); }); diff --git a/word-addin/src/shared/chat/Markdown.tsx b/word-addin/src/shared/chat/Markdown.tsx index d2a583bbd..49c41e87c 100644 --- a/word-addin/src/shared/chat/Markdown.tsx +++ b/word-addin/src/shared/chat/Markdown.tsx @@ -1,122 +1,114 @@ -import ReactMarkdown from "react-markdown"; +import { memo } from "react"; +import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; +// Hoisted so react-markdown receives referentially stable props — an inline +// plugins array/components object defeats its internal memoization and forces +// a full re-parse of every message on every transcript render. +const REMARK_PLUGINS = [remarkGfm]; + +const MARKDOWN_COMPONENTS: Components = { + p: ({ children }) => ( +

{children}

+ ), + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), + ul: ({ children }) => ( + + ), + ol: ({ children }) => ( +
    {children}
+ ), + li: ({ children }) =>
  • {children}
  • , + a: ({ children, href }) => ( + + {children} + + ), + strong: ({ children }) => ( + {children} + ), + em: ({ children }) => {children}, + blockquote: ({ children }) => ( +
    + {children} +
    + ), + code: ({ className: codeClass, children }) => { + const isBlock = (codeClass ?? "").includes("language-"); + if (isBlock) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + pre: ({ children }) =>
    {children}
    , + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + {children} + ), + hr: () =>
    , +}; + /** * Markdown renderer for the Word add-in's chat content. A trimmed, * dependency-light adaptation of the web assistant's renderer * (frontend/src/app/components/assistant/message/MarkdownContent.tsx: * react-markdown + remark-gfm) with explicit element styling instead of the * Tailwind typography plugin, and without the web's math/citation handling. + * Memoized: parsing is linear in the answer's length, so settled messages + * must not re-parse when the transcript re-renders around them. */ -export function Markdown({ - children, - className, +export const Markdown = memo(function Markdown({ + children, + className, }: { - children: string; - className?: string; + children: string; + className?: string; }) { - return ( -
    - ( -

    - {children} -

    - ), - h1: ({ children }) => ( -

    - {children} -

    - ), - h2: ({ children }) => ( -

    - {children} -

    - ), - h3: ({ children }) => ( -

    - {children} -

    - ), - ul: ({ children }) => ( -
      - {children} -
    - ), - ol: ({ children }) => ( -
      - {children} -
    - ), - li: ({ children }) =>
  • {children}
  • , - a: ({ children, href }) => ( - - {children} - - ), - strong: ({ children }) => ( - {children} - ), - em: ({ children }) => {children}, - blockquote: ({ children }) => ( -
    - {children} -
    - ), - code: ({ className: codeClass, children }) => { - const isBlock = (codeClass ?? "").includes("language-"); - if (isBlock) { - return ( - - {children} - - ); - } - return ( - - {children} - - ); - }, - pre: ({ children }) => ( -
    {children}
    - ), - table: ({ children }) => ( -
    - - {children} -
    -
    - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - - {children} - - ), - hr: () =>
    , - }} - > - {children} -
    -
    - ); -} + return ( +
    + + {children} + +
    + ); +}); diff --git a/word-addin/src/taskpane/components/assistant/AssistantMessage.tsx b/word-addin/src/taskpane/components/assistant/AssistantMessage.tsx index 7059b6fb1..01d048449 100644 --- a/word-addin/src/taskpane/components/assistant/AssistantMessage.tsx +++ b/word-addin/src/taskpane/components/assistant/AssistantMessage.tsx @@ -51,9 +51,7 @@ type EventGroup = | { kind: "pre"; events: ( - | WordThinkingEvent - | WordReasoningEvent - | WordDocumentReadEvent + WordThinkingEvent | WordReasoningEvent | WordDocumentReadEvent )[]; indices: number[]; } @@ -90,7 +88,7 @@ function groupAssistantEvents(events: WordAssistantEvent[]): EventGroup[] { return groups; } -export function AssistantMessage({ +function AssistantMessageImpl({ message, isStreaming, minHeight, @@ -99,14 +97,19 @@ export function AssistantMessage({ onResolveEdit, onResolveAll, }: AssistantMessageProps): React.ReactElement { - const content = assistantContent(message); + const content = React.useMemo(() => assistantContent(message), [message]); const error = assistantError(message); const responseStatus: StatusState = error ? "error" : isStreaming ? "active" : null; - const projection = projectRedlineStream(content, !isStreaming); + // Re-projecting the full answer is linear in its length; memoize so edit + // runtime updates (editStateByKey) do not re-parse an unchanged transcript. + const projection = React.useMemo( + () => projectRedlineStream(content, !isStreaming), + [content, isStreaming], + ); const edits: StreamingRedlineEdit[] = projection.edits; const editRows = edits.map((edit, editIndex) => { const key = getEditKey(message.id, edit.blockIndex); @@ -157,26 +160,34 @@ export function AssistantMessage({ const summaryReady = edits.length === 0 || (!isStreaming && !hasUnfinishedEdit); - const groups = groupAssistantEvents(message.events); - const lastContentEventIndex = message.events.reduce( - (last, event, index) => (isWordContentEvent(event) ? index : last), - -1, - ); - const contentProjectionByIndex = new Map( - message.events.flatMap((event, index) => - isWordContentEvent(event) - ? [ - [ - index, - projectRedlineStream( - event.text, - !isStreaming || index !== lastContentEventIndex, - ), - ] as const, - ] - : [], - ), - ); + const { groups, lastContentEventIndex, contentProjectionByIndex } = + React.useMemo(() => { + const eventGroups = groupAssistantEvents(message.events); + const lastContentIndex = message.events.reduce( + (last, event, index) => (isWordContentEvent(event) ? index : last), + -1, + ); + const projectionByIndex = new Map( + message.events.flatMap((event, index) => + isWordContentEvent(event) + ? [ + [ + index, + projectRedlineStream( + event.text, + !isStreaming || index !== lastContentIndex, + ), + ] as const, + ] + : [], + ), + ); + return { + groups: eventGroups, + lastContentEventIndex: lastContentIndex, + contentProjectionByIndex: projectionByIndex, + }; + }, [message.events, isStreaming]); const editSourceEventIndex = message.events.findIndex( (event, index) => isWordContentEvent(event) && @@ -237,7 +248,7 @@ export function AssistantMessage({ groupIndex >= editInsertionGroupIndex && !summaryReady; return ( - + {insertStandaloneEdit} {prose && !holdForEdit && (
    @@ -259,7 +270,9 @@ export function AssistantMessage({ ) || (includesEdit && hasUnfinishedEdit); return ( - + {insertStandaloneEdit} ); } + +// Streaming commits replace only the live assistant row's message object; +// memoizing here keeps every settled row (and its full Markdown re-parse) +// out of the per-chunk render entirely. +export const AssistantMessage = React.memo(AssistantMessageImpl); diff --git a/word-addin/src/taskpane/components/assistant/ChatView.tsx b/word-addin/src/taskpane/components/assistant/ChatView.tsx index dd0920a8b..4f76eff10 100644 --- a/word-addin/src/taskpane/components/assistant/ChatView.tsx +++ b/word-addin/src/taskpane/components/assistant/ChatView.tsx @@ -12,6 +12,7 @@ import type { ChatInputHandle } from "./ChatInput"; import { InitialView } from "./InitialView"; import { UserMessage } from "./UserMessage"; import type { + EditDecision, WordAssistantChatController, WordTrackedEditsController, WorkflowAttachment, @@ -63,17 +64,27 @@ function measureSpacerPx( const PIN_SCROLL_DURATION_MS = 200; +// How long after a user input its scroll events remain user-owned. Wheel and +// keyboard repeat within this window; macOS momentum wheel events keep +// re-arming it. Touch momentum outlives the last touch event, so touch +// releases arm a longer window. +const USER_SCROLL_GRACE_MS = 250; +const TOUCH_SCROLL_GRACE_MS = 2000; + /** * rAF-driven pin scroll. Native `scrollTo({behavior: "smooth"})` is not * animated in every Office webview (WKWebView can execute it as an instant * jump), so the pin scroll drives its own easing. Matching native semantics, * the target is clamped to the scroll range ONCE at start and never extended: * content that streams in later must not move a settled transcript. Returns a - * cancel function; ChatView owns all user-input cancellation. + * cancel function; ChatView owns all user-input cancellation. `onFrame` + * reports every position the animation writes, so the caller can track the + * last application-owned scroll position frame by frame. */ function animateScrollTo( container: HTMLElement, targetTop: number, + onFrame?: (top: number) => void, duration = PIN_SCROLL_DURATION_MS, ): () => void { const startTop = container.scrollTop; @@ -86,6 +97,7 @@ function animateScrollTo( window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reducedMotion || duration <= 0) { container.scrollTop = clampedTarget; + onFrame?.(clampedTarget); return () => {}; } const startTime = performance.now(); @@ -99,8 +111,10 @@ function animateScrollTo( const step = (now: number): void => { if (done) return; const progress = Math.min(1, (now - startTime) / duration); - container.scrollTop = + const nextTop = startTop + (clampedTarget - startTop) * easeOutCubic(progress); + container.scrollTop = nextTop; + onFrame?.(nextTop); if (progress < 1) { frame = requestAnimationFrame(step); } else { @@ -150,6 +164,13 @@ export function ChatView({ // transcript disables CSS scroll anchoring. Keep the last application- or // user-owned position so a resize can restore it explicitly. const desiredScrollTopRef = useRef(0); + // Scroll ownership for engine-scroll correction: while a pointer/touch is + // held, or briefly after any user input, scroll events are the user's and + // desiredScrollTopRef follows them. Outside those windows a scroll event + // the application did not write is engine-initiated (WKWebView anchoring + // or bottom-follow) and gets snapped back to the owned position. + const userInputOwnedUntilRef = useRef(0); + const pointerHeldRef = useRef(false); const showScrollButtonRef = useRef(false); // False until the first positioning pass after mount / session switch. const hasPositionedRef = useRef(false); @@ -187,6 +208,26 @@ export function ChatView({ : null; const hasMessages = messages.length > 0; + // Stable handlers so memoized message rows do not re-render per chunk. + const handleViewEdit = useCallback( + (key: string) => { + void viewEdit(key); + }, + [viewEdit], + ); + const handleResolveEdit = useCallback( + (key: string, decision: EditDecision) => { + void resolveOneEdit(key, decision); + }, + [resolveOneEdit], + ); + const handleResolveAll = useCallback( + (keys: string[], decision: EditDecision) => { + void resolveMessageEdits(keys, decision); + }, + [resolveMessageEdits], + ); + const updateScrollButton = useCallback(() => { const container = messagesContainerRef.current; if (!container) return; @@ -205,10 +246,17 @@ export function ChatView({ useEffect(() => { const container = messagesContainerRef.current; if (!container) return; + const ownScrollForUser = (graceMs: number): void => { + userInputOwnedUntilRef.current = Math.max( + userInputOwnedUntilRef.current, + performance.now() + graceMs, + ); + }; const cancelForUser = (): void => { anchorActiveRef.current = false; cancelPinScrollRef.current?.(); cancelPinScrollRef.current = null; + ownScrollForUser(USER_SCROLL_GRACE_MS); // Wheel/touch/keyboard scrolling is applied after the input event. // Capture the resulting position on the next frame, before any // later streamed resize is allowed to preserve it. @@ -218,6 +266,16 @@ export function ChatView({ } }); }; + const holdPointer = (): void => { + pointerHeldRef.current = true; + cancelForUser(); + }; + const releasePointer = (graceMs: number) => (): void => { + pointerHeldRef.current = false; + ownScrollForUser(graceMs); + }; + const releaseTouch = releasePointer(TOUCH_SCROLL_GRACE_MS); + const releaseMouse = releasePointer(USER_SCROLL_GRACE_MS); const cancelForKeyboard = (event: KeyboardEvent): void => { if ( event.key === "ArrowUp" || @@ -231,22 +289,69 @@ export function ChatView({ cancelForUser(); } }; - container.addEventListener("scroll", updateScrollButton); + // WKWebView sometimes rewrites a scroller's position on its own — + // its scroll-anchoring heuristics run even though the transcript sets + // `overflow-anchor: none` (WebKit does not support the property): + // unmounting the node it anchored to can reset scrollTop to 0, and a + // bottom-resting scroller is dragged along as streamed content grows. + // Every position the application writes is mirrored into + // desiredScrollTopRef first, and user input marks its ownership + // window before its scroll events land — so any other scroll event is + // engine-initiated and gets snapped back to the owned position. + const correctEngineScroll = (): void => { + if (anchorActiveRef.current) { + const desired = desiredScrollTopRef.current; + if (Math.abs(container.scrollTop - desired) > 1) { + container.scrollTop = desired; + } + } else if ( + pointerHeldRef.current || + performance.now() <= userInputOwnedUntilRef.current + ) { + desiredScrollTopRef.current = container.scrollTop; + } else { + const preserved = Math.max( + 0, + Math.min( + desiredScrollTopRef.current, + container.scrollHeight - container.clientHeight, + ), + ); + if (Math.abs(container.scrollTop - preserved) > 1) { + desiredScrollTopRef.current = preserved; + container.scrollTop = preserved; + } + } + updateScrollButton(); + }; + container.addEventListener("scroll", correctEngineScroll); container.addEventListener("wheel", cancelForUser, { passive: true }); - container.addEventListener("touchstart", cancelForUser, { + container.addEventListener("touchstart", holdPointer, { passive: true, }); - container.addEventListener("pointerdown", cancelForUser, { + window.addEventListener("touchend", releaseTouch, { passive: true }); + window.addEventListener("touchcancel", releaseTouch, { + passive: true, + }); + container.addEventListener("pointerdown", holdPointer, { + passive: true, + }); + window.addEventListener("pointerup", releaseMouse, { passive: true }); + window.addEventListener("pointercancel", releaseMouse, { passive: true, }); window.addEventListener("keydown", cancelForKeyboard, true); // eslint-disable-next-line react-hooks/set-state-in-effect -- initial scroll-button state must be measured from the live DOM updateScrollButton(); return () => { - container.removeEventListener("scroll", updateScrollButton); + container.removeEventListener("scroll", correctEngineScroll); container.removeEventListener("wheel", cancelForUser); - container.removeEventListener("touchstart", cancelForUser); - container.removeEventListener("pointerdown", cancelForUser); + container.removeEventListener("touchstart", holdPointer); + window.removeEventListener("touchend", releaseTouch); + window.removeEventListener("touchcancel", releaseTouch); + container.removeEventListener("pointerdown", holdPointer); + window.removeEventListener("pointerup", releaseMouse); + window.removeEventListener("pointercancel", releaseMouse); window.removeEventListener("keydown", cancelForKeyboard, true); }; }, [hasMessages, updateScrollButton]); @@ -272,6 +377,8 @@ export function ChatView({ cancelPinScrollRef.current = null; anchorActiveRef.current = false; desiredScrollTopRef.current = 0; + userInputOwnedUntilRef.current = 0; + pointerHeldRef.current = false; spacerHandledIdRef.current = null; scrollHandledIdRef.current = null; hasPositionedRef.current = false; @@ -404,6 +511,9 @@ export function ChatView({ cancelPinScrollRef.current = animateScrollTo( container, targetTop, + (top) => { + desiredScrollTopRef.current = top; + }, ); }, []); @@ -604,13 +714,9 @@ export function ChatView({ : undefined } editStateByKey={editStateByKey} - onViewEdit={(key) => void viewEdit(key)} - onResolveEdit={(key, decision) => - void resolveOneEdit(key, decision) - } - onResolveAll={(keys, decision) => - void resolveMessageEdits(keys, decision) - } + onViewEdit={handleViewEdit} + onResolveEdit={handleResolveEdit} + onResolveAll={handleResolveAll} /> ); })} diff --git a/word-addin/src/taskpane/components/assistant/UserMessage.tsx b/word-addin/src/taskpane/components/assistant/UserMessage.tsx index 0aa8a372b..b66c7b6e6 100644 --- a/word-addin/src/taskpane/components/assistant/UserMessage.tsx +++ b/word-addin/src/taskpane/components/assistant/UserMessage.tsx @@ -6,8 +6,9 @@ const COLLAPSED_CONTENT_HEIGHT = 144; /** * Right-aligned user bubble, duplicated from the web app's UserMessage * including the same file/workflow chips used by the frontend assistant. + * Memoized: user turns never change while an answer streams below them. */ -export function UserMessage({ +function UserMessageImpl({ content, files, workflow, @@ -114,3 +115,5 @@ export function UserMessage({
    ); } + +export const UserMessage = React.memo(UserMessageImpl); diff --git a/word-addin/src/taskpane/components/assistant/message/messageStyles.ts b/word-addin/src/taskpane/components/assistant/message/messageStyles.ts index 5c544450c..e03e1add4 100644 --- a/word-addin/src/taskpane/components/assistant/message/messageStyles.ts +++ b/word-addin/src/taskpane/components/assistant/message/messageStyles.ts @@ -6,8 +6,11 @@ export const RESPONSE_GLASS_SURFACE = "rounded-xl border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl"; +// No backdrop-blur here: these surfaces are fully opaque (bg-white), so the +// blur is invisible while still costing a compositing layer per card in the +// Office WebView. export const EDIT_CARD_SURFACE = - "rounded-xl bg-white shadow-[0_3px_9px_rgba(15,23,42,0.1),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl"; + "rounded-xl bg-white shadow-[0_3px_9px_rgba(15,23,42,0.1),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)]"; export const EDIT_SECTION_SURFACE = - "rounded-xl bg-white shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl"; + "rounded-xl bg-white shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)]"; diff --git a/word-addin/src/taskpane/hooks/useWordAssistantChat.ts b/word-addin/src/taskpane/hooks/useWordAssistantChat.ts index a7e7b5fe7..bd655ccf9 100644 --- a/word-addin/src/taskpane/hooks/useWordAssistantChat.ts +++ b/word-addin/src/taskpane/hooks/useWordAssistantChat.ts @@ -59,6 +59,13 @@ export function useWordAssistantChat({ const [messages, setMessages] = useState([]); const [isResponseLoading, setIsResponseLoading] = useState(false); const [requestError, setRequestError] = useState(null); + // Render-synced mirrors so handleChat can read the latest transcript + // without depending on it — a per-chunk `messages` dependency changes the + // callback's identity on every streamed delta and re-renders the composer. + const messagesRef = useRef(messages); + messagesRef.current = messages; + const isResponseLoadingRef = useRef(isResponseLoading); + isResponseLoadingRef.current = isResponseLoading; const abortRef = useRef(null); const mountedRef = useRef(true); const sessionGenerationRef = useRef(0); @@ -106,7 +113,7 @@ export function useWordAssistantChat({ options: WordChatSubmitOptions = {}, ): Promise => { const text = submission.content.trim(); - if (!text || isResponseLoading || sendingRef.current) return; + if (!text || isResponseLoadingRef.current || sendingRef.current) return; const generation = sessionGenerationRef.current; const sendToken = sendSequenceRef.current + 1; @@ -150,7 +157,7 @@ export function useWordAssistantChat({ files: submission.files, workflow: submission.workflow, }; - const history = [...messages, userMessage]; + const history = [...messagesRef.current, userMessage]; const requestChatId = chatId ?? (wordChatStorage === "local" ? crypto.randomUUID() : undefined); @@ -195,7 +202,13 @@ export function useWordAssistantChat({ let streamedContent = ""; let completedDocReads: DocumentReadActivity[] = []; - const publishAssistantEvents = (): void => { + // Fast streams deliver many SSE events per frame; committing React + // state per event re-renders the transcript far more often than the + // screen can paint. Publishes coalesce onto one rAF, and the flush + // reads the live locals so it always commits the latest snapshot. + let publishFrame: number | null = null; + const flushAssistantEvents = (): void => { + publishFrame = null; const messageId = assistantMessageId; const eventSnapshot = assistantEvents; setMessages((current) => @@ -206,6 +219,17 @@ export function useWordAssistantChat({ ), ); }; + const publishAssistantEvents = (): void => { + if (publishFrame !== null) return; + publishFrame = requestAnimationFrame(flushAssistantEvents); + }; + const publishAssistantEventsNow = (): void => { + if (publishFrame !== null) { + cancelAnimationFrame(publishFrame); + publishFrame = null; + } + flushAssistantEvents(); + }; try { if (wordChatStorage === "local" && requestChatId) { await saveLocalWordMessage({ @@ -305,6 +329,7 @@ export function useWordAssistantChat({ ); }, ); + publishAssistantEventsNow(); // readSSE deliberately resolves normally when cancelling its reader. // Route that clean cancellation through the same abort cleanup below // as transports that reject with AbortError. @@ -337,6 +362,9 @@ export function useWordAssistantChat({ notifyWordChatHistoryChanged(); } } catch (error) { + // Commit whatever streamed before the failure so the terminal UI + // state below always builds on the latest transcript. + publishAssistantEventsNow(); const sessionIsCurrent = mountedRef.current && generation === sessionGenerationRef.current && @@ -445,8 +473,6 @@ export function useWordAssistantChat({ [ chatId, editController, - isResponseLoading, - messages, onChatIdChange, readDocumentText, wordChatOwnerId, diff --git a/word-addin/src/taskpane/lib/redline.ts b/word-addin/src/taskpane/lib/redline.ts index 9f6021de0..d569c8add 100644 --- a/word-addin/src/taskpane/lib/redline.ts +++ b/word-addin/src/taskpane/lib/redline.ts @@ -51,7 +51,8 @@ type FieldName = "original" | "replacement" | "reason"; // Tolerates list numbering ("1. ORIGINAL:") and Markdown bold ("**ORIGINAL:**") // in case the model decorates the mandated format. -const FIELD_LINE = /^\s*(?:\d+[.)]\s*)?\*{0,2}(ORIGINAL|REPLACEMENT|REASON)\*{0,2}\s*:\s*(.*)$/; +const FIELD_LINE = + /^\s*(?:\d+[.)]\s*)?\*{0,2}(ORIGINAL|REPLACEMENT|REASON)\*{0,2}\s*:\s*(.*)$/; const FIELD_NAMES = ["ORIGINAL", "REPLACEMENT", "REASON"] as const; @@ -93,7 +94,7 @@ interface MutableStreamingBlock { */ function projectLegacyRedlineStream( text: string, - streamComplete = false + streamComplete = false, ): RedlineStreamProjection { const visibleLines: string[] = []; const blocks: MutableStreamingBlock[] = []; @@ -162,9 +163,7 @@ function projectLegacyRedlineStream( blocks.forEach((block, blockIndex) => { const original = block.original?.trim() ?? ""; const replacement = - block.replacement === undefined - ? undefined - : block.replacement.trim(); + block.replacement === undefined ? undefined : block.replacement.trim(); const reason = block.reason?.trim(); const sealed = block.boundaryReached && !!original && replacement !== undefined; @@ -230,7 +229,7 @@ function withoutPartialClosingTag(value: string, tag: string): string { function parseProvisionalTaggedEdit( source: string, - blockIndex: number + blockIndex: number, ): StreamingRedlineEdit { const lower = source.toLowerCase(); const originalValueStart = ORIGINAL_OPEN.length; @@ -241,7 +240,7 @@ function parseProvisionalTaggedEdit( blockIndex, original: withoutPartialClosingTag( source.slice(originalValueStart), - originalClose + originalClose, ).trim(), sealed: false, }; @@ -265,7 +264,7 @@ function parseProvisionalTaggedEdit( original, replacement: withoutPartialClosingTag( source.slice(replacementValueStart), - replacementClose + replacementClose, ).trim(), sealed: false, }; @@ -289,7 +288,7 @@ function parseProvisionalTaggedEdit( replacement, reason: withoutPartialClosingTag( source.slice(reasonValueStart), - "" + "", ).trim(), sealed: false, }; @@ -340,16 +339,14 @@ function projectTaggedRedlineStream(text: string): RedlineStreamProjection { visibleParts.push(tail.slice(0, nextOriginal)); const provisional = parseProvisionalTaggedEdit( tail.slice(nextOriginal), - blockIndex + blockIndex, ); if (!provisional.original || !seenSafeOriginals.has(provisional.original)) { edits.push(provisional); } } else { const partialStart = partialTagStartAtEnd(tail, ORIGINAL_OPEN); - visibleParts.push( - partialStart >= 0 ? tail.slice(0, partialStart) : tail - ); + visibleParts.push(partialStart >= 0 ? tail.slice(0, partialStart) : tail); } return { @@ -360,15 +357,34 @@ function projectTaggedRedlineStream(text: string): RedlineStreamProjection { }; } +// Single-entry projection memo. During a streamed answer the projection runs +// against the identical accumulated string several times per chunk (the edit +// controller once per delta, the message renderer once per re-render), and +// every call re-parses from index zero — O(n²) over a stream without this. +let lastProjectionText: string | null = null; +let lastProjectionComplete = false; +let lastProjection: RedlineStreamProjection | null = null; + /** * Project the current tagged edit stream into prose and edit cards. Legacy * label blocks remain supported so previously saved Word chats still load. */ export function projectRedlineStream( text: string, - streamComplete = false + streamComplete = false, ): RedlineStreamProjection { - return hasTaggedProtocol(text) + if ( + lastProjection && + lastProjectionText === text && + lastProjectionComplete === streamComplete + ) { + return lastProjection; + } + const projection = hasTaggedProtocol(text) ? projectTaggedRedlineStream(text) : projectLegacyRedlineStream(text, streamComplete); + lastProjectionText = text; + lastProjectionComplete = streamComplete; + lastProjection = projection; + return projection; } diff --git a/word-addin/src/taskpane/lib/wordChatEvents.ts b/word-addin/src/taskpane/lib/wordChatEvents.ts index 9bbf6d8da..e0843283a 100644 --- a/word-addin/src/taskpane/lib/wordChatEvents.ts +++ b/word-addin/src/taskpane/lib/wordChatEvents.ts @@ -14,6 +14,19 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +// Stable render identity for live-streamed events. React keys derived from +// array indices remount the activity strips when completeAssistantEvents +// filters an earlier event out — and in WKWebView, unmounting the DOM node +// its scroll anchoring latched onto can reset the transcript's scrollTop. +// Stamping identity at creation keeps every surviving event's subtree alive +// across completion. The key is inert if it reaches storage. +let liveEventKeyCounter = 0; + +function nextEventKey(): string { + liveEventKeyCounter += 1; + return `live-${liveEventKeyCounter}`; +} + export function isWordThinkingEvent( event: WordAssistantEvent, ): event is WordThinkingEvent { @@ -249,7 +262,7 @@ export function appendAssistantContent( { ...last, type: "content", text: last.text + text }, ]; } - return [...current, { type: "content", text }]; + return [...current, { type: "content", text, key: nextEventKey() }]; } function finalizeTrailingReasoning( @@ -277,7 +290,7 @@ export function appendAssistantReasoning( } return [ ...finalizeTrailingReasoning(current), - { type: "reasoning", text, isStreaming: true }, + { type: "reasoning", text, isStreaming: true, key: nextEventKey() }, ]; } @@ -292,7 +305,7 @@ export function finishAssistantReasoning( } return [ ...finalizeTrailingReasoning(current), - { type: "thinking", isStreaming: true }, + { type: "thinking", isStreaming: true, key: nextEventKey() }, ]; } @@ -338,7 +351,7 @@ export function upsertDocumentReadEvent( status: read.status, }; - if (index < 0) return [...current, nextEvent]; + if (index < 0) return [...current, { ...nextEvent, key: nextEventKey() }]; const previous = current[index]; if ( previous && @@ -349,7 +362,12 @@ export function upsertDocumentReadEvent( return current; } return current.map((event, eventIndex) => - eventIndex === index ? nextEvent : event, + eventIndex === index + ? { + ...nextEvent, + ...(typeof previous?.key === "string" ? { key: previous.key } : {}), + } + : event, ); } @@ -362,7 +380,7 @@ export function setAssistantError( (event) => !isWordErrorEvent(event) && !isWordThinkingEvent(event), ), ); - return [...current, { type: "error", message }]; + return [...current, { type: "error", message, key: nextEventKey() }]; } export function completeAssistantEvents( diff --git a/word-addin/src/taskpane/types.ts b/word-addin/src/taskpane/types.ts index a422ed9ba..871d25e30 100644 --- a/word-addin/src/taskpane/types.ts +++ b/word-addin/src/taskpane/types.ts @@ -44,18 +44,22 @@ export interface DocumentReadActivity { export type WordThinkingEvent = { type: "thinking"; isStreaming?: boolean; + /** Stable render identity assigned at creation; see wordChatEvents.ts. */ + key?: string; }; export type WordReasoningEvent = { type: "reasoning"; text: string; isStreaming?: boolean; + key?: string; }; export type WordContentEvent = { type: "content"; text: string; isStreaming?: boolean; + key?: string; }; export type WordDocumentReadEvent = { @@ -63,9 +67,10 @@ export type WordDocumentReadEvent = { filename: string; documentId?: string; status: DocumentReadActivity["status"]; + key?: string; }; -export type WordErrorEvent = { type: "error"; message: string }; +export type WordErrorEvent = { type: "error"; message: string; key?: string }; /** * A backend-persisted assistant activity the Word surface does not render yet.