diff --git a/.github/workflows/ask-agent-workspace-integration.yml b/.github/workflows/ask-agent-workspace-integration.yml
new file mode 100644
index 000000000..08933582f
--- /dev/null
+++ b/.github/workflows/ask-agent-workspace-integration.yml
@@ -0,0 +1,92 @@
+name: Apply Ask Agent evidence workspace
+
+on:
+ push:
+ branches:
+ - feat/ask-agent-evidence-workspace-v2180
+ paths:
+ - .github/workflows/ask-agent-workspace-integration.yml
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+concurrency:
+ group: ask-agent-workspace-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ integrate-and-verify:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout exact branch head
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7
+ with:
+ ref: feat/ask-agent-evidence-workspace-v2180
+ fetch-depth: 0
+
+ - name: Set up Python
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Apply bounded integration patch
+ env:
+ PR_NUMBER: "351"
+ run: python scripts/patch_ask_agent_workspace.py
+
+ - name: Verify patch shape
+ run: |
+ git diff --check
+ test -f frontend/src/components/AskAgentWorkspace.tsx
+ test -f docs/adr/0125-ask-agent-evidence-workspace.md
+ grep -F 'AskAgentWorkspace as AskAgentPanel' frontend/src/App.tsx
+ grep -F '| #351 | Ask Agent evidence workspace' docs/product-technical-gap-baseline.md
+ if grep -F 'function AskAgentPanel({' frontend/src/App.tsx; then
+ echo 'Legacy inline AskAgentPanel remains' >&2
+ exit 1
+ fi
+
+ - name: Set up Node
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5
+ with:
+ node-version: "24"
+
+ - name: Enable Corepack
+ run: corepack enable
+
+ - name: Install locked frontend dependencies
+ working-directory: frontend
+ run: pnpm install --frozen-lockfile
+
+ - name: Run focused Ask Agent tests
+ working-directory: frontend
+ run: pnpm run test -- src/components/AskAgentWorkspace.test.tsx
+
+ - name: Run frontend lint
+ working-directory: frontend
+ run: pnpm run lint
+
+ - name: Run full frontend tests
+ working-directory: frontend
+ run: pnpm run test
+
+ - name: Build frontend
+ working-directory: frontend
+ run: pnpm run build
+
+ - name: Build Storybook state inventory
+ working-directory: frontend
+ run: pnpm run build-storybook
+
+ - name: Publish verified source change and remove one-shot machinery
+ run: |
+ rm -rf frontend/dist frontend/storybook-static
+ rm -f scripts/patch_ask_agent_workspace.py
+ rm -f .github/workflows/ask-agent-workspace-integration.yml
+ git diff --check
+ git config user.name 'github-actions[bot]'
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ git add -A
+ git commit -m 'feat: integrate verified Ask Agent evidence workspace'
+ git push origin HEAD:feat/ask-agent-evidence-workspace-v2180
diff --git a/CHANGELOG.d/2.18.0-ask-agent-evidence-workspace.md b/CHANGELOG.d/2.18.0-ask-agent-evidence-workspace.md
new file mode 100644
index 000000000..c95e8af11
--- /dev/null
+++ b/CHANGELOG.d/2.18.0-ask-agent-evidence-workspace.md
@@ -0,0 +1,12 @@
+## Added
+
+- Rebuilt the Global Ask destination as a responsive evidence workspace with a
+ dedicated semantic composer, explicit empty/loading/error/answer states,
+ separated Event Lineage and citation evidence, and answer focus management.
+- Added Storybook scenes for empty, loading, answered, unavailable, and phone
+ layouts while preserving stale-session recovery and cited-post navigation.
+
+## Fixed
+
+- Replaced the unrelated Keyman-link button treatment in the Ask chatbox and
+ prevented Enter during IME composition from submitting a partial question.
diff --git a/docs/adr/0125-ask-agent-evidence-workspace.md b/docs/adr/0125-ask-agent-evidence-workspace.md
new file mode 100644
index 000000000..4a0d69759
--- /dev/null
+++ b/docs/adr/0125-ask-agent-evidence-workspace.md
@@ -0,0 +1,66 @@
+# ADR 0125 — Ask Agent evidence workspace and composer contract
+
+**Decision status:** Accepted
+**Date:** 2026-08-21
+**Figma File ID:** `1Su3lDRmiZdcUs47t1QwIX`
+**Figma desktop frame:** `11:2` (`Ask Agent / Desktop / Answered`)
+**Figma mobile frame:** `11:3` (`Ask Agent / Mobile / Answered`)
+**Stack placement:** This change is a direct child of PR #264 head `8832216fcd2b0a1dcb486ea83269e25695ce378a`.
+
+## Context
+
+The stacked buyer surface already provided authorized Global Ask, durable
+session recovery, cited-post navigation, and Event Lineage focus continuity.
+The visible Ask Agent screen, however, remained a generic content section: the
+composer reused a Keyman link-style button, the answer, timeline, citations,
+and evidence facts shared one undifferentiated card, and the screen had no
+component or Storybook state boundary of its own.
+
+The project UI/UX Standard Guide v3.0 requires a clearly differentiated focused
+input, visible action controls, responsive PC/tablet/phone behavior, and a
+mobile layout that places the primary action within the content flow. ADR 0002
+also established the safe Figma file above as the public design-system boundary
+and forbids copying confidential source-organization material.
+
+## Decision
+
+1. Extract the destination into `AskAgentWorkspace`, with a stateful controller
+ and a presentational `AskAgentWorkspaceView`.
+2. Use a semantic form and a visually primary Ask button. Enter submits,
+ Shift+Enter inserts a line break, and an IME composition Enter never submits.
+3. Separate composer, pending/error/empty state, answer, Event Lineage timeline,
+ and cited evidence into explicit regions. A completed answer receives focus.
+4. Keep every existing security and truth boundary: only the authenticated
+ `/api/ask` contract is called, stale sessions retry once without the expired
+ identifier, previous evidence is hidden while a replacement answer is
+ pending, and source actions retain the #263/#264 Event Lineage handoff.
+5. Reuse existing design tokens and define responsive breakpoints at 1024px and
+ 768px. The phone state uses a single column and a full-width primary action.
+ The primary action uses the shared `color/on-accent` token in both light and
+ dark modes rather than an inline foreground literal.
+6. Keep editable answered-state desktop and mobile specifications in the Figma
+ frames recorded above. Inventory Empty, Loading, Answered, Unavailable, and
+ Phone Answered executable scenes in Storybook so state and edge-event review
+ remains coupled to the production component.
+
+## Consequences
+
+- Ask Agent is no longer coupled to the post-popup or Keyman control styles.
+- Future streaming, follow-up history, or source comparison can evolve inside a
+ bounded component without expanding `App.tsx`.
+- The component preserves the exact accessible labels relied on by existing
+ integration tests and downstream stacked PRs.
+- Figma and CSS share named color, radius, and spacing concepts while Storybook
+ remains the executable source for loading, unavailable, and responsive states.
+- This ADR does not claim the unmerged stack is protected-main behavior.
+
+## Verification
+
+- Focused controller, keyboard, session recovery, source navigation, and design
+ token/breakpoint contract tests.
+- Existing App-level citation, timeline, stale-session, pending-answer, and
+ Event Lineage focus regressions.
+- Frontend lint, production build, complete Vitest suite, and Storybook build.
+- Desktop and mobile Figma screenshots checked for clipping, overflow, action
+ prominence, and source-evidence hierarchy.
+- Responsive and focus-visible CSS review against the UI/UX guide.
diff --git a/frontend/src/components/AskAgentWorkspace.css b/frontend/src/components/AskAgentWorkspace.css
new file mode 100644
index 000000000..bcd1d55e9
--- /dev/null
+++ b/frontend/src/components/AskAgentWorkspace.css
@@ -0,0 +1,276 @@
+.ask-agent-workspace {
+ min-width: 0;
+}
+
+.ask-agent-header {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1.25rem;
+}
+
+.ask-agent-header h2,
+.ask-agent-header p {
+ margin-block: 0;
+}
+
+.ask-agent-header h2 {
+ margin-block: 0.25rem 0.35rem;
+}
+
+.ask-agent-scope {
+ max-width: 20rem;
+ padding: var(--space-chip-block) var(--space-chip-inline);
+ border: 1px solid var(--color-accent-info);
+ border-radius: var(--radius-chip);
+ background: var(--color-accent-info-background);
+ color: var(--text-h);
+ font-size: var(--font-size-badge);
+ font-weight: 700;
+ text-align: center;
+}
+
+.ask-agent-layout {
+ display: grid;
+ grid-template-columns: minmax(17rem, 0.85fr) minmax(0, 1.4fr);
+ gap: 1rem;
+ align-items: start;
+}
+
+.ask-agent-composer,
+.ask-agent-state,
+.ask-agent-answer {
+ min-width: 0;
+ padding: 1rem;
+ border: 1px solid var(--border);
+ border-radius: calc(var(--radius-panel) + 0.25rem);
+ background: var(--bg);
+}
+
+.ask-agent-composer {
+ display: grid;
+ gap: 0.85rem;
+ background: var(--code-bg);
+}
+
+.ask-agent-source {
+ display: grid;
+ gap: 0.45rem;
+ color: var(--text-h);
+ font-weight: 700;
+}
+
+.ask-agent-source textarea {
+ box-sizing: border-box;
+ width: 100%;
+ min-height: 10rem;
+ resize: vertical;
+ padding: 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-control);
+ background: var(--bg);
+ color: var(--text);
+ font: inherit;
+ line-height: 1.5;
+}
+
+.ask-agent-source textarea:focus-visible,
+.ask-agent-submit:focus-visible,
+.ask-agent-source-card:focus-visible,
+.ask-agent-answer:focus-visible {
+ outline: 3px solid var(--color-accent-border);
+ outline-offset: 3px;
+}
+
+.ask-agent-composer-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.ask-agent-submit {
+ min-width: 7rem;
+ min-height: 2.75rem;
+ padding: 0.55rem 1rem;
+ border: 1px solid var(--color-accent-border);
+ border-radius: var(--radius-control);
+ background: var(--accent);
+ color: var(--color-on-accent);
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.ask-agent-submit:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.ask-agent-output {
+ min-width: 0;
+}
+
+.ask-agent-state {
+ min-height: 10rem;
+ display: grid;
+ align-content: center;
+ gap: 0.5rem;
+ border-style: dashed;
+ color: var(--text-muted, var(--text));
+}
+
+.ask-agent-state h3,
+.ask-agent-answer h3,
+.ask-agent-answer h4 {
+ margin-block: 0;
+}
+
+.ask-agent-answer {
+ display: grid;
+ gap: 1rem;
+ box-shadow: var(--shadow);
+}
+
+.ask-agent-answer-header {
+ display: grid;
+ gap: 0.15rem;
+}
+
+.ask-agent-answer-text {
+ font-size: 1.05rem;
+ line-height: 1.65;
+ white-space: pre-wrap;
+}
+
+.ask-agent-result-section {
+ display: grid;
+ gap: 0.65rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border);
+}
+
+.ask-agent-timeline,
+.ask-agent-citations {
+ display: grid;
+ gap: 0.65rem;
+ padding: 0;
+ margin: 0;
+ list-style: none;
+}
+
+.ask-agent-timeline {
+ counter-reset: ask-agent-event;
+}
+
+.ask-agent-timeline > li {
+ display: grid;
+ grid-template-columns: 1.5rem minmax(0, 1fr);
+ gap: 0.55rem;
+ align-items: stretch;
+}
+
+.ask-agent-timeline > li::before {
+ counter-increment: ask-agent-event;
+ content: counter(ask-agent-event);
+ display: grid;
+ place-items: center;
+ align-self: start;
+ width: 1.5rem;
+ height: 1.5rem;
+ margin-top: 0.7rem;
+ border-radius: 50%;
+ background: var(--color-accent-info-background);
+ color: var(--color-accent-info);
+ font-size: var(--font-size-badge);
+ font-weight: 800;
+}
+
+.ask-agent-source-card {
+ width: 100%;
+ min-width: 0;
+ display: grid;
+ gap: 0.25rem;
+ padding: 0.7rem 0.8rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-control);
+ background: transparent;
+ color: var(--text-h);
+ font: inherit;
+ font-weight: 700;
+ text-align: left;
+ cursor: pointer;
+}
+
+.ask-agent-source-card:hover {
+ border-color: var(--color-accent-info);
+ background: var(--color-accent-info-background);
+}
+
+.ask-agent-source-card time {
+ color: var(--text);
+ font-size: var(--font-size-badge);
+ font-weight: 400;
+ overflow-wrap: anywhere;
+}
+
+.ask-agent-citation-card {
+ display: grid;
+ gap: 0.55rem;
+ padding: 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-panel);
+ background: var(--code-bg);
+}
+
+.ask-agent-citation-card .ask-agent-source-card {
+ background: var(--bg);
+}
+
+.ask-agent-citation-card .post-evidence-list {
+ margin: 0;
+}
+
+@media (max-width: 1024px) {
+ .ask-agent-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .ask-agent-composer-footer {
+ align-items: flex-end;
+ }
+}
+
+@media (max-width: 768px) {
+ .ask-agent-header,
+ .ask-agent-composer-footer {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .ask-agent-scope {
+ max-width: none;
+ }
+
+ .ask-agent-composer,
+ .ask-agent-state,
+ .ask-agent-answer {
+ padding: 0.85rem;
+ }
+
+ .ask-agent-submit {
+ width: 100%;
+ }
+
+ .ask-agent-source textarea {
+ min-height: 8rem;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ask-agent-source-card,
+ .ask-agent-submit {
+ transition: none;
+ }
+}
diff --git a/frontend/src/components/AskAgentWorkspace.stories.tsx b/frontend/src/components/AskAgentWorkspace.stories.tsx
new file mode 100644
index 000000000..defa06253
--- /dev/null
+++ b/frontend/src/components/AskAgentWorkspace.stories.tsx
@@ -0,0 +1,99 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import type { AskAgentResponse } from "../api";
+import { AskAgentWorkspaceView } from "./AskAgentWorkspace";
+
+const answered: AskAgentResponse = {
+ session_id: "storybook-session",
+ answer_text:
+ "The revised quotation follows the pricing review and is supported by two authorized posts.",
+ cited_post_ids: ["post-2"],
+ cited_posts: [{ post_id: "post-2", post_title: "Pricing review follow-up" }],
+ cited_post_evidence: [
+ {
+ post_id: "post-2",
+ facts: [
+ {
+ kind: "semantic_project",
+ text: "project: Demonstration project | evidence: revised quotation referenced in the source body",
+ },
+ ],
+ },
+ ],
+ source_post_ids: ["post-1", "post-2"],
+ timeline: [
+ {
+ post_id: "post-1",
+ post_title: "Pricing review requested",
+ occurred_at: "2026-01-06T09:00:00Z",
+ timeline_kind: "lineage_anchor",
+ },
+ {
+ post_id: "post-2",
+ post_title: "Pricing review follow-up",
+ occurred_at: "2026-01-10T11:30:00Z",
+ timeline_kind: "lineage_neighbor",
+ },
+ ],
+};
+
+const meta = {
+ title: "Ask/AskAgentWorkspace",
+ component: AskAgentWorkspaceView,
+ parameters: { layout: "fullscreen" },
+ args: {
+ question: "",
+ answer: null,
+ error: null,
+ asking: false,
+ onQuestionChange: () => undefined,
+ onSubmit: () => undefined,
+ onOpenPost: () => undefined,
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Empty: Story = {};
+
+export const Loading: Story = {
+ args: {
+ question: "Which project changed after the customer review?",
+ asking: true,
+ },
+};
+
+export const Answered: Story = {
+ args: {
+ question: "Which project changed after the customer review?",
+ answer: answered,
+ },
+};
+
+export const Unavailable: Story = {
+ args: {
+ question: "Which project changed after the customer review?",
+ error: "Ask Agent is temporarily unavailable. Saved evidence is still available.",
+ },
+};
+
+export const PhoneAnswered: Story = {
+ args: {
+ question: "Which project changed after the customer review?",
+ answer: answered,
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
diff --git a/frontend/src/components/AskAgentWorkspace.styles.test.ts b/frontend/src/components/AskAgentWorkspace.styles.test.ts
new file mode 100644
index 000000000..21ede31c1
--- /dev/null
+++ b/frontend/src/components/AskAgentWorkspace.styles.test.ts
@@ -0,0 +1,27 @@
+///
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const componentCss = readFileSync(join(here, "AskAgentWorkspace.css"), "utf-8");
+const tokensCss = readFileSync(join(here, "..", "styles", "tokens.css"), "utf-8");
+const [lightBlock, darkBlock] = tokensCss.split("@media (prefers-color-scheme: dark)");
+
+describe("Ask Agent design-token contract", () => {
+ it("defines the primary-action foreground in both color modes", () => {
+ expect(lightBlock).toContain("--color-on-accent:");
+ expect(darkBlock).toContain("--color-on-accent:");
+ });
+
+ it("uses the primary-action foreground token instead of an inline literal", () => {
+ expect(componentCss).toContain("color: var(--color-on-accent);");
+ expect(componentCss.toLowerCase()).not.toContain("color: #fff");
+ });
+
+ it("keeps the three responsive layout bands explicit", () => {
+ expect(componentCss).toContain("@media (max-width: 1024px)");
+ expect(componentCss).toContain("@media (max-width: 768px)");
+ });
+});
diff --git a/frontend/src/components/AskAgentWorkspace.test.tsx b/frontend/src/components/AskAgentWorkspace.test.tsx
new file mode 100644
index 000000000..90a4c2c00
--- /dev/null
+++ b/frontend/src/components/AskAgentWorkspace.test.tsx
@@ -0,0 +1,162 @@
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { BackendError, type AskAgentResponse } from "../api";
+import {
+ AskAgentWorkspace,
+ AskAgentWorkspaceView,
+ GLOBAL_ASK_SESSION_STORAGE_KEY,
+} from "./AskAgentWorkspace";
+
+const answer: AskAgentResponse = {
+ session_id: "session-1",
+ answer_text: "The cited project is supported by the stored semantic evidence.",
+ cited_post_ids: ["post-2"],
+ cited_posts: [{ post_id: "post-2", post_title: "Linked post" }],
+ cited_post_evidence: [
+ {
+ post_id: "post-2",
+ facts: [
+ { kind: "semantic_project", text: "project: Semantic project | evidence: Body evidence" },
+ ],
+ },
+ ],
+ source_post_ids: ["post-1", "post-2"],
+ timeline: [
+ {
+ post_id: "post-1",
+ post_title: "Public post",
+ occurred_at: "2026-01-01T00:00:00Z",
+ timeline_kind: "lineage_anchor",
+ },
+ ],
+};
+
+beforeEach(() => {
+ window.sessionStorage.clear();
+});
+
+describe("AskAgentWorkspace", () => {
+ it("submits an evidence-grounded question, focuses the answer, and opens sources", async () => {
+ const request = vi.fn().mockResolvedValue(answer);
+ const onOpenPost = vi.fn();
+ render(
+ ,
+ );
+
+ await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?");
+ await userEvent.click(screen.getByRole("button", { name: "Ask" }));
+
+ const result = await screen.findByRole("article", { name: "Answer" });
+ expect(result).toHaveFocus();
+ expect(result).toHaveTextContent(answer.answer_text);
+ expect(within(result).getByRole("list", { name: "Evidence facts" })).toHaveTextContent(
+ "Semantic project",
+ );
+ expect(within(result).getByRole("list", { name: "Event Lineage timeline" })).toHaveTextContent(
+ "2026-01-01T00:00:00Z",
+ );
+
+ await userEvent.click(within(result).getByRole("button", { name: "Open cited post: Linked post" }));
+ expect(onOpenPost).toHaveBeenCalledWith("post-2");
+ expect(window.sessionStorage.getItem(GLOBAL_ASK_SESSION_STORAGE_KEY)).toBe("session-1");
+ });
+
+ it("retries once without an expired saved session", async () => {
+ window.sessionStorage.setItem(GLOBAL_ASK_SESSION_STORAGE_KEY, "stale-session");
+ const request = vi
+ .fn()
+ .mockRejectedValueOnce(new BackendError("/api/ask", 404, "Global Ask session not found"))
+ .mockResolvedValueOnce(answer);
+ render(
+ undefined}
+ request={request}
+ />,
+ );
+
+ await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?");
+ await userEvent.click(screen.getByRole("button", { name: "Ask" }));
+ expect(await screen.findByText(answer.answer_text)).toBeInTheDocument();
+
+ expect(request).toHaveBeenNthCalledWith(1, "access-token", "Which project?", "stale-session");
+ expect(request).toHaveBeenNthCalledWith(2, "access-token", "Which project?");
+ expect(window.sessionStorage.getItem(GLOBAL_ASK_SESSION_STORAGE_KEY)).toBe("session-1");
+ });
+
+ it("hides the previous answer while a replacement answer is pending", async () => {
+ let resolveSecond: (value: AskAgentResponse) => void = () => undefined;
+ const pending = new Promise((resolve) => {
+ resolveSecond = resolve;
+ });
+ const request = vi.fn().mockResolvedValueOnce(answer).mockReturnValueOnce(pending);
+ render(
+ undefined}
+ request={request}
+ />,
+ );
+
+ const textbox = screen.getByRole("textbox", { name: "Ask a question" });
+ await userEvent.type(textbox, "Which project?");
+ await userEvent.click(screen.getByRole("button", { name: "Ask" }));
+ expect(await screen.findByText(answer.answer_text)).toBeInTheDocument();
+
+ await userEvent.clear(textbox);
+ await userEvent.type(textbox, "Which person?");
+ await userEvent.click(screen.getByRole("button", { name: "Ask" }));
+ expect(screen.getByRole("button", { name: "Asking..." })).toBeDisabled();
+ expect(screen.queryByText(answer.answer_text)).not.toBeInTheDocument();
+
+ resolveSecond(answer);
+ expect(await screen.findByText(answer.answer_text)).toBeInTheDocument();
+ });
+});
+
+describe("AskAgentWorkspaceView keyboard contract", () => {
+ it("submits Enter, preserves Shift+Enter, and ignores an IME composition Enter", () => {
+ const onSubmit = vi.fn();
+ render(
+ undefined}
+ onSubmit={onSubmit}
+ onOpenPost={() => undefined}
+ />,
+ );
+ const textbox = screen.getByRole("textbox", { name: "Ask a question" });
+
+ fireEvent.keyDown(textbox, { key: "Enter", shiftKey: true });
+ fireEvent.keyDown(textbox, { key: "Enter", isComposing: true });
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ fireEvent.keyDown(textbox, { key: "Enter" });
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps a visible, non-submit error state", async () => {
+ render(
+ undefined}
+ onSubmit={() => undefined}
+ onOpenPost={() => undefined}
+ />,
+ );
+
+ expect(screen.getByRole("alert")).toHaveTextContent("Saved evidence is still available.");
+ await waitFor(() => expect(screen.getByRole("textbox", { name: "Ask a question" })).toBeEnabled());
+ });
+});
diff --git a/frontend/src/components/AskAgentWorkspace.tsx b/frontend/src/components/AskAgentWorkspace.tsx
new file mode 100644
index 000000000..444fdc560
--- /dev/null
+++ b/frontend/src/components/AskAgentWorkspace.tsx
@@ -0,0 +1,320 @@
+import {
+ useEffect,
+ useRef,
+ useState,
+ type FormEvent,
+ type KeyboardEvent,
+} from "react";
+import {
+ askAgent,
+ BackendError,
+ type AskAgentResponse,
+} from "../api";
+import { t } from "../i18n";
+import "./AskAgentWorkspace.css";
+
+export const GLOBAL_ASK_SESSION_STORAGE_KEY = "lineageweave.globalAskSessionId";
+
+const CHAT_EVIDENCE_KIND_LABELS: Record = {
+ source_field: "Source field hint",
+ semantic_project: "Semantic project",
+ semantic_role: "Semantic role",
+ semantic_keyman: "Semantic Keyman",
+};
+
+function chatEvidenceKindLabel(kind: string): string {
+ return t(CHAT_EVIDENCE_KIND_LABELS[kind] ?? "Evidence");
+}
+
+function safeSessionStorage(): Storage | null {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.sessionStorage;
+ } catch {
+ return null;
+ }
+}
+
+function readSavedSession(storage: Storage | null): string | undefined {
+ try {
+ return storage?.getItem(GLOBAL_ASK_SESSION_STORAGE_KEY) || undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function saveSession(storage: Storage | null, sessionId: string): void {
+ try {
+ storage?.setItem(GLOBAL_ASK_SESSION_STORAGE_KEY, sessionId);
+ } catch {
+ // A blocked browser storage policy must not block an evidence-grounded answer.
+ }
+}
+
+function clearSession(storage: Storage | null): void {
+ try {
+ storage?.removeItem(GLOBAL_ASK_SESSION_STORAGE_KEY);
+ } catch {
+ // The next request can still proceed without the stale session identifier.
+ }
+}
+
+function askAgentErrorMessage(error: unknown): string {
+ if (error instanceof BackendError && error.status === 503) {
+ return `${t("Ask Agent")} ${t("is temporarily unavailable.")} ${t("Saved evidence is still available.")}`;
+ }
+ return String(error);
+}
+
+export type AskAgentRequest = (
+ accessToken: string,
+ question: string,
+ sessionId?: string,
+) => Promise;
+
+export interface AskAgentWorkspaceViewProps {
+ question: string;
+ answer: AskAgentResponse | null;
+ error: string | null;
+ asking: boolean;
+ onQuestionChange: (question: string) => void;
+ onSubmit: () => void;
+ onOpenPost: (postId: string) => void;
+}
+
+export function AskAgentWorkspaceView({
+ question,
+ answer,
+ error,
+ asking,
+ onQuestionChange,
+ onSubmit,
+ onOpenPost,
+}: AskAgentWorkspaceViewProps) {
+ const answerRef = useRef(null);
+
+ useEffect(() => {
+ if (answer && !asking) answerRef.current?.focus();
+ }, [answer, asking]);
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ if (!asking && question.trim()) onSubmit();
+ }
+
+ function handleQuestionKeyDown(event: KeyboardEvent) {
+ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
+ event.preventDefault();
+ event.currentTarget.form?.requestSubmit();
+ }
+
+ return (
+
+
+
+
+
+
+
+ {error ? (
+
+ {t("Answer")}
+ {error}
+
+ ) : null}
+
+ {asking ? (
+
+ {t("Answer")}
+ {t("Asking...")}
+
+ ) : null}
+
+ {!asking && !error && !answer ? (
+
+ {t("Answer")}
+ {t("Questions use authorized posts and their evidence.")}
+
+ ) : null}
+
+ {!asking && answer ? (
+
+
+ {t("Answer")}
+ {t("Answer")}
+
+ {answer.answer_text ? {answer.answer_text}
: null}
+ {answer.next_action ? (
+
+ {t(answer.next_action)}
+
+ ) : null}
+
+ {answer.timeline && answer.timeline.length > 0 ? (
+
+ {t("Event Lineage timeline")}
+
+ {answer.timeline.map((event) => (
+ -
+
+
+ ))}
+
+
+ ) : null}
+
+ {answer.cited_posts && answer.cited_posts.length > 0 ? (
+
+
+ {t("Authorized cited posts are current. Open a cited post to read Event Lineage.")}
+
+ {t("Cited posts")}
+
+ {answer.cited_posts.map((post) => {
+ const evidence = answer.cited_post_evidence?.find(
+ (item) => item.post_id === post.post_id,
+ );
+ return (
+ -
+
+ {evidence?.facts.length ? (
+
+ {evidence.facts.map((fact, index) => (
+ -
+ {chatEvidenceKindLabel(fact.kind)}
+ {fact.text}
+
+ ))}
+
+ ) : null}
+
+ );
+ })}
+
+
+ ) : null}
+
+ ) : null}
+
+
+
+ );
+}
+
+export function AskAgentWorkspace({
+ accessToken,
+ onOpenPost,
+ request = askAgent,
+ storage = safeSessionStorage(),
+}: {
+ accessToken: string;
+ onOpenPost: (postId: string) => void;
+ request?: AskAgentRequest;
+ storage?: Storage | null;
+}) {
+ const [question, setQuestion] = useState("");
+ const [answer, setAnswer] = useState(null);
+ const [error, setError] = useState(null);
+ const [asking, setAsking] = useState(false);
+ const [sessionId, setSessionId] = useState(() => readSavedSession(storage));
+ const requestOrdinal = useRef(0);
+
+ async function handleAsk() {
+ const normalized = question.trim();
+ if (!normalized || asking) return;
+ const ordinal = ++requestOrdinal.current;
+ setAsking(true);
+ setError(null);
+ setAnswer(null);
+ try {
+ let nextAnswer: AskAgentResponse;
+ try {
+ nextAnswer = await request(accessToken, normalized, sessionId);
+ } catch (requestError) {
+ if (!(requestError instanceof BackendError) || requestError.status !== 404 || !sessionId) {
+ throw requestError;
+ }
+ setSessionId(undefined);
+ clearSession(storage);
+ nextAnswer = await request(accessToken, normalized);
+ }
+ if (ordinal !== requestOrdinal.current) return;
+ setAnswer(nextAnswer);
+ setSessionId(nextAnswer.session_id);
+ saveSession(storage, nextAnswer.session_id);
+ } catch (requestError) {
+ if (ordinal !== requestOrdinal.current) return;
+ setAnswer(null);
+ setError(askAgentErrorMessage(requestError));
+ } finally {
+ if (ordinal === requestOrdinal.current) setAsking(false);
+ }
+ }
+
+ return (
+ void handleAsk()}
+ onOpenPost={onOpenPost}
+ />
+ );
+}
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
index 6c2bfa4f4..35e6684da 100644
--- a/frontend/src/styles/tokens.css
+++ b/frontend/src/styles/tokens.css
@@ -5,6 +5,7 @@
--color-border: #e5e4e7;
--color-code-background: #f4f3ec;
--color-accent: #aa3bff;
+ --color-on-accent: #fff;
--color-accent-background: rgba(170, 59, 255, 0.1);
--color-accent-border: rgba(170, 59, 255, 0.5);
--color-chip-border: #3335;
@@ -48,6 +49,7 @@
--color-border: #2e303a;
--color-code-background: #1f2028;
--color-accent: #c084fc;
+ --color-on-accent: #fff;
--color-accent-background: rgba(192, 132, 252, 0.15);
--color-accent-border: rgba(192, 132, 252, 0.5);
--color-chip-border: #9ca3af;
diff --git a/scripts/patch_ask_agent_workspace.py b/scripts/patch_ask_agent_workspace.py
new file mode 100644
index 000000000..48deacf5f
--- /dev/null
+++ b/scripts/patch_ask_agent_workspace.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+import os
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+FRONTEND = ROOT / "frontend"
+PR_NUMBER = os.environ.get("PR_NUMBER", "").strip()
+PR_LABEL = f"#{PR_NUMBER}" if PR_NUMBER else "this PR"
+BASE_HEAD = "8832216fcd2b0a1dcb486ea83269e25695ce378a"
+
+app_path = FRONTEND / "src/App.tsx"
+app = app_path.read_text(encoding="utf-8")
+app = app.replace(" askAgent,\n", "")
+app = app.replace(" type AskAgentResponse,\n", "")
+app = app.replace(
+ 'import { CustomerMasterTree, CustomerRelatedPostCard } from "./components/CustomerMasterTree";\n',
+ 'import { CustomerMasterTree, CustomerRelatedPostCard } from "./components/CustomerMasterTree";\n'
+ 'import {\n'
+ ' AskAgentWorkspace as AskAgentPanel,\n'
+ ' GLOBAL_ASK_SESSION_STORAGE_KEY,\n'
+ '} from "./components/AskAgentWorkspace";\n',
+)
+app = app.replace(
+ '\nconst GLOBAL_ASK_SESSION_STORAGE_KEY = "lineageweave.globalAskSessionId";\n',
+ "\n",
+)
+app = re.sub(
+ r'\nconst CHAT_EVIDENCE_KIND_LABELS: Record = \{.*?\n\}\;\n\nfunction chatEvidenceKindLabel\(kind: string\): string \{.*?\n\}\n',
+ "\n",
+ app,
+ count=1,
+ flags=re.DOTALL,
+)
+start = app.index("function AskAgentPanel({")
+end = app.index("\nexport default function App", start)
+app = app[:start] + app[end:]
+app_path.write_text(app, encoding="utf-8")
+
+package_path = FRONTEND / "package.json"
+package = package_path.read_text(encoding="utf-8")
+package = package.replace('"version": "2.17.0"', '"version": "2.18.0"', 1)
+package_path.write_text(package, encoding="utf-8")
+
+baseline_path = ROOT / "docs/product-technical-gap-baseline.md"
+baseline = baseline_path.read_text(encoding="utf-8")
+if "**Active Ask Agent design head:**" not in baseline:
+ baseline = baseline.replace(
+ "**Purpose:**",
+ f"**Active Ask Agent design head:** {PR_LABEL}, stacked directly on #264 exact head `{BASE_HEAD}`; this is proposed work until merged.\n**Purpose:**",
+ 1,
+ )
+if "| FR-14 |" not in baseline:
+ fr14 = (
+ "| FR-14 | Global Ask presents a dedicated evidence workspace: semantic form submission, IME-safe keyboard behavior, explicit empty/loading/error/answer states, separated timeline and cited evidence, answer focus, responsive phone/tablet/PC layout, and the existing authorized cited-post → Event Lineage handoff. | ADR 0125, ADR 0002, ADR 0032, ADR 0090 | `AskAgentWorkspace.tsx`, component tests, Storybook scene inventory, and the existing App navigation regressions on "
+ f"{PR_LABEL} |\n"
+ )
+ baseline = re.sub(r"(\| FR-13 \|[^\n]+\n)", r"\1" + fr14, baseline, count=1)
+if "| NFR-08 |" not in baseline:
+ nfr8 = (
+ "| NFR-08 | Ask Agent uses the shared token system, clear focus differentiation, a primary content action, and 1024px/768px responsive transitions without horizontal scrolling | Focused component tests, full frontend build, Storybook state builds, and UI/UX Standard Guide v3.0 review |\n"
+ )
+ baseline = re.sub(r"(\| NFR-07 \|[^\n]+\n)", r"\1" + nfr8, baseline, count=1)
+if "Ask Agent had no dedicated UI component" not in baseline:
+ gap_row = (
+ "| Ask Agent had no dedicated UI component or executable state inventory; its chatbox reused a Keyman link-style action and mixed answer, timeline, citations, and evidence facts in one generic section. | The Global Ask feature accumulated inside `App.tsx` while the stack focused on authorization and cross-surface navigation, leaving presentation without its own ownership boundary. | "
+ f"{PR_LABEL} extracts a token-based evidence workspace, uses a semantic and IME-safe form, focuses completed answers, separates source-backed result regions, and adds five Storybook scenes plus focused regressions. | Streaming and persisted conversation history remain separate future product work; final-head hosted Checks and independent approval are still required. |\n"
+ )
+ marker = "|---|---|---|---|\n"
+ location = baseline.index(marker, baseline.index("## Active-PR gap closure evidence")) + len(marker)
+ baseline = baseline[:location] + gap_row + baseline[location:]
+if PR_NUMBER and f"| #{PR_NUMBER} | Ask Agent evidence workspace" not in baseline:
+ row = (
+ f"| #{PR_NUMBER} | Ask Agent evidence workspace and semantic chatbox | `#264` → `v2.18.0` | Exact-head verification and independent review required |\n"
+ )
+ marker = "|---|---|---|---|\n"
+ location = baseline.index(marker, baseline.index("## Active PR audit")) + len(marker)
+ baseline = baseline[:location] + row + baseline[location:]
+baseline_path.write_text(baseline, encoding="utf-8")
+
+print("Ask Agent evidence workspace patch applied")