diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 84bb50fc2..75d92221a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -50775,6 +50775,11 @@ components: - messageId - ref additionalProperties: false + view: + description: "`table` when the answer asked for the table view of a table that has a chart; the chart stays for the + toggle. Absent: the chart shows first when there is one." + type: string + const: table required: - ref - source @@ -51074,6 +51079,11 @@ components: - messageId - ref additionalProperties: false + view: + description: "`table` when the answer asked for the table view of a table that has a chart; the chart stays for the + toggle. Absent: the chart shows first when there is one." + type: string + const: table columns: type: array items: diff --git a/src/app/api/insights/chat/__tests__/route-followups.test.ts b/src/app/api/insights/chat/__tests__/route-followups.test.ts index 72188cc93..8110489ee 100644 --- a/src/app/api/insights/chat/__tests__/route-followups.test.ts +++ b/src/app/api/insights/chat/__tests__/route-followups.test.ts @@ -205,11 +205,18 @@ describe("a reuse chip", () => { "result", ).map((f) => f.result); expect(result.rows).toEqual(world.storedTables[0].rows); - expect(result.chart).toBeNull(); + // The table shows first; the chart stays, so the chart is offered back. + expect(result.view).toBe("table"); + expect(result.chart).toEqual(world.storedTables[0].chart); expect(result.reusedFrom).toEqual({ messageId: h.LAST_ASSISTANT_ID, ref: "r1", }); + const [chips] = framesOf<{ followUps: CoachFollowUp[] }>( + run.frames, + "followUps", + ); + expect(chips.followUps.map((c) => c.kind)).toEqual(["as_chart"]); expect(m.appendMessage).toHaveBeenCalledWith( expect.objectContaining({ role: "assistant", providerType: "reuse" }), ); diff --git a/src/components/insights/coach-panel/__tests__/coach-results.test.tsx b/src/components/insights/coach-panel/__tests__/coach-results.test.tsx index a20b8e685..fb8135868 100644 --- a/src/components/insights/coach-panel/__tests__/coach-results.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-results.test.tsx @@ -108,6 +108,16 @@ describe("CoachResults", () => { expect(tableButton).toContain('aria-pressed="false"'); }); + it("opens an 'as a table' answer on the table, with the chart one tap away", () => { + const html = results([table({ view: "table" })], "displayed"); + expect(html).toContain('data-slot="coach-result-table"'); + expect(html).not.toContain('data-slot="coach-result-chart"'); + const chartButton = html.match( + /]*data-slot="coach-result-view-chart"[^>]*>/, + )?.[0]; + expect(chartButton).toContain('aria-pressed="false"'); + }); + it("opens a table without a chart as a table, with no toggle", () => { const html = results( [table({ chart: null, chartKind: null })], diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx index f8456035c..b0a855961 100644 --- a/src/components/insights/coach-panel/coach-results.tsx +++ b/src/components/insights/coach-panel/coach-results.tsx @@ -247,8 +247,9 @@ function CoachResultView({ const titleId = useId(); const reusedLine = useReusedLine(result); const hasChart = result.chart !== null; + // "As a table" keeps the chart for the toggle but shows the table first. const [view, setView] = useState( - hasChart && chartFirst ? "chart" : "table", + hasChart && chartFirst && result.view !== "table" ? "chart" : "table", ); if (!hasChart) { return ; diff --git a/src/lib/ai/coach/eval/__tests__/scenarios.test.ts b/src/lib/ai/coach/eval/__tests__/scenarios.test.ts index 4b605f2ec..688bd090f 100644 --- a/src/lib/ai/coach/eval/__tests__/scenarios.test.ts +++ b/src/lib/ai/coach/eval/__tests__/scenarios.test.ts @@ -700,7 +700,8 @@ describe.each(["de", "en"] as const)("in %s", (lang) => { const [table] = resultsOf(frames); // The person still sees the title on their own table. expect(table.title).toBe(INJECTED_TITLE); - expect(table.chart).toBeNull(); + // Asked for as a table: the table shows first, its chart kept beside it. + expect(table.view).toBe("table"); // Were the same text to ride the transcript, the fence check fails. const leaked = await run({ diff --git a/src/lib/ai/coach/follow-ups/__tests__/derive.test.ts b/src/lib/ai/coach/follow-ups/__tests__/derive.test.ts index 1aa7ec7da..35aeb355d 100644 --- a/src/lib/ai/coach/follow-ups/__tests__/derive.test.ts +++ b/src/lib/ai/coach/follow-ups/__tests__/derive.test.ts @@ -152,6 +152,15 @@ describe("deriveFollowUps — the rules", () => { expect(all).toEqual([]); }); + it("offers the chart back for a table shown as a table", () => { + const chips = derive({ + results: [ + table("bp", { chartKind: "line", displayed: true, view: "table" }), + ], + }); + expect(chips[0]).toMatchObject({ kind: "as_chart", reuse: true }); + }); + it("offers the other view of a table that has a chart, as a reuse chip", () => { const shown = derive({ results: [table("bp", { chartKind: "line", displayed: true })], diff --git a/src/lib/ai/coach/follow-ups/derive.ts b/src/lib/ai/coach/follow-ups/derive.ts index 7c22c6537..6bf30bee9 100644 --- a/src/lib/ai/coach/follow-ups/derive.ts +++ b/src/lib/ai/coach/follow-ups/derive.ts @@ -167,9 +167,12 @@ export function deriveFollowUps(args: { period: source.period, }; if (meta.chartKind !== null) { + // The other view of what the answer shows: a table shown as a table + // offers its chart, a shown chart offers its table. + const showsChart = meta.displayed && meta.view !== "table"; candidates.push( buildFollowUp({ - kind: meta.displayed ? "as_table" : "as_chart", + kind: showsChart ? "as_table" : "as_chart", anchor, origin: "server", locale, diff --git a/src/lib/ai/coach/results/__tests__/executor-results.test.ts b/src/lib/ai/coach/results/__tests__/executor-results.test.ts index 7890a7c97..3ea6c0338 100644 --- a/src/lib/ai/coach/results/__tests__/executor-results.test.ts +++ b/src/lib/ai/coach/results/__tests__/executor-results.test.ts @@ -376,7 +376,7 @@ describe("show_result", () => { }); }); - it("shows the table without a chart for view table", async () => { + it("shows the table first for view table, the chart kept beside it", async () => { readMessageResults.mockResolvedValue([STORED]); const result = await executeCoachTool({ userId: "u1", @@ -384,10 +384,12 @@ describe("show_result", () => { rawArguments: JSON.stringify({ ref: "m2.r1", view: "table" }), turn: turn(), }); + // The table shows first; the chart the server picks stays beside it. expect(result.table).toMatchObject({ rows: STORED.rows, - chart: null, - chartKind: null, + view: "table", + chart: { kind: "line", x: "day", series: ["value"] }, + chartKind: "line", }); }); diff --git a/src/lib/ai/coach/stream-events.ts b/src/lib/ai/coach/stream-events.ts index d20ea8bd7..20bdc60fc 100644 --- a/src/lib/ai/coach/stream-events.ts +++ b/src/lib/ai/coach/stream-events.ts @@ -167,6 +167,12 @@ const coachResultMetaShape = { .object({ messageId: messageIdSchema, ref: resultRefSchema }) .optional() .describe("Set when the table was copied from an earlier message."), + view: z + .literal("table") + .optional() + .describe( + "`table` when the answer asked for the table view of a table that has a chart; the chart stays for the toggle. Absent: the chart shows first when there is one.", + ), }; export const coachResultMetaSchema = z.object(coachResultMetaShape).meta({ diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index af311b9b0..ed4b09da2 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -893,11 +893,20 @@ async function getMetricTable( * then reads the summary of what is shown. */ function withResultView( - table: CoachResultTable, + shown: CoachResultTable, view: "table" | "chart" | undefined, locale: Locale, ): CoachResultTable { - if (view === "table") return { ...table, chart: null, chartKind: null }; + // The view is this showing's alone, never the stored copy's. + const { view: _stored, ...table } = shown; + if (view === "table") { + // The table shows first; the chart stays for the toggle and the chip + // back to it. + const chart = deriveChartSpec(table); + return chart + ? { ...table, chart, chartKind: chart.kind, view: "table" } + : { ...table, chart: null, chartKind: null }; + } if (view === "chart") { const { t } = getServerTranslator(locale); const distribution = buildDistributionTable(table, { diff --git a/src/lib/ai/coach/turn/__tests__/follow-up-turn.test.ts b/src/lib/ai/coach/turn/__tests__/follow-up-turn.test.ts index d2eed8eb2..139fb6def 100644 --- a/src/lib/ai/coach/turn/__tests__/follow-up-turn.test.ts +++ b/src/lib/ai/coach/turn/__tests__/follow-up-turn.test.ts @@ -327,16 +327,21 @@ describe("a reuse chip", () => { expect(annotated("coach.followUp.reused")).toHaveLength(1); }); - it("drops the chart on 'as a table'", async () => { + it("shows the table first on 'as a table' and offers the chart back", async () => { const asTable = { ...AS_CHART, kind: "as_table" as const }; m.findMany.mockResolvedValue(latestReply([asTable])); m.readMessageResults.mockResolvedValue([STORED]); const out = await frames(await runCoachTurn(input())); const result = out.find((f) => f.type === "result"); - expect(result?.type === "result" && result.result.chart).toBeNull(); - expect(result?.type === "result" && result.result.rows).toEqual( - STORED.rows, - ); + expect(result?.type === "result" && result.result).toMatchObject({ + view: "table", + chart: STORED.chart, + rows: STORED.rows, + }); + const chips = out.find((f) => f.type === "followUps"); + expect( + chips?.type === "followUps" && chips.followUps.map((c) => c.kind), + ).toEqual(["as_chart"]); }); it("goes to the model with the chip's hint when the table is withheld", async () => { diff --git a/src/lib/ai/coach/turn/provenance.ts b/src/lib/ai/coach/turn/provenance.ts index 9df7b0098..1be87da47 100644 --- a/src/lib/ai/coach/turn/provenance.ts +++ b/src/lib/ai/coach/turn/provenance.ts @@ -56,6 +56,7 @@ export function toResultMeta(table: CoachResultTable): CoachResultMeta { chartKind: table.chartKind, displayed: table.displayed, ...(table.reusedFrom ? { reusedFrom: table.reusedFrom } : {}), + ...(table.view ? { view: table.view } : {}), }; } diff --git a/src/lib/ai/coach/turn/reuse-turn.ts b/src/lib/ai/coach/turn/reuse-turn.ts index 1543d2699..84c35a260 100644 --- a/src/lib/ai/coach/turn/reuse-turn.ts +++ b/src/lib/ai/coach/turn/reuse-turn.ts @@ -6,8 +6,9 @@ * and persists an assistant message with `providerType: "reuse"`. * * The table is copied as it was stored: the same rows, the same columns, - * marked with where it came from. Only the view changes: "as a chart" keeps - * the stored chart, "as a table" drops it so the table is what shows. + * marked with where it came from. Only the view changes: "as a chart" shows + * the stored chart, "as a table" shows the table first and keeps the chart + * for the toggle and the chip back to it. * * Answers null, before anything is written, when the stored table cannot be * served (withheld, unreadable, or no chart to show). The pipeline then runs @@ -59,14 +60,17 @@ async function copyStoredTable(args: { ); const entry = entries?.find((candidate) => candidate.ref === ref); if (!entry || "withheld" in entry) return null; - const chart = followUp.kind === "as_chart" ? entry.chart : null; - if (followUp.kind === "as_chart" && chart === null) return null; + if (followUp.kind === "as_chart" && entry.chart === null) return null; + // Only the view changes. "As a table" keeps the chart beside the table, so + // the reply can offer the chart again. + const { view: _view, ...stored } = entry; return { - ...entry, + ...stored, ref: "r1", displayed: true, - chart, - chartKind: chart?.kind ?? null, + ...(followUp.kind === "as_table" && entry.chart !== null + ? { view: "table" as const } + : {}), reusedFrom: { messageId: sourceMessageId, ref }, }; } diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 24dd84ebf..fa488034f 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -387,6 +387,12 @@ export interface CoachResultMeta { displayed: boolean; /** Set when the table was copied from an earlier message of the thread. */ reusedFrom?: { messageId: string; ref: string }; + /** + * v1.39.4 — `table` when the answer asked for the table view of a table + * that has a chart ("as a table"). The chart stays, so the other view is + * one tap away. Absent: the chart shows first when there is one. + */ + view?: "table"; } /** Encrypted at rest; on the wire only to the owner. */