diff --git a/.github/workflows/word-addin.yml b/.github/workflows/word-addin.yml new file mode 100644 index 000000000..807d72103 --- /dev/null +++ b/.github/workflows/word-addin.yml @@ -0,0 +1,90 @@ +# CI: Word add-in typecheck + hermetic Playwright e2e (chromium AND webkit). +# +# The add-in's e2e suite is fully hermetic (static-served production bundle, +# in-page Office shim, every backend call intercepted — see +# word-addin/playwright.config.ts), so no Supabase/API/secrets are needed here. +# +# WebKit is a first-class project, not extra coverage: the add-in's real host +# on Word for Mac is WKWebView, which ignores `overflow-anchor: none` and +# re-anchors scrollTop when a descendant resizes, while Chromium honours the +# opt-out. The scroll-pinning assertions in e2e/chat-layout.spec.ts only bite +# under webkit — a chromium-only run would silently pass with the scroll fix +# reverted. +# +# Unlike ci.yml/e2e.yml this workflow is path-filtered: the add-in is fully +# self-contained under word-addin/ (own lockfile, own tsconfigs, mocked +# backend), so nothing outside that directory can change what this job proves. +name: Word add-in + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - "word-addin/**" + - ".github/workflows/word-addin.yml" + pull_request: + # `main` is the destination upstream; `upstream-main` is the fork's mirror + # of it (same pairing as e2e.yml / stack-tests.yml). + branches: [main, upstream-main] + paths: + - "word-addin/**" + - ".github/workflows/word-addin.yml" + +# Don't pile up runs on rapid pushes to the same PR; pushes to main each get +# their own group (keyed by sha) and are never cancelled. +concurrency: + group: word-addin-${{ github.event_name == 'push' && github.sha || github.ref }} + cancel-in-progress: ${{ github.event_name != 'push' }} + +jobs: + playwright: + name: Typecheck and Playwright (chromium + webkit) + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: word-addin + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: word-addin/package-lock.json + + # Same silent-merge-corruption guard as ci.yml: git's line-level merge + # can splice package(-lock).json into invalid JSON without a conflict, + # and npm's own error for that is misleading. + - name: Validate package.json and lockfile parse + run: node -e "for (const f of ['package.json','package-lock.json']) JSON.parse(require('fs').readFileSync(f, 'utf8'))" + + - run: npm ci + + # Covers both the app bundle (tsconfig.json) and the e2e suite + + # playwright.config.ts (tsconfig.e2e.json). + - run: npm run typecheck + + # Both engines: webkit is the browser the assertions were written for + # (see header), chromium guards the honours-overflow-anchor path. + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium webkit + + # Runs BOTH projects. playwright.config.ts owns the build/serve + # orchestration: its webServer command runs `build:e2e` (typecheck + + # production webpack bundle with fixed REACT_APP_* values) and then + # static-serves dist/ over plain HTTP, so this single step builds, + # serves, and tests. + - name: Run Playwright (chromium + webkit) + run: npx playwright test + + # always() (not !cancelled()) so traces/screenshots still upload when + # the job is cancelled by the timeout — exactly when they're needed. + - uses: actions/upload-artifact@v4 + if: always() + with: + name: word-addin-playwright-results + path: word-addin/test-results/ + retention-days: 14 + if-no-files-found: ignore diff --git a/backend/src/__tests__/integration/chat.routes.test.ts b/backend/src/__tests__/integration/chat.routes.test.ts index f3af4f003..56879ebc4 100644 --- a/backend/src/__tests__/integration/chat.routes.test.ts +++ b/backend/src/__tests__/integration/chat.routes.test.ts @@ -17,6 +17,10 @@ const { runLLMStream, dbInserts, dbUpdates, dbControl } = vi.hoisted(() => ({ terminalUpdateAttempts: 0, terminalUpdateGate: null as Promise | null, wordChatMissing: false, + // When set, selects on chat_messages resolve against these rows with + // the eq/not/order/limit chain genuinely applied (a mini query + // engine), so tests can prove which assistant row a query picks. + assistantMessageRows: null as Record[] | null, }, })); @@ -44,7 +48,6 @@ function makeQuery(table: string) { } | undefined; const chain = [ - "select", "delete", "upsert", "neq", @@ -56,12 +59,34 @@ function makeQuery(table: string) { "gte", "lte", "filter", - "order", - "limit", "range", "contains", ]; for (const m of chain) q[m] = vi.fn(() => q); + // Select-chain state, applied against dbControl.assistantMessageRows when + // the query resolves (see q.then below). + let didSelect = false; + const selectState = { + filters: [] as { column: string; op: string; value: unknown }[], + order: null as { column: string; ascending: boolean } | null, + limit: null as number | null, + }; + q.select = vi.fn(() => { + didSelect = true; + return q; + }); + q.not = vi.fn((column: string, operator: string, value: unknown) => { + selectState.filters.push({ column, op: `not-${operator}`, value }); + return q; + }); + q.order = vi.fn((column: string, opts?: { ascending?: boolean }) => { + selectState.order = { column, ascending: opts?.ascending !== false }; + return q; + }); + q.limit = vi.fn((count: number) => { + selectState.limit = count; + return q; + }); q.insert = vi.fn((value: unknown) => { dbInserts.push({ table, value }); if ( @@ -82,7 +107,8 @@ function makeQuery(table: string) { return q; }); q.eq = vi.fn((column: string, value: unknown) => { - activeUpdate?.filters.push({ column, value }); + if (activeUpdate) activeUpdate.filters.push({ column, value }); + else selectState.filters.push({ column, op: "eq", value }); return q; }); q.single = vi.fn(() => Promise.resolve(result)); @@ -115,6 +141,33 @@ function makeQuery(table: string) { }; } } + if ( + !activeUpdate && + didSelect && + table === "chat_messages" && + dbControl.assistantMessageRows + ) { + let rows = [...dbControl.assistantMessageRows]; + for (const f of selectState.filters) { + if (f.op === "eq") { + rows = rows.filter((row) => row[f.column] === f.value); + } else if (f.op === "not-is" && f.value === null) { + rows = rows.filter((row) => row[f.column] !== null); + } + } + if (selectState.order) { + const { column, ascending } = selectState.order; + rows = [...rows].sort( + (a, b) => + String(a[column]).localeCompare(String(b[column])) * + (ascending ? 1 : -1), + ); + } + if (selectState.limit != null) { + rows = rows.slice(0, selectState.limit); + } + return { data: rows, error: null }; + } return result; }; return resolveQuery().then(resolve, reject); @@ -208,6 +261,7 @@ describe("POST /chat — streaming endpoint", () => { dbControl.terminalUpdateAttempts = 0; dbControl.terminalUpdateGate = null; dbControl.wordChatMissing = false; + dbControl.assistantMessageRows = null; runLLMStream.mockResolvedValue({ fullText: "hi there", events: [], @@ -649,6 +703,84 @@ describe("POST /chat — streaming endpoint", () => { ).toEqual([]); }); + it("appends ask-input responses to the real last assistant message, skipping a null-content reservation", async () => { + // A stream that died before its save path (or a concurrently + // streaming POST) leaves the newest assistant row as an empty + // reservation. The continuation must attach the user's answers to + // the older, real message that actually asked the question. + dbControl.assistantMessageRows = [ + { + id: "assistant-real", + chat_id: "chat-1", + role: "assistant", + content: [{ type: "ask_inputs", items: [] }], + citations: null, + created_at: "2026-01-01T00:00:00Z", + }, + { + id: "assistant-reservation", + chat_id: "chat-1", + role: "assistant", + content: null, + citations: null, + created_at: "2026-01-01T00:05:00Z", + }, + ]; + + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send({ + ...VALID_BODY, + chat_id: "chat-1", + ask_inputs_response: { + responses: [ + { + id: "choice-1", + kind: "choice", + question: "Continue?", + answer: "Yes", + }, + ], + }, + }); + + expect(res.status).toBe(200); + const askInputsUpdate = dbUpdates.find( + ({ table, filters }) => + table === "chat_messages" && + filters.some( + (f) => f.column === "id" && f.value === "assistant-real", + ), + ); + expect(askInputsUpdate?.value).toMatchObject({ + content: [ + { type: "ask_inputs", items: [] }, + { + type: "ask_inputs_response", + responses: [ + { + id: "choice-1", + kind: "choice", + question: "Continue?", + answer: "Yes", + }, + ], + }, + ], + }); + // The orphaned reservation is never selected or written to. + expect( + dbUpdates.some(({ filters }) => + filters.some( + (f) => + f.column === "id" && + f.value === "assistant-reservation", + ), + ), + ).toBe(false); + }); + it("returns 400 on an empty messages array (never starts a stream)", async () => { const res = await request(app) .post("/chat") diff --git a/backend/src/lib/__tests__/documentContext.test.ts b/backend/src/lib/__tests__/documentContext.test.ts index aeecd4994..090c68bdd 100644 --- a/backend/src/lib/__tests__/documentContext.test.ts +++ b/backend/src/lib/__tests__/documentContext.test.ts @@ -4,6 +4,8 @@ import { parseOptionalDocumentContext, generateSpotlightNonce, spotlight, + enrichWithPriorEvents, + appendAskInputsResponseToLastAssistantMessage, } from "../chat/contextBuilders"; import { ACTIVE_WORD_DOCUMENT_FILENAME, @@ -109,6 +111,213 @@ describe("spotlight", () => { }); }); +// --------------------------------------------------------------------------- +// Null-content assistant reservations (crashed or concurrent streams) +// +// The streaming routes reserve the assistant row with content = null BEFORE +// streaming, so a stream that dies before its save path (or a concurrently +// streaming POST) leaves an orphaned null-content row as the newest assistant +// message. The "latest assistant row" queries must skip those reservations. +// --------------------------------------------------------------------------- + +type FakeAssistantRow = { + id: string; + chat_id: string; + role: string; + content: unknown; + citations: unknown; + created_at: string; +}; + +/** + * Minimal in-memory chat_messages table that genuinely applies the + * eq / not("content","is",null) / order / limit chain, so these tests fail + * if the reservation filter is dropped from the production queries. + */ +function makeFakeMessagesDb(rows: FakeAssistantRow[]) { + const updates: { id: string; content: unknown; citations: unknown }[] = []; + const db = { + from: () => { + let selected = [...rows]; + let pendingUpdate: + | { content: unknown; citations: unknown } + | undefined; + const builder = { + select: () => builder, + update: (value: { content: unknown; citations: unknown }) => { + pendingUpdate = value; + return builder; + }, + eq: (column: keyof FakeAssistantRow, value: unknown) => { + selected = selected.filter((row) => row[column] === value); + return builder; + }, + not: ( + column: keyof FakeAssistantRow, + operator: string, + value: unknown, + ) => { + if (operator === "is" && value === null) { + selected = selected.filter( + (row) => row[column] !== null, + ); + } + return builder; + }, + order: ( + column: keyof FakeAssistantRow, + opts: { ascending: boolean }, + ) => { + selected = [...selected].sort( + (a, b) => + String(a[column]).localeCompare( + String(b[column]), + ) * (opts.ascending ? 1 : -1), + ); + return builder; + }, + limit: (count: number) => { + selected = selected.slice(0, count); + return builder; + }, + then: ( + resolve: (value: unknown) => unknown, + reject?: (error: unknown) => unknown, + ) => { + if (pendingUpdate) { + for (const row of selected) { + updates.push({ id: row.id, ...pendingUpdate }); + Object.assign(row, pendingUpdate); + } + return Promise.resolve({ + data: null, + error: null, + }).then(resolve, reject); + } + return Promise.resolve({ + data: selected, + error: null, + }).then(resolve, reject); + }, + }; + return builder; + }, + }; + return { db: db as never, updates }; +} + +function realAssistantRow(content: unknown): FakeAssistantRow { + return { + id: "assistant-real", + chat_id: "chat-1", + role: "assistant", + content, + citations: null, + created_at: "2026-01-01T00:00:00Z", + }; +} + +function reservationRow(): FakeAssistantRow { + return { + id: "assistant-reservation", + chat_id: "chat-1", + role: "assistant", + content: null, + citations: null, + created_at: "2026-01-01T00:05:00Z", + }; +} + +describe("null-content assistant reservations", () => { + it("enrichWithPriorEvents surfaces the prior real turn's events past a newer reservation", async () => { + const { db } = makeFakeMessagesDb([ + realAssistantRow([ + { + type: "doc_created", + document_id: "doc-uuid-1", + filename: "Brief.docx", + }, + ]), + reservationRow(), + ]); + + const enriched = await enrichWithPriorEvents( + [ + { role: "user", content: "Draft a brief" }, + { role: "assistant", content: "Done." }, + { role: "user", content: "Now edit it" }, + ], + "chat-1", + db, + { "doc-0": { document_id: "doc-uuid-1", filename: "Brief.docx" } }, + ); + + expect(enriched[1].content).toContain( + "[Tool activity in your previous turn]", + ); + expect(enriched[1].content).toContain( + '- generated_document → doc-0 ("Brief.docx")', + ); + }); + + it("enrichWithPriorEvents leaves messages untouched when only a reservation exists", async () => { + const { db } = makeFakeMessagesDb([reservationRow()]); + const messages = [ + { role: "user", content: "Draft a brief" }, + { role: "assistant", content: "Done." }, + ]; + + const enriched = await enrichWithPriorEvents( + messages, + "chat-1", + db, + {}, + ); + + expect(enriched).toEqual(messages); + }); + + it("ask-input responses append to the real last message, never the reservation", async () => { + const rows = [ + realAssistantRow([{ type: "ask_inputs", items: [] }]), + reservationRow(), + ]; + const { db, updates } = makeFakeMessagesDb(rows); + + await appendAskInputsResponseToLastAssistantMessage(db, "chat-1", { + responses: [ + { + id: "choice-1", + kind: "choice", + question: "Continue?", + answer: "Yes", + }, + ], + }); + + expect(updates).toHaveLength(1); + expect(updates[0].id).toBe("assistant-real"); + expect(updates[0].content).toEqual([ + { type: "ask_inputs", items: [] }, + { + type: "ask_inputs_response", + responses: [ + { + id: "choice-1", + kind: "choice", + question: "Continue?", + answer: "Yes", + }, + ], + }, + ]); + // The reservation stays empty for its own stream's terminal save. + expect( + rows.find((row) => row.id === "assistant-reservation")?.content, + ).toBeNull(); + }); +}); + // --------------------------------------------------------------------------- // Active Word document tool context // --------------------------------------------------------------------------- diff --git a/backend/src/lib/chat/contextBuilders.ts b/backend/src/lib/chat/contextBuilders.ts index 217f85f64..04b35c9cf 100644 --- a/backend/src/lib/chat/contextBuilders.ts +++ b/backend/src/lib/chat/contextBuilders.ts @@ -97,11 +97,16 @@ export async function enrichWithPriorEvents( messageTable = "chat_messages", ): Promise { if (!chatId) return messages; + // Skip streaming reservations: routeStreaming inserts the assistant row + // with content = null BEFORE the stream runs, so a crashed stream (or a + // concurrently streaming POST) leaves a newer null-content row that would + // otherwise shadow the previous turn's real events here. const { data: rows } = await db .from(messageTable) .select("content, created_at") .eq("chat_id", chatId) .eq("role", "assistant") + .not("content", "is", null) .order("created_at", { ascending: false }) .limit(1); @@ -401,11 +406,15 @@ export async function appendAssistantEventsToLastAssistantMessage( if (events.length === 0 && (!citations || citations.length === 0)) { return; } + // Skip streaming reservations (content = null, see routeStreaming) so + // events are appended to the real last assistant message, not onto an + // empty reservation left by a crashed or still-streaming request. const { data: rows, error: selectError } = await db .from(messageTable) .select("id, content, citations") .eq("chat_id", chatId) .eq("role", "assistant") + .not("content", "is", null) .order("created_at", { ascending: false }) .limit(1); if (selectError || !rows?.[0]) { diff --git a/word-addin/e2e/chat-layout.spec.ts b/word-addin/e2e/chat-layout.spec.ts index 825e931ac..0b2869a96 100644 --- a/word-addin/e2e/chat-layout.spec.ts +++ b/word-addin/e2e/chat-layout.spec.ts @@ -74,10 +74,12 @@ test("uses the frontend assistant spacer while a new answer grows", async ({ }), ); - // The blur ramps down in masked stages instead of ending on one hard edge, - // so every blurring layer is masked and none of them draws a shadowed line. + // Exactly one blurring layer: each backdrop-filter re-samples the moving + // transcript every frame in WKWebView, so the progressive-blur look is + // produced by a single blur whose mask alpha ramps down (no stacked + // blur-per-stage layers), and it must not draw a shadowed seam. const blurLayers = scrimLayers.filter((layer) => layer.blurPx > 0); - expect.soft(blurLayers.length).toBeGreaterThanOrEqual(3); + expect.soft(blurLayers.length).toBe(1); for (const layer of blurLayers) { expect.soft(layer.maskImage).toContain("linear-gradient"); expect.soft(layer.boxShadow).toBe("none"); @@ -492,10 +494,6 @@ test("keeps the submitted turn at 80px while Working becomes Completed", async ( await waitForStableSample(readPosition); const completedPosition = await readPosition(); - console.log("WEBKIT_COMPLETION_DIAGNOSTIC", { - workingPosition, - completedPosition, - }); expect( Math.abs(completedPosition.userTop - (completedPosition.containerTop + 80)), ).toBeLessThanOrEqual(4); diff --git a/word-addin/e2e/chat.spec.ts b/word-addin/e2e/chat.spec.ts index 6a3c15abb..7f3e9f18e 100644 --- a/word-addin/e2e/chat.spec.ts +++ b/word-addin/e2e/chat.spec.ts @@ -702,6 +702,88 @@ test("uses Web Crypto for document IDs when randomUUID is unavailable", async ({ ); }); +test("a Save As copy of the document mints a fresh chat identity", async ({ + addin, + page, +}) => { + await addin.mockChatStream(["ok"]); + await addin.gotoTaskpane({ documentText: "Copy detection test" }); + await addin.expectAuthedShell(); + + await page.getByPlaceholder("How can I help?").fill("First question"); + const firstRequest = page.waitForRequest("**/word-chat"); + await page.getByRole("button", { name: "Send" }).click(); + const firstId = (await firstRequest).postDataJSON().document_id as string; + + // Simulate "Save As": document settings travel inside the .docx (the mock + // persists them in sessionStorage), but the copy opens from a new URL. Seed + // a stale anchor registry to prove the copy does not inherit it either. + await addin.setWordDocumentSetting("mike.wordEditAnchors.v1", { + version: 1, + anchors: {}, + }); + await addin.gotoTaskpane({ + documentUrl: "C:/Users/e2e/Demo Contract (Copy).docx", + }); + await addin.expectAuthedShell(); + + await page.getByPlaceholder("How can I help?").fill("Second question"); + const secondRequest = page.waitForRequest("**/word-chat"); + await page.getByRole("button", { name: "Send" }).click(); + const secondId = (await secondRequest).postDataJSON().document_id as string; + + expect(secondId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(secondId).not.toBe(firstId); + const { settings } = await addin.wordDocument(); + expect(settings["mike.word.documentId.v1"]).toBe(secondId); + expect(settings["mike.word.documentUrl.v1"]).toBe( + "c:/users/e2e/demo contract (copy).docx", + ); + expect(settings["mike.wordEditAnchors.v1"]).toBeUndefined(); +}); + +test("keeps the existing identity when either document URL is unknown", async ({ + addin, + page, +}) => { + await addin.mockChatStream(["ok"]); + await addin.gotoTaskpane({ documentText: "Conservative identity test" }); + await addin.expectAuthedShell(); + + await page.getByPlaceholder("How can I help?").fill("First question"); + const firstRequest = page.waitForRequest("**/word-chat"); + await page.getByRole("button", { name: "Send" }).click(); + const firstId = (await firstRequest).postDataJSON().document_id as string; + + // Pre-URL-tracking upgrade path: an identity exists but no URL was stored. + // Even at a brand-new URL this must NOT count as a copy. + await addin.removeWordDocumentSetting("mike.word.documentUrl.v1"); + await addin.gotoTaskpane({ + documentUrl: "C:/Users/e2e/Renamed Contract.docx", + }); + await addin.expectAuthedShell(); + + await page.getByPlaceholder("How can I help?").fill("Second question"); + const secondRequest = page.waitForRequest("**/word-chat"); + await page.getByRole("button", { name: "Send" }).click(); + expect((await secondRequest).postDataJSON().document_id).toBe(firstId); + + // Unsaved-document path: the current URL is empty, so the stored identity + // (and the URL adopted above) must survive untouched. + await addin.gotoTaskpane({ documentUrl: "" }); + await addin.expectAuthedShell(); + + await page.getByPlaceholder("How can I help?").fill("Third question"); + const thirdRequest = page.waitForRequest("**/word-chat"); + await page.getByRole("button", { name: "Send" }).click(); + expect((await thirdRequest).postDataJSON().document_id).toBe(firstId); + expect((await addin.wordDocument()).settings["mike.word.documentUrl.v1"]).toBe( + "c:/users/e2e/renamed contract.docx", + ); +}); + test("shows Reading and Read only when the model triggers the read tool", async ({ addin, page, diff --git a/word-addin/e2e/support/office-mock.ts b/word-addin/e2e/support/office-mock.ts index f9592c4ac..b623f8a69 100644 --- a/word-addin/e2e/support/office-mock.ts +++ b/word-addin/e2e/support/office-mock.ts @@ -14,6 +14,12 @@ export interface OfficeSeed { token?: string | null; refreshToken?: string | null; documentText?: string; + /** + * URL exposed as Office.context.document.url. Defaults to a stable fake + * path; pass a different value to simulate opening a "Save As" copy of the + * same document, or "" to simulate a never-saved document. + */ + documentUrl?: string; existingTrackedChangeOriginals?: string[]; unmanagedTrackedChangeOriginals?: string[]; staleInsertedRangeOriginals?: string[]; @@ -209,7 +215,7 @@ export function installOfficeMock(seed: OfficeSeed): void { }; const officeDocument = { - url: "C:/Users/e2e/Demo Contract.docx", + url: seed.documentUrl ?? "C:/Users/e2e/Demo Contract.docx", settings, }; diff --git a/word-addin/package.json b/word-addin/package.json index afe2db94a..9674e6e5d 100644 --- a/word-addin/package.json +++ b/word-addin/package.json @@ -19,6 +19,7 @@ "build:e2e": "npm run typecheck && REACT_APP_API_BASE_URL=http://localhost:3001 REACT_APP_SUPABASE_URL=http://localhost:54321 REACT_APP_SUPABASE_ANON_KEY=test-anon-key REACT_APP_WEB_APP_URL=http://localhost:3000 webpack --mode production", "serve:e2e": "http-server dist -p 3100 -a 127.0.0.1 -c-1 --silent", "test:e2e": "playwright test", + "test:e2e:webkit": "playwright test --project=webkit", "test:e2e:headed": "playwright test --headed", "test:e2e:ui": "playwright test --ui" }, diff --git a/word-addin/playwright.config.ts b/word-addin/playwright.config.ts index 1595a1c50..75121ff7d 100644 --- a/word-addin/playwright.config.ts +++ b/word-addin/playwright.config.ts @@ -41,6 +41,15 @@ export default defineConfig({ name: "chromium", use: { ...devices["Desktop Chrome"] }, }, + // WebKit is NOT redundant coverage here: WKWebView (the Word-on-Mac task + // pane host) ignores `overflow-anchor: none` and re-anchors scrollTop when + // a descendant resizes, while Chromium honours the opt-out. The scroll + // pinning assertions in e2e/chat-layout.spec.ts only bite under this + // project — dropping it silently un-tests the WebKit scroll fix. + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, ], // Build the production bundle, then static-serve dist/ over HTTP. Build runs @@ -50,6 +59,9 @@ export default defineConfig({ command: "npm run build:e2e && npm run serve:e2e", url: `${BASE_URL}/taskpane.html`, reuseExistingServer: !process.env.CI, - timeout: 180_000, + // Generous because the command includes a cold typecheck + production + // webpack build on CI runners; a webServer timeout aborts the whole run + // (retries never apply to it). + timeout: 300_000, }, }); diff --git a/word-addin/playwright.webkit.temp.config.ts b/word-addin/playwright.webkit.temp.config.ts deleted file mode 100644 index a00dff2f5..000000000 --- a/word-addin/playwright.webkit.temp.config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; - -export default defineConfig({ - testDir: "./e2e", - fullyParallel: false, - workers: 1, - retries: 0, - reporter: "line", - use: { - baseURL: "http://127.0.0.1:3100", - screenshot: "only-on-failure", - trace: "off", - }, - projects: [ - { - name: "webkit", - use: { ...devices["Desktop Safari"] }, - }, - ], - webServer: { - command: "npm run build:e2e && npm run serve:e2e", - url: "http://127.0.0.1:3100/taskpane.html", - reuseExistingServer: true, - timeout: 180_000, - }, -}); diff --git a/word-addin/src/taskpane/components/assistant/ChatPanel.tsx b/word-addin/src/taskpane/components/assistant/ChatPanel.tsx index 185a21bf3..0e4036376 100644 --- a/word-addin/src/taskpane/components/assistant/ChatPanel.tsx +++ b/word-addin/src/taskpane/components/assistant/ChatPanel.tsx @@ -46,7 +46,9 @@ export function ChatPanel({ wordDocumentId, wordChatStorage, wordChatOwnerId, - editController: trackedEdits, + // Only the identity-stable streaming callbacks; passing the whole + // controller would tie handleChat's identity to every edit-state change. + editController: trackedEdits.streamController, }); return ( diff --git a/word-addin/src/taskpane/components/primitives/ModalForm.tsx b/word-addin/src/taskpane/components/primitives/ModalForm.tsx index a00a12f31..8137508cb 100644 --- a/word-addin/src/taskpane/components/primitives/ModalForm.tsx +++ b/word-addin/src/taskpane/components/primitives/ModalForm.tsx @@ -96,6 +96,16 @@ export function ModalSelect({ align="start" sideOffset={4} collisionPadding={12} + onEscapeKeyDown={(event) => { + // These selects live inside Modal, which closes on any Escape it + // sees on `window`. Radix's dismissable layer hears the key first + // (document-level capture runs before the event bubbles back out + // to window), so stopping propagation here scopes the first + // Escape to closing just the dropdown instead of discarding the + // whole form. A second Escape, with the dropdown gone, reaches + // the Modal and closes it as before. + event.stopPropagation(); + }} className="max-h-56 w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto" > {options.map((option) => ( diff --git a/word-addin/src/taskpane/components/shell/FloatingHeader.tsx b/word-addin/src/taskpane/components/shell/FloatingHeader.tsx index fea48e59f..b0b519c63 100644 --- a/word-addin/src/taskpane/components/shell/FloatingHeader.tsx +++ b/word-addin/src/taskpane/components/shell/FloatingHeader.tsx @@ -79,19 +79,23 @@ export function FloatingHeader({ data-testid="floating-header" className="pointer-events-none absolute inset-x-0 top-0 z-40 isolate flex items-center justify-between gap-3 p-3" > - {/* Content fades out under the header. A single blurred pane ends on a - visible seam however softly it is masked, so the blur is ramped down - in stages: each layer blurs what the layer above already blurred and - is masked out higher up, leaving no edge to catch the eye. */} + {/* Content fades out under the header. This used to ramp the blur down + in four stacked backdrop-blur layers (1/2/4/8px), but every + backdrop-filter re-samples whatever is moving behind it on each + frame — in WKWebView that made the streaming transcript pay for four + full-width re-samples per scrolled frame. One blurred layer whose + mask alpha ramps down through several stops is the standard + single-layer approximation of a progressive blur: the cross-fade + from blurred to sharp reads as the blur easing off, and the mask + reaches zero well before the pane's bottom edge so no seam is left + to catch the eye. -webkit-mask-image is spelled out because + WKWebView still wants the prefixed form. */}