diff --git a/apps/web/src/components/ai-sidebar/AiSidebar.tsx b/apps/web/src/components/ai-sidebar/AiSidebar.tsx index 013b1858..39ece075 100644 --- a/apps/web/src/components/ai-sidebar/AiSidebar.tsx +++ b/apps/web/src/components/ai-sidebar/AiSidebar.tsx @@ -1661,7 +1661,12 @@ function AiSidebarSession({ {turn.reasoning.trim() || turn.tools.length ? ( - + 0 || turn.images.length > 0} + /> ) : turn.status === "running" ? (

- {reasoning} -
- ) : null} - {tools.length ? ( -
-

{t("aiAssistant.sidebar.processTools.activity")}

-
    - {tools.slice(-5).map((tool) => { - const { label, detail } = toolSummary(tool, t); - const state = statusKey(tool.status); - return ( -
  • -
  • - ); - })} -
-
- - {t("aiAssistant.sidebar.processTools.raw")} - -
    - {tools.map((tool) => ( -
  • {tool.title || tool.name} · {tool.status}
  • - ))} + +
    + {reasoning.trim() ? ( +
    + {reasoning} +
    + ) : null} + {tools.length ? ( +
    +

    {t("aiAssistant.sidebar.processTools.activity")}

    +
      + {tools.slice(-5).map((tool) => { + const { label, detail } = toolSummary(tool, t); + const state = statusKey(tool.status); + return ( +
    • +
    • + ); + })}
    -
-
- ) : null} +
+ + {t("aiAssistant.sidebar.processTools.raw")} + +
    + {tools.map((tool) => ( +
  • {tool.title || tool.name} · {tool.status}
  • + ))} +
+
+ + ) : null} + ); diff --git a/apps/web/src/components/ai-sidebar/local-process-disclosure.test.mjs b/apps/web/src/components/ai-sidebar/local-process-disclosure.test.mjs new file mode 100644 index 00000000..b8fdde25 --- /dev/null +++ b/apps/web/src/components/ai-sidebar/local-process-disclosure.test.mjs @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { + initialLocalProcessDisclosure, + LOCAL_PROCESS_CLOSE_DELAY_MS, + syncLocalProcessDisclosure, + toggleLocalProcessDisclosure, +} from "./local-process-disclosure.ts"; + +const thinking = { running: true, answering: false }; +const answering = { running: true, answering: true }; +const finished = { running: false, answering: true }; + +describe("local process disclosure", () => { + test("starts open only while thinking", () => { + expect(initialLocalProcessDisclosure(thinking).open).toBe(true); + expect(initialLocalProcessDisclosure(answering).open).toBe(false); + expect(initialLocalProcessDisclosure(finished).open).toBe(false); + expect(LOCAL_PROCESS_CLOSE_DELAY_MS).toBe(1000); + }); + + test("stays open through thinking and schedules a close once the answer starts", () => { + const open = initialLocalProcessDisclosure(thinking); + expect(syncLocalProcessDisclosure(open, thinking)).toEqual(open); + expect(syncLocalProcessDisclosure(open, answering)).toEqual({ closeSoon: true }); + expect(syncLocalProcessDisclosure(open, finished)).toEqual({ closeSoon: true }); + }); + + test("does not reopen a panel the user collapsed while thinking", () => { + const collapsed = toggleLocalProcessDisclosure(initialLocalProcessDisclosure(thinking), false, thinking); + expect(collapsed).toEqual({ open: false, dismissed: true, heldOpen: false }); + expect(syncLocalProcessDisclosure(collapsed, thinking)).toEqual(collapsed); + expect(syncLocalProcessDisclosure(collapsed, answering)).toEqual(collapsed); + }); + + test("keeps a manual reopen after the answer starts", () => { + const closed = { open: false, dismissed: false, heldOpen: false }; + const reopened = toggleLocalProcessDisclosure(closed, true, answering); + expect(reopened.heldOpen).toBe(true); + expect(syncLocalProcessDisclosure(reopened, answering)).toEqual(reopened); + expect(syncLocalProcessDisclosure(reopened, finished)).toEqual(reopened); + }); + + test("still closes when the user reopens during thinking and the answer then starts", () => { + const collapsed = toggleLocalProcessDisclosure(initialLocalProcessDisclosure(thinking), false, thinking); + const reopened = toggleLocalProcessDisclosure(collapsed, true, thinking); + expect(reopened).toEqual({ open: true, dismissed: false, heldOpen: false }); + expect(syncLocalProcessDisclosure(reopened, answering)).toEqual({ closeSoon: true }); + }); +}); diff --git a/apps/web/src/components/ai-sidebar/local-process-disclosure.ts b/apps/web/src/components/ai-sidebar/local-process-disclosure.ts new file mode 100644 index 00000000..b40b4dcc --- /dev/null +++ b/apps/web/src/components/ai-sidebar/local-process-disclosure.ts @@ -0,0 +1,41 @@ +export const LOCAL_PROCESS_CLOSE_DELAY_MS = 1000; + +export type LocalProcessPhase = { running: boolean; answering: boolean }; + +export type LocalProcessDisclosure = { + open: boolean; + /** The user collapsed the panel while thinking was still the only output. */ + dismissed: boolean; + /** The user reopened the panel after the answer had already started. */ + heldOpen: boolean; +}; + +export const initialLocalProcessDisclosure = (phase: LocalProcessPhase): LocalProcessDisclosure => ({ + open: phase.running && !phase.answering, + dismissed: false, + heldOpen: false, +}); + +export function syncLocalProcessDisclosure( + current: LocalProcessDisclosure, + phase: LocalProcessPhase, +): LocalProcessDisclosure | { closeSoon: true } { + if (current.heldOpen) return current; + if (phase.running && !phase.answering) { + if (current.dismissed) return { ...current, open: false }; + return { open: true, dismissed: false, heldOpen: false }; + } + if (!current.open) return { ...current, open: false }; + return { closeSoon: true }; +} + +export function toggleLocalProcessDisclosure( + current: LocalProcessDisclosure, + open: boolean, + phase: LocalProcessPhase, +): LocalProcessDisclosure { + if (open) { + return { open: true, dismissed: false, heldOpen: !phase.running || phase.answering }; + } + return { open: false, dismissed: phase.running && !phase.answering, heldOpen: false }; +}