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 ( +
+
+
+

{t("Evidence-grounded questions")}

+

{t("Ask Agent")}

+

+ {t("Questions use authorized posts and their evidence.")} +

+
+

{t("Authorized posts in this board.")}

+
+ +
+
+