@iloveagents/foundry-web-ui 0.14.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -120,7 +120,7 @@ export function ChatContent({ starterSuggestions = DEFAULT_STARTER_SUGGESTIONS,
120
120
  const collapseChat = useChatBubbleStore((s) => s.collapse);
121
121
  const showPagePanel = useChatBubbleStore((s) => s.showPagePanel);
122
122
  const togglePagePanel = useChatBubbleStore((s) => s.togglePagePanel);
123
- return (_jsxs(ThreadPrimitive.Root, { className: cn("flex flex-col flex-1", "bg-background text-foreground", "overflow-hidden"), children: [isExpanded && (_jsxs("div", { className: "shrink-0 flex items-center justify-between px-4 py-2 border-b border-border", children: [_jsxs("span", { className: "text-sm font-medium text-foreground flex items-center gap-2", children: [_jsx(Bot, { className: "size-4 text-primary" }), "Chat"] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(TooltipIconButton, { tooltip: showPagePanel ? "Hide page panel" : "Show page panel", size: "icon", onClick: togglePagePanel, className: "hidden xl:inline-flex", children: showPagePanel ? (_jsx(PanelRightClose, { className: "size-4" })) : (_jsx(PanelRightOpen, { className: "size-4" })) }), _jsx(TooltipIconButton, { tooltip: "Minimize to bubble", size: "icon", onClick: collapseChat, children: _jsx(Minimize2, { className: "size-4" }) })] })] })), _jsxs(DropZone, { children: [_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [_jsxs(ThreadPrimitive.Viewport, { className: "absolute inset-0 overflow-y-auto bg-background", children: [_jsx(ThreadPrimitive.Empty, { children: _jsxs("div", { className: cn("flex min-h-full flex-col", "items-center justify-center", "px-4"), children: [_jsx(Bot, { className: "size-16 mb-6 text-primary/70" }), _jsx("h1", { className: "text-[2rem] font-normal text-foreground text-center mb-6", children: "How can I help you today?" }), starterSuggestions.length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-2 max-w-lg", children: starterSuggestions.map((prompt) => (_jsx(ThreadPrimitive.Suggestion, { prompt: prompt, send: true, asChild: true, children: _jsx(Button, { variant: "outline", className: "text-sm", children: prompt }) }, prompt))) }))] }) }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsxs("div", { className: "mx-auto max-w-3xl px-4 pt-8", children: [_jsx(ThreadPrimitive.Messages, { components: {
123
+ return (_jsxs(ThreadPrimitive.Root, { className: cn("flex flex-col flex-1", "bg-background text-foreground", "overflow-hidden"), children: [isExpanded && (_jsxs("div", { className: "shrink-0 flex items-center justify-between px-4 py-2 border-b border-border", children: [_jsxs("span", { className: "text-sm font-medium text-foreground flex items-center gap-2", children: [_jsx(Bot, { className: "size-4 text-primary" }), "Chat"] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(TooltipIconButton, { tooltip: showPagePanel ? "Hide page panel" : "Show page panel", size: "icon", onClick: togglePagePanel, className: "hidden xl:inline-flex", children: showPagePanel ? (_jsx(PanelRightClose, { className: "size-4" })) : (_jsx(PanelRightOpen, { className: "size-4" })) }), _jsx(TooltipIconButton, { tooltip: "Minimize to bubble", size: "icon", onClick: collapseChat, children: _jsx(Minimize2, { className: "size-4" }) })] })] })), _jsxs(DropZone, { children: [_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [_jsxs(ThreadPrimitive.Viewport, { className: "absolute inset-0 overflow-y-auto bg-background", children: [_jsx(ThreadPrimitive.Empty, { children: _jsxs("div", { className: cn("flex min-h-full flex-col", "items-center justify-center", "px-4"), children: [_jsx(Bot, { className: "size-16 mb-6 text-primary/70" }), _jsx("h1", { className: "text-[2rem] font-normal text-foreground text-center mb-6", children: "How can I help you today?" }), starterSuggestions.length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-2 max-w-lg", children: starterSuggestions.map((prompt) => (_jsx(ThreadPrimitive.Suggestion, { prompt: prompt, send: true, asChild: true, children: _jsx(Button, { variant: "outline", className: "text-sm", children: prompt }) }, prompt))) }))] }) }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsxs("div", { className: "mx-auto max-w-3xl px-4 pt-8 pb-4", children: [_jsx(ThreadPrimitive.Messages, { components: {
124
124
  UserMessage,
125
125
  EditComposer,
126
126
  AssistantMessage,
@@ -10,13 +10,18 @@
10
10
  * hand-rolling a parts config is what keeps them honest — there is now only
11
11
  * one place to drift from.
12
12
  *
13
- * Adjacent tool calls fold into one {@link ToolRunGroup} — a single summary row
14
- * over the cards, collapsed; see that component for why.
13
+ * Each stretch of machinery folds into one {@link TurnActivity} — a single
14
+ * summary row over the cards and the thinking, collapsed; see that component
15
+ * for why.
15
16
  *
16
- * Reading order is fixed rather than chronological: tool runs, then the
17
- * reasoning that explains them, then the answer. A model normally emits its
18
- * thinking FIRST, which put the least important row at the top of every
19
- * message and pushed the answer down. Flex `order` does the moving, so the
20
- * parts stay where the runtime put them in the DOM.
17
+ * Reading order is CHRONOLOGICAL, throughout. It briefly was not: text
18
+ * carried a flex `order` that floated every prose part to the bottom of the
19
+ * message, to keep a reasoning row from sitting above a one-line answer.
20
+ * With the machinery folded into a quiet 20px row that argument is spent,
21
+ * and the hoist actively hurt in a multi-step turn it stripped the
22
+ * narration out from between the work and stacked the bare rows into the
23
+ * ladder this component exists to prevent. It also glued consecutive prose
24
+ * parts into one run-on paragraph. Parts now render where the model put
25
+ * them.
21
26
  */
22
27
  export declare function ChatMessageParts(): import("react/jsx-runtime").JSX.Element;
@@ -3,12 +3,21 @@ import { MessagePrimitive, groupPartByType } from "@assistant-ui/react";
3
3
  import { MarkdownText } from "./markdown-text.js";
4
4
  import { ReasoningMessagePartComponent } from "./reasoning-part.js";
5
5
  import { ToolFallback } from "./tool-fallback.js";
6
- import { ToolRunGroup } from "./tool-run-group.js";
6
+ import { TurnActivity } from "./turn-activity.js";
7
7
  /**
8
- * Coalesce adjacent tool calls into one `group-tool` node. Everything else
9
- * stays ungrouped and renders as its own leaf.
8
+ * A turn's machinery nests under a `group-activity` node; inside it, adjacent
9
+ * tool calls still coalesce into `group-tool`.
10
+ *
11
+ * Text is deliberately absent from this map. The model's prose stays
12
+ * ungrouped, so it can never end up inside the fold — and because grouping
13
+ * coalesces ADJACENT parts, each stretch of prose also ends one activity node
14
+ * and starts the next. That is the intent: a multi-step turn reads as work,
15
+ * what it learned, work, what it learned, answer.
10
16
  */
11
- const groupBy = groupPartByType({ "tool-call": ["group-tool"] });
17
+ const groupBy = groupPartByType({
18
+ "tool-call": ["group-activity", "group-tool"],
19
+ reasoning: ["group-activity"],
20
+ });
12
21
  /**
13
22
  * The parts of an assistant message — ONE renderer, used by every chat
14
23
  * surface.
@@ -21,27 +30,53 @@ const groupBy = groupPartByType({ "tool-call": ["group-tool"] });
21
30
  * hand-rolling a parts config is what keeps them honest — there is now only
22
31
  * one place to drift from.
23
32
  *
24
- * Adjacent tool calls fold into one {@link ToolRunGroup} — a single summary row
25
- * over the cards, collapsed; see that component for why.
33
+ * Each stretch of machinery folds into one {@link TurnActivity} — a single
34
+ * summary row over the cards and the thinking, collapsed; see that component
35
+ * for why.
26
36
  *
27
- * Reading order is fixed rather than chronological: tool runs, then the
28
- * reasoning that explains them, then the answer. A model normally emits its
29
- * thinking FIRST, which put the least important row at the top of every
30
- * message and pushed the answer down. Flex `order` does the moving, so the
31
- * parts stay where the runtime put them in the DOM.
37
+ * Reading order is CHRONOLOGICAL, throughout. It briefly was not: text
38
+ * carried a flex `order` that floated every prose part to the bottom of the
39
+ * message, to keep a reasoning row from sitting above a one-line answer.
40
+ * With the machinery folded into a quiet 20px row that argument is spent,
41
+ * and the hoist actively hurt in a multi-step turn it stripped the
42
+ * narration out from between the work and stacked the bare rows into the
43
+ * ladder this component exists to prevent. It also glued consecutive prose
44
+ * parts into one run-on paragraph. Parts now render where the model put
45
+ * them.
32
46
  */
33
47
  export function ChatMessageParts() {
34
48
  return (_jsx(MessagePrimitive.GroupedParts, { groupBy: groupBy, indicator: "never", children: ({ part, children }) => {
35
49
  switch (part.type) {
50
+ case "group-activity":
51
+ return (_jsx(TurnActivity, { indices: part.indices, isRunning: part.status?.type === "running", children: children }));
36
52
  case "group-tool":
37
- return (_jsx(ToolRunGroup, { indices: part.indices, isRunning: part.status?.type === "running", children: children }));
53
+ // Inside the activity fold, so NOT another collapsed row
54
+ // opening one disclosure only to meet a second is a maze. The
55
+ // cards render directly; the count already showed on the row
56
+ // above.
57
+ return _jsx("div", { className: "flex flex-col gap-2", children: children });
38
58
  case "text":
39
- // The answer is ordered LAST so the message ends on its point,
40
- // with the reasoning row (order-1) sitting between the tool runs
41
- // and it. Tool groups keep the default order-0.
42
- return (_jsx("div", { className: "order-2", children: _jsx(MarkdownText, {}) }));
59
+ // The wrapper is load-bearing, though it carries no classes.
60
+ // `MarkdownText` renders `<ReactMarkdown>` directly, which emits
61
+ // its top-level blocks into a FRAGMENT without an element to
62
+ // hold them, every paragraph, list and code block becomes a
63
+ // direct child of the message's `gap-4` flex container and picks
64
+ // up 16px of message-part spacing between them, on top of their
65
+ // own margins. It used to be the `order-2` div; the ordering went
66
+ // away, the wrapper must not.
67
+ return (_jsx("div", { children: _jsx(MarkdownText, {}) }));
43
68
  case "reasoning":
44
- return _jsx(ReasoningMessagePartComponent, { text: part.text });
69
+ // Plain inside the fold, rather than behind a second disclosure.
70
+ //
71
+ // Its POSITION is only as chronological as the parts array it is
72
+ // given. Replayed history preserves the model's interleaving;
73
+ // the live adapter does not — it accumulates all reasoning into a
74
+ // single part emitted ahead of every tool call, so a live
75
+ // reasoning A → tool A → reasoning B → tool B arrives as
76
+ // reasoning A+B → tool A → tool B. That is the adapter's to fix,
77
+ // and rendering in place is right either way; this note is here
78
+ // so the next reader does not take the ordering on faith.
79
+ return _jsx(ReasoningMessagePartComponent, { text: part.text, nested: true });
45
80
  case "tool-call":
46
81
  return part.toolUI ?? _jsx(ToolFallback, { ...part });
47
82
  default:
@@ -9,10 +9,11 @@ import type { ReactNode } from "react";
9
9
  * different paddings, different hover treatments, different default states.
10
10
  * Read together in a transcript they looked like two unrelated widgets.
11
11
  *
12
- * So the shape lives here once. A row is: disclosure caret, the part's own
13
- * icon, its labelleft-aligned in that order, on a single baseline, with
14
- * identical metrics. The caret is the FIRST thing in the row because it is
15
- * the control; the icon that follows says what kind of thing is folded away.
12
+ * So the shape lives here once. A row is: the part's own icon, its label,
13
+ * then the disclosure caret — on a single baseline, with identical metrics.
14
+ * The icon leads because it is the glyph that carries meaning (what kind of
15
+ * thing is folded away, and whether it went well); the caret trails, where
16
+ * the eye lands after the words and where every chat client puts it.
16
17
  *
17
18
  * Everything is understated on purpose. These rows sit between the user's
18
19
  * question and the answer, and they are machinery, not content: muted until
@@ -20,7 +21,7 @@ import type { ReactNode } from "react";
20
21
  * hairline rule, so an open row reads as a margin note rather than a second
21
22
  * message.
22
23
  */
23
- export declare function MessageDisclosure({ icon, label, open, onOpenChange, tone, placement, ariaLabel, children, ...rest }: {
24
+ export declare function MessageDisclosure({ icon, label, open, onOpenChange, tone, ariaLabel, children, ...rest }: {
24
25
  /** The part's own icon — a brain, a wrench, a status glyph. */
25
26
  icon: ReactNode;
26
27
  label: ReactNode;
@@ -28,19 +29,6 @@ export declare function MessageDisclosure({ icon, label, open, onOpenChange, ton
28
29
  onOpenChange: (open: boolean) => void;
29
30
  /** ``destructive`` is for a run where everything failed; nothing else. */
30
31
  tone?: "muted" | "destructive";
31
- /**
32
- * ``after-tools`` sits the row between the tool runs and the answer,
33
- * wherever the model actually emitted it — reasoning belongs under the
34
- * work it explains and above the conclusion it produced. (The message
35
- * text is ordered after it; see `ChatMessageParts`.)
36
- *
37
- * It is a flex `order`, so the row moves VISUALLY while staying put in the
38
- * DOM. That is exactly why such a row carries a different attribute: the
39
- * compaction below is a DOM-sibling rule, and a row that is first in the
40
- * DOM but second on screen would otherwise hand its negative margin to
41
- * whichever row really is on top.
42
- */
43
- placement?: "inline" | "after-tools";
44
32
  ariaLabel?: string;
45
33
  children: ReactNode;
46
34
  } & Record<`data-${string}`, unknown>): import("react/jsx-runtime").JSX.Element;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ChevronRight } from "lucide-react";
2
+ import { ChevronDown } from "lucide-react";
3
3
  import { cn } from "@iloveagents/foundry-web-primitives";
4
4
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
5
5
  /**
@@ -12,10 +12,11 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/colla
12
12
  * different paddings, different hover treatments, different default states.
13
13
  * Read together in a transcript they looked like two unrelated widgets.
14
14
  *
15
- * So the shape lives here once. A row is: disclosure caret, the part's own
16
- * icon, its labelleft-aligned in that order, on a single baseline, with
17
- * identical metrics. The caret is the FIRST thing in the row because it is
18
- * the control; the icon that follows says what kind of thing is folded away.
15
+ * So the shape lives here once. A row is: the part's own icon, its label,
16
+ * then the disclosure caret — on a single baseline, with identical metrics.
17
+ * The icon leads because it is the glyph that carries meaning (what kind of
18
+ * thing is folded away, and whether it went well); the caret trails, where
19
+ * the eye lands after the words and where every chat client puts it.
19
20
  *
20
21
  * Everything is understated on purpose. These rows sit between the user's
21
22
  * question and the answer, and they are machinery, not content: muted until
@@ -23,8 +24,22 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/colla
23
24
  * hairline rule, so an open row reads as a margin note rather than a second
24
25
  * message.
25
26
  */
26
- export function MessageDisclosure({ icon, label, open, onOpenChange, tone = "muted", placement = "inline", ariaLabel, children, ...rest }) {
27
- return (_jsxs(Collapsible, { open: open, onOpenChange: onOpenChange, ...(placement === "after-tools"
28
- ? { "data-message-disclosure-reordered": true }
29
- : { "data-message-disclosure": true }), className: cn("w-full [[data-message-disclosure]_+_&]:-mt-3", placement === "after-tools" && "order-1"), ...rest, children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", "data-disclosure-trigger": true, "aria-label": ariaLabel, className: cn("group/disclosure flex w-full items-center gap-1.5 rounded-md", "px-1.5 py-0.5 text-left text-xs transition-colors", "text-muted-foreground hover:bg-muted hover:text-foreground", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", tone === "destructive" && "text-destructive hover:text-destructive"), children: [_jsx(ChevronRight, { "aria-hidden": "true", className: cn("size-3.5 shrink-0 opacity-60 transition-transform duration-150", "group-hover/disclosure:opacity-100", open && "rotate-90") }), _jsx("span", { className: "flex size-3.5 shrink-0 items-center justify-center", children: icon }), _jsx("span", { className: "truncate", children: label })] }) }), _jsx(CollapsibleContent, { children: _jsx("div", { className: "mt-1.5 ml-3 border-l border-border pl-3", children: children }) })] }));
27
+ export function MessageDisclosure({ icon, label, open, onOpenChange, tone = "muted", ariaLabel, children, ...rest }) {
28
+ return (_jsxs(Collapsible, { open: open, onOpenChange: onOpenChange, "data-message-disclosure": true,
29
+ // Rows render where the model put them. There was once an
30
+ // ``after-tools`` placement that moved a row with flex `order` and a
31
+ // separate data attribute, because the compaction below is a
32
+ // DOM-SIBLING rule and a row that is first in the DOM but second on
33
+ // screen would hand its negative margin to the wrong neighbour. Both
34
+ // are gone: the reordering existed to keep a reasoning row from sitting
35
+ // above a short answer, which folding the machinery into one quiet row
36
+ // already solves.
37
+ className: "w-full [[data-message-disclosure]_+_&]:-mt-3", ...rest, children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", "data-disclosure-trigger": true, "aria-label": ariaLabel, className: cn(
38
+ // `w-fit` with `-ml-1.5`: the row is a pill that hugs its own
39
+ // words, and the negative margin cancels the padding so the
40
+ // ICON lines up with the message text's left edge rather than
41
+ // sitting 6px inside it. Full-width was worse on both counts —
42
+ // it made a hover target the width of the message, and it left
43
+ // the caret stranded at the far right once the caret moved.
44
+ "group/disclosure -ml-1.5 flex w-fit max-w-full items-center gap-1.5 rounded-md", "px-1.5 py-0.5 text-left text-xs transition-colors", "text-muted-foreground hover:bg-muted hover:text-foreground", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", tone === "destructive" && "text-destructive hover:text-destructive"), children: [_jsx("span", { className: "flex size-3.5 shrink-0 items-center justify-center", children: icon }), _jsx("span", { className: "truncate", children: label }), _jsx(ChevronDown, { "aria-hidden": "true", className: cn("size-3.5 shrink-0 opacity-60 transition-transform duration-150", "group-hover/disclosure:opacity-100", open && "rotate-180") })] }) }), _jsx(CollapsibleContent, { children: _jsx("div", { className: "mt-1.5 ml-3 border-l border-border pl-3", children: children }) })] }));
30
45
  }
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useStore } from "zustand";
3
3
  import { Check, ChevronDown } from "lucide-react";
4
4
  import { reasoningDefaultStore, reasoningEffortStore, REASONING_EFFORT_LABELS, } from "@iloveagents/foundry-agent";
@@ -11,6 +11,40 @@ const OPTIONS = [
11
11
  { value: "high", hint: "Slower, better on hard problems" },
12
12
  { value: "xhigh", hint: "Slowest, for the hardest questions" },
13
13
  ];
14
+ /** Bar count per level, lowest to highest. */
15
+ const EFFORT_RANK = {
16
+ low: 1,
17
+ medium: 2,
18
+ high: 3,
19
+ xhigh: 4,
20
+ };
21
+ /**
22
+ * The level as four rising bars.
23
+ *
24
+ * The trigger's job is to answer "how hard is it thinking?" at a glance, and
25
+ * in a narrow composer — the chat bubble, a side panel — the words cost more
26
+ * width than that question is worth. Bars cost ~14px and answer it without
27
+ * reading, so in that case the words drop and this stands in for them.
28
+ *
29
+ * Takes a CONCRETE level, never `null`. A gauge with nothing lit is not a
30
+ * gauge, it is four faint marks; the caller keeps the words instead when
31
+ * there is no level to encode, which makes the empty state unrepresentable
32
+ * here rather than something this has to render politely.
33
+ *
34
+ * Deliberately shows LEVEL only, never whether the level is pinned or
35
+ * inherited from `Auto`. The level is what changes cost and latency; the mode
36
+ * is the half this trigger already drops first when space runs short.
37
+ *
38
+ * `bg-current` throughout, so the bars inherit the button's own muted →
39
+ * foreground hover transition instead of needing their own colour states.
40
+ */
41
+ function EffortGauge({ level }) {
42
+ const rank = EFFORT_RANK[level];
43
+ return (_jsx("span", { "aria-hidden": "true", className: "flex h-3.5 shrink-0 items-end gap-px", children: [1, 2, 3, 4].map((step) => (_jsx("span", { className: cn("w-0.5 rounded-full bg-current transition-opacity", step === 1 && "h-1", step === 2 && "h-1.5", step === 3 && "h-2", step === 4 && "h-2.5",
44
+ // The unlit bars stay visible: the gauge has to read as "2 of 4",
45
+ // not as a bare pair of marks whose ceiling you cannot see.
46
+ step <= rank ? "opacity-100" : "opacity-25") }, step))) }));
47
+ }
14
48
  /**
15
49
  * What `Auto` is currently doing, in words.
16
50
  *
@@ -54,5 +88,7 @@ export function ReasoningEffortPicker({ className }) {
54
88
  ? REASONING_EFFORT_LABELS[resolved]
55
89
  : REASONING_EFFORT_LABELS[effort];
56
90
  const triggerLabel = showsMode ? `${modeLabel} · ${levelLabel}` : levelLabel;
57
- return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "data-reasoning-effort-trigger": true, className: cn("flex items-center gap-1 rounded-md px-2 py-1", "text-xs text-muted-foreground hover:text-foreground hover:bg-muted", "transition-colors", className), "aria-label": `Intelligence: ${triggerLabel}`, title: `Intelligence: ${triggerLabel}`, children: [_jsxs("span", { className: "whitespace-nowrap", children: [showsMode && _jsxs("span", { className: "@max-[24rem]:hidden", children: [modeLabel, " \u00B7 "] }), levelLabel] }), _jsx(ChevronDown, { className: "size-3.5 shrink-0" })] }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-56", children: [_jsx(DropdownMenuLabel, { className: "text-xs font-normal text-muted-foreground", children: "Intelligence" }), OPTIONS.map((opt) => (_jsxs(DropdownMenuItem, { onSelect: () => setEffort(opt.value), className: "flex items-start gap-2", children: [_jsx(Check, { className: cn("size-4 mt-0.5 shrink-0", effort === opt.value ? "opacity-100" : "opacity-0") }), _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "text-sm", children: REASONING_EFFORT_LABELS[opt.value] }), _jsx("span", { className: "text-xs text-muted-foreground", children: opt.value === "default" ? autoHint(resolved, scope) : opt.hint })] })] }, opt.value)))] })] }));
91
+ // `default` with nothing resolved is the one case with no level to draw.
92
+ const gaugeLevel = effort === "default" ? resolved : effort;
93
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "data-reasoning-effort-trigger": true, className: cn("flex items-center gap-1 rounded-md px-2 py-1", "text-xs text-muted-foreground hover:text-foreground hover:bg-muted", "transition-colors", className), "aria-label": `Intelligence: ${triggerLabel}`, title: `Intelligence: ${triggerLabel}`, children: [gaugeLevel !== null && (_jsx("span", { className: "hidden @max-[24rem]:flex", children: _jsx(EffortGauge, { level: gaugeLevel }) })), _jsxs("span", { className: cn("whitespace-nowrap", gaugeLevel !== null && "@max-[24rem]:hidden"), children: [showsMode && `${modeLabel} · `, levelLabel] }), _jsx(ChevronDown, { className: "size-3.5 shrink-0" })] }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-56", children: [_jsx(DropdownMenuLabel, { className: "text-xs font-normal text-muted-foreground", children: "Intelligence" }), OPTIONS.map((opt) => (_jsxs(DropdownMenuItem, { onSelect: () => setEffort(opt.value), className: "flex items-start gap-2", children: [_jsx(Check, { className: cn("size-4 mt-0.5 shrink-0", effort === opt.value ? "opacity-100" : "opacity-0") }), _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "text-sm", children: REASONING_EFFORT_LABELS[opt.value] }), _jsx("span", { className: "text-xs text-muted-foreground", children: opt.value === "default" ? autoHint(resolved, scope) : opt.hint })] })] }, opt.value)))] })] }));
58
94
  }
@@ -6,14 +6,17 @@
6
6
  * a curious reader, not the response. It stays visible after the run so a
7
7
  * reader can check *why* an answer came out the way it did.
8
8
  *
9
- * The row shape comes from `MessageDisclosure`, shared with the tool run
10
- * group — these two sit next to each other in a message and used to be built
11
- * separately, which is how they ended up with their carets on opposite sides.
9
+ * The row shape comes from `MessageDisclosure`, shared with the turn's
10
+ * activity row — these sit next to each other in a message and used to be
11
+ * built separately, which is how they ended up with their carets on opposite
12
+ * sides.
12
13
  */
13
- export declare function ReasoningPart({ text }: {
14
+ export declare function ReasoningPart({ text, nested }: {
14
15
  text: string;
16
+ nested?: boolean;
15
17
  }): import("react/jsx-runtime").JSX.Element | null;
16
18
  /** Adapter for assistant-ui's `Reasoning` part slot. */
17
- export declare const ReasoningMessagePartComponent: ({ text }: {
19
+ export declare const ReasoningMessagePartComponent: ({ text, nested, }: {
18
20
  text: string;
21
+ nested?: boolean;
19
22
  }) => import("react/jsx-runtime").JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
3
  import { Brain } from "lucide-react";
4
4
  import { MessageDisclosure } from "./message-disclosure.js";
@@ -10,19 +10,23 @@ import { MessageDisclosure } from "./message-disclosure.js";
10
10
  * a curious reader, not the response. It stays visible after the run so a
11
11
  * reader can check *why* an answer came out the way it did.
12
12
  *
13
- * The row shape comes from `MessageDisclosure`, shared with the tool run
14
- * group — these two sit next to each other in a message and used to be built
15
- * separately, which is how they ended up with their carets on opposite sides.
13
+ * The row shape comes from `MessageDisclosure`, shared with the turn's
14
+ * activity row — these sit next to each other in a message and used to be
15
+ * built separately, which is how they ended up with their carets on opposite
16
+ * sides.
16
17
  */
17
- export function ReasoningPart({ text }) {
18
+ export function ReasoningPart({ text, nested = false }) {
18
19
  const [isOpen, setIsOpen] = useState(false);
19
20
  if (!text.trim())
20
21
  return null;
21
- return (_jsx(MessageDisclosure, { icon: _jsx(Brain, { className: "size-3.5" }), label: "Reasoning", open: isOpen, onOpenChange: setIsOpen,
22
- // Under the work, above the conclusion: the model usually emits its
23
- // thinking first, but a reader wants "what it did", then "why", and
24
- // the answer last — the message should end on its point.
25
- placement: "after-tools", ariaLabel: isOpen ? "Hide reasoning" : "Show reasoning", children: _jsx("div", { className: "whitespace-pre-wrap text-xs text-muted-foreground", children: text }) }));
22
+ // Inside the turn's activity fold there is no second fold: the reader
23
+ // already opened one to get here, and a disclosure containing disclosures
24
+ // is a maze. Just the thinking, captioned, in the slot the model produced
25
+ // it in.
26
+ if (nested) {
27
+ return (_jsxs("div", { className: "flex flex-col gap-1", "data-reasoning-part": true, children: [_jsxs("span", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [_jsx(Brain, { className: "size-3.5" }), "Reasoning"] }), _jsx("div", { className: "whitespace-pre-wrap text-xs text-muted-foreground", children: text })] }));
28
+ }
29
+ return (_jsx(MessageDisclosure, { icon: _jsx(Brain, { className: "size-3.5" }), label: "Reasoning", open: isOpen, onOpenChange: setIsOpen, ariaLabel: isOpen ? "Hide reasoning" : "Show reasoning", children: _jsx("div", { className: "whitespace-pre-wrap text-xs text-muted-foreground", children: text }) }));
26
30
  }
27
31
  /** Adapter for assistant-ui's `Reasoning` part slot. */
28
- export const ReasoningMessagePartComponent = ({ text }) => (_jsx(ReasoningPart, { text: text }));
32
+ export const ReasoningMessagePartComponent = ({ text, nested = false, }) => _jsx(ReasoningPart, { text: text, nested: nested });
@@ -1,17 +1,17 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
3
  import { NavLink, useLocation, useNavigate } from "react-router";
4
- import { Ellipsis, PanelLeft, PanelLeftOpen, SquarePen, ChevronDown, ChevronRight, GripVertical, Plus, Upload } from "lucide-react";
4
+ import { Ellipsis, PanelLeft, PanelLeftOpen, SquarePen, ChevronDown, ChevronRight, GripVertical, Plus, Upload, } from "lucide-react";
5
5
  import { cn } from "@iloveagents/foundry-web-primitives";
6
6
  import { useSidebarStore, RAIL_WIDTH, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH, } from "../lib/sidebar-store.js";
7
7
  import { useToolPanelStore } from "../lib/tool-panel-store.js";
8
8
  import { useNewConversation } from "../lib/use-new-conversation.js";
9
9
  import { useNavStore } from "../lib/nav-store.js";
10
10
  import { useAppStore } from "../lib/app-store.js";
11
- import { getContainerDropZoneLabel, hasAnyDropTarget, hasExternalFilesData } from "../lib/nav-dnd.js";
11
+ import { getContainerDropZoneLabel, hasAnyDropTarget, hasExternalFilesData, } from "../lib/nav-dnd.js";
12
12
  import { TooltipIconButton } from "./tooltip-icon-button.js";
13
13
  import { Button } from "@iloveagents/foundry-web-primitives";
14
- import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu.js";
14
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu.js";
15
15
  import { AppBrand } from "./app-brand.js";
16
16
  const ACTIVE_NAV_ROW_CLASS = "border border-transparent bg-sidebar-selected shadow-none";
17
17
  const ACTIVE_NAV_TEXT_CLASS = "font-semibold text-sidebar-foreground";
@@ -127,10 +127,7 @@ function useActiveNavRowScroll(isActive) {
127
127
  return;
128
128
  const rect = element.getBoundingClientRect();
129
129
  const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
130
- const isOnScreen = rect.width > 0 &&
131
- rect.height > 0 &&
132
- rect.right > 0 &&
133
- rect.left < viewportWidth;
130
+ const isOnScreen = rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.left < viewportWidth;
134
131
  if (!isOnScreen)
135
132
  return;
136
133
  const nav = element.closest("nav");
@@ -427,10 +424,10 @@ function ContainerDropZone({ dnd }) {
427
424
  * row underneath; the branch chevron keeps its own, since positioned
428
425
  * elements paint above the static button and win the hit test.
429
426
  */
430
- function NavTwisty({ hasChildren, isExpanded, onToggle, label, isActive, depth, inTree = true, }) {
427
+ function NavTwisty({ hasChildren, childrenUnloaded = false, isExpanded, onToggle, label, isActive, depth, inTree = true, }) {
431
428
  const cell = cn(NAV_TWISTY_CLASS, getNavDepthOffsets(depth).column);
432
429
  if (!hasChildren) {
433
- return (_jsx("span", { "data-nav-twisty-cell": true, "aria-hidden": "true", className: cn(cell, "pointer-events-none"), children: inTree && (_jsx("span", { className: cn("size-1 rounded-full", isActive ? "bg-primary/70" : "bg-sidebar-foreground/30") })) }));
430
+ return (_jsx("span", { "data-nav-twisty-cell": true, "aria-hidden": "true", className: cn(cell, "pointer-events-none"), children: inTree && !childrenUnloaded && (_jsx("span", { "data-nav-leaf-dot": true, className: cn("size-1 rounded-full", isActive ? "bg-primary/70" : "bg-sidebar-foreground/30") })) }));
434
431
  }
435
432
  return (_jsx("span", { "data-nav-twisty-cell": true, className: cell, children: _jsx("button", { type: "button", "data-nav-twisty": true, "aria-expanded": isExpanded, "aria-label": isExpanded ? `Collapse ${label}` : `Expand ${label}`, onClick: (e) => {
436
433
  e.preventDefault();
@@ -480,11 +477,13 @@ function NavLeafItem({ item, isActive, onClick, depth = 0, }) {
480
477
  !isDisabled &&
481
478
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
482
479
  !isDisabled &&
483
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, label: item.label, isActive: isActive && !isDisabled, depth: depth }), _jsxs("button", { type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, className: cn("flex items-center gap-2 flex-1 min-w-0 py-1.5 text-sm text-left", bodyPl, "pr-3 transition-[padding] duration-150", NAV_ROW_ACTIONS_CLEARANCE, isDisabled
480
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, label: item.label, isActive: isActive && !isDisabled, depth: depth }), _jsxs("button", { type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, className: cn("flex items-center gap-2 flex-1 min-w-0 py-1.5 text-sm text-left", bodyPl, "pr-3 transition-[padding] duration-150", NAV_ROW_ACTIONS_CLEARANCE, isDisabled
484
481
  ? "text-sidebar-foreground/45 cursor-default"
485
482
  : isActive
486
483
  ? ACTIVE_NAV_TEXT_CLASS
487
- : "text-sidebar-foreground/80"), children: [_jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }));
484
+ : "text-sidebar-foreground/80"), children: [_jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
485
+ ? ACTIVE_NAV_BADGE_CLASS
486
+ : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }));
488
487
  }
489
488
  return (_jsxs("button", { ref: activeButtonRef, "data-active-nav-row": isActive ? "true" : undefined, "data-nav-depth": depth, type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, ...(isDisabled ? {} : draggableProps), ...(isDisabled ? {} : dropTargetProps), className: cn("relative flex items-center gap-2 w-full", "rounded-lg border border-transparent pr-3 py-1.5 text-sm text-left", bodyPl, isDisabled
490
489
  ? "opacity-40 cursor-default"
@@ -493,7 +492,7 @@ function NavLeafItem({ item, isActive, onClick, depth = 0, }) {
493
492
  !isDisabled &&
494
493
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
495
494
  !isDisabled &&
496
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, label: item.label, isActive: isActive && !isDisabled, depth: depth }), _jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: cn("truncate", isActive && !isDisabled && ACTIVE_NAV_TEXT_CLASS), children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "ml-auto size-3.5 shrink-0 text-primary pointer-events-none" }))] }));
495
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, label: item.label, isActive: isActive && !isDisabled, depth: depth }), _jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: cn("truncate", isActive && !isDisabled && ACTIVE_NAV_TEXT_CLASS), children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "ml-auto size-3.5 shrink-0 text-primary pointer-events-none" }))] }));
497
496
  }
498
497
  function SubNavItems({ items, parentPath, depth = 1, }) {
499
498
  const navigate = useNavigate();
@@ -587,7 +586,9 @@ function NestedFolderItem({ item, depth }) {
587
586
  ? ACTIVE_NAV_ICON_CLASS
588
587
  : isAncestorActive
589
588
  ? "text-sidebar-foreground/80"
590
- : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }), isExpanded && item.children && (_jsxs("div", { children: [item.children.map((child) => {
589
+ : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
590
+ ? ACTIVE_NAV_BADGE_CLASS
591
+ : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }), isExpanded && item.children && (_jsxs("div", { children: [item.children.map((child) => {
591
592
  const hasNestedChildren = child.children && child.children.length > 0;
592
593
  if (hasNestedChildren) {
593
594
  return (_jsx(NestedFolderItem, { item: child, depth: depth + 1 }, child.to + child.label));
@@ -634,7 +635,7 @@ function CollapsibleNavItem({ item, inTree = true }) {
634
635
  !isDisabled &&
635
636
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
636
637
  !isDisabled &&
637
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: hasChildren, inTree: inTree, isExpanded: isExpanded, onToggle: hasChildren ? () => setManualOverride(!isExpanded) : undefined, label: item.label, isActive: (selected || ancestor) && !isDisabled, depth: 0 }), _jsxs("button", { type: "button", onClick: () => {
638
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: hasChildren, childrenUnloaded: item.childrenUnloaded, inTree: inTree, isExpanded: isExpanded, onToggle: hasChildren ? () => setManualOverride(!isExpanded) : undefined, label: item.label, isActive: (selected || ancestor) && !isDisabled, depth: 0 }), _jsxs("button", { type: "button", onClick: () => {
638
639
  if (isDisabled)
639
640
  return;
640
641
  setNavContext({
@@ -659,13 +660,15 @@ function CollapsibleNavItem({ item, inTree = true }) {
659
660
  ? ACTIVE_NAV_ICON_CLASS
660
661
  : ancestor
661
662
  ? "text-sidebar-foreground/80"
662
- : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", selected ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }));
663
+ : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", selected
664
+ ? ACTIVE_NAV_BADGE_CLASS
665
+ : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }));
663
666
  return (_jsxs("div", { ...(isDisabled ? {} : dropTargetProps), ...(isDisabled ? {} : draggableProps), children: [inner(isActive, isAncestorActive), hasChildren && isExpanded && (_jsxs(_Fragment, { children: [_jsx(SubNavItems, { items: item.children, parentPath: item.to }), item.dnd?.canDrop() && _jsx(ContainerDropZone, { dnd: item.dnd })] }))] }));
664
667
  }
665
668
  if (isDisabled) {
666
669
  return (_jsxs("div", { "data-nav-depth": 0, className: cn("relative flex items-center gap-2 w-full opacity-40", inTree ? getNavDepthOffsets(0).body : NAV_FLAT_BODY_CLASS,
667
670
  // Transparent border for box-model parity — see the NavLink variant.
668
- "rounded-lg border border-transparent pr-3 py-2 text-left text-sm text-sidebar-foreground/45"), children: [_jsx(NavTwisty, { hasChildren: false, inTree: inTree, label: item.label, depth: 0 }), _jsx(item.icon, { className: "size-4 shrink-0 text-sidebar-foreground/45" }), _jsx("span", { className: "truncate", children: item.label })] }));
671
+ "rounded-lg border border-transparent pr-3 py-2 text-left text-sm text-sidebar-foreground/45"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, inTree: inTree, label: item.label, depth: 0 }), _jsx(item.icon, { className: "size-4 shrink-0 text-sidebar-foreground/45" }), _jsx("span", { className: "truncate", children: item.label })] }));
669
672
  }
670
673
  return (_jsx(NavLink, { ref: activeLinkRef, "data-active-nav-row": currentPath === item.to ? "true" : undefined, "data-nav-depth": 0, to: item.to, ...draggableProps, ...dropTargetProps, className: ({ isActive }) => cn("relative flex items-center gap-2 w-full", inTree ? getNavDepthOffsets(0).body : NAV_FLAT_BODY_CLASS,
671
674
  // The transparent border is NOT decoration: it is what makes this
@@ -675,7 +678,8 @@ function CollapsibleNavItem({ item, inTree = true }) {
675
678
  // siblings' and the column goes ragged.
676
679
  "rounded-xl border border-transparent pr-3 py-2 text-sm text-left", "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70", isActive && cn(ACTIVE_NAV_ROW_CLASS, ACTIVE_NAV_TEXT_CLASS), isDragOver &&
677
680
  !isFileDragOver &&
678
- "bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50", isFileDragOver && "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && "opacity-50"), children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx(NavTwisty, { hasChildren: false, inTree: inTree, label: item.label, isActive: isActive, depth: 0 }), _jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
681
+ "bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50", isFileDragOver &&
682
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && "opacity-50"), children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, inTree: inTree, label: item.label, isActive: isActive, depth: 0 }), _jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
679
683
  ? ACTIVE_NAV_BADGE_CLASS
680
684
  : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot }), isFileDragOver && (_jsx(Upload, { "aria-hidden": "true", className: "ml-auto size-3.5 shrink-0 text-primary pointer-events-none" }))] })) }));
681
685
  }
@@ -802,9 +806,7 @@ function useGroupCollapsed(label, defaultCollapsed) {
802
806
  // that happens to collide with a prototype key (e.g. ``toString``,
803
807
  // ``__proto__``, ``constructor``) is not treated as a persisted
804
808
  // entry. ``map`` is parsed JSON so its prototype is ``Object``'s.
805
- return Object.prototype.hasOwnProperty.call(map, label)
806
- ? map[label]
807
- : Boolean(defaultCollapsed);
809
+ return Object.prototype.hasOwnProperty.call(map, label) ? map[label] : Boolean(defaultCollapsed);
808
810
  }
809
811
  function toggleGroupCollapsed(label, current) {
810
812
  writeCollapseMap({ ...readCollapseMap(), [label]: !current });
@@ -0,0 +1,33 @@
1
+ import type { ReactNode } from "react";
2
+ /**
3
+ * ONE row for everything a turn did before it answered.
4
+ *
5
+ * A turn that searched a corpus eleven times used to render eleven summary
6
+ * rows — one per adjacent run of tool calls — plus a reasoning row of its
7
+ * own, and the answer somewhere underneath. Each row was individually
8
+ * reasonable; stacked, they were a ladder the reader had to climb past to
9
+ * reach the point.
10
+ *
11
+ * So the machinery folds: "Worked · 20 tools · reasoning". The model's PROSE
12
+ * never goes inside — it is what the reader came for and it stays in the
13
+ * open, which also means each stretch of it ends one of these rows and
14
+ * starts the next. A turn that narrates its steps gets one row per step
15
+ * rather than one per tool run; a turn that just works and answers gets
16
+ * exactly one.
17
+ *
18
+ * Order is CHRONOLOGICAL, inside the fold and outside it. Reasoning explains
19
+ * why the next call happened ("I need the termination clause from both
20
+ * agreements" → three searches); moving it away from the calls it explains
21
+ * turns a narrative into a pile. Outside, a multi-step turn reads as work,
22
+ * what it learned, work, what it learned, answer — the model's prose ends
23
+ * one of these rows and starts the next.
24
+ *
25
+ * Collapsed by default, for the reason the per-run fold this replaces had
26
+ * already found: in a real transcript the machinery dominates the answer it
27
+ * was meant to support, and the reader scrolls past it to reach the point.
28
+ */
29
+ export declare function TurnActivity({ indices, isRunning, children, }: {
30
+ indices: readonly number[];
31
+ isRunning: boolean;
32
+ children: ReactNode;
33
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,110 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { useAuiState } from "@assistant-ui/react";
4
+ import { Loader2, CheckCircle2, XCircle, AlertCircle } from "lucide-react";
5
+ import { MessageDisclosure } from "./message-disclosure.js";
6
+ import { _detectErrorResult } from "./tool-fallback.js";
7
+ /**
8
+ * ONE row for everything a turn did before it answered.
9
+ *
10
+ * A turn that searched a corpus eleven times used to render eleven summary
11
+ * rows — one per adjacent run of tool calls — plus a reasoning row of its
12
+ * own, and the answer somewhere underneath. Each row was individually
13
+ * reasonable; stacked, they were a ladder the reader had to climb past to
14
+ * reach the point.
15
+ *
16
+ * So the machinery folds: "Worked · 20 tools · reasoning". The model's PROSE
17
+ * never goes inside — it is what the reader came for and it stays in the
18
+ * open, which also means each stretch of it ends one of these rows and
19
+ * starts the next. A turn that narrates its steps gets one row per step
20
+ * rather than one per tool run; a turn that just works and answers gets
21
+ * exactly one.
22
+ *
23
+ * Order is CHRONOLOGICAL, inside the fold and outside it. Reasoning explains
24
+ * why the next call happened ("I need the termination clause from both
25
+ * agreements" → three searches); moving it away from the calls it explains
26
+ * turns a narrative into a pile. Outside, a multi-step turn reads as work,
27
+ * what it learned, work, what it learned, answer — the model's prose ends
28
+ * one of these rows and starts the next.
29
+ *
30
+ * Collapsed by default, for the reason the per-run fold this replaces had
31
+ * already found: in a real transcript the machinery dominates the answer it
32
+ * was meant to support, and the reader scrolls past it to reach the point.
33
+ */
34
+ export function TurnActivity({ indices, isRunning, children, }) {
35
+ const { tools, failed, hasReasoning } = useTurnOutcome(indices);
36
+ const [isOpen, setIsOpen] = useState(false);
37
+ const summary = summarise({ tools, failed, hasReasoning, isRunning });
38
+ // Red only when EVERY call failed AND the turn has SETTLED. One failure
39
+ // among twenty is a turn that recovered, and painting it red would report a
40
+ // broken answer where there is a working one — but so would painting it red
41
+ // mid-run, when the only calls so far happen to have failed and the model is
42
+ // still reasoning its way to one that works. A partial failure still says
43
+ // so, in the count, without the alarm.
44
+ const allFailed = !isRunning && tools > 0 && failed === tools;
45
+ return (_jsx(MessageDisclosure, { open: isOpen, onOpenChange: setIsOpen, tone: allFailed ? "destructive" : "muted", icon: isRunning ? (_jsx(Loader2, { className: "size-3.5 animate-spin text-primary" })) : allFailed ? (_jsx(XCircle, { className: "size-3.5 text-destructive" })) : failed > 0 ? (_jsx(AlertCircle, { className: "size-3.5 text-muted-foreground" })) : (_jsx(CheckCircle2, { className: "size-3.5 text-primary" })), label: summary,
46
+ // The summary goes in the accessible name, not just the visible one.
47
+ // An `aria-label` REPLACES the name a button would otherwise derive
48
+ // from its contents, so a bare "Show what this turn did" would have
49
+ // announced the row while hiding the very thing it summarises — the
50
+ // call count, whether anything failed, whether there is thinking to
51
+ // read. A screen-reader user got strictly less than a sighted one.
52
+ ariaLabel: `${summary} — ${isOpen ? "hide" : "show"} details`, "data-turn-activity": true, children: _jsx("div", { className: "flex flex-col gap-2", children: children }) }));
53
+ }
54
+ /**
55
+ * The row's one line of text.
56
+ *
57
+ * Says what happened, in the order a reader asks it: is it still going, how
58
+ * much work was there, did any of it fail, and was there thinking to read.
59
+ * "Worked" alone when a turn only thought — claiming tools it never called
60
+ * would be a lie in the summary line, which is the one part nobody expands
61
+ * to check.
62
+ *
63
+ * This row describes its CONTENTS; it does not narrate the run. The phase a
64
+ * run is in belongs to the status indicator above the composer, which knows
65
+ * things this row cannot — which tool is in flight, whether the stream has
66
+ * stalled. A turn with reasoning and no calls yet therefore says "Reasoning"
67
+ * rather than "Thinking…", which is what the indicator is already saying two
68
+ * lines below; the same word twice on one screen reads as a stutter, not as
69
+ * two facts.
70
+ *
71
+ * No ellipses either: the spinner in the row's own icon slot carries "still
72
+ * going", and it does it without competing for the label.
73
+ */
74
+ function summarise({ tools, failed, hasReasoning, isRunning, }) {
75
+ const toolText = tools === 1 ? "1 tool" : `${tools} tools`;
76
+ // 0 tools while running means the group holds nothing but reasoning —
77
+ // `useTurnOutcome` counts every non-reasoning part as a call — so naming it
78
+ // is accurate, not a guess.
79
+ if (isRunning)
80
+ return tools > 0 ? `Working · ${toolText}` : "Reasoning";
81
+ const parts = ["Worked"];
82
+ if (tools > 0)
83
+ parts.push(failed > 0 ? `${toolText} · ${failed} failed` : toolText);
84
+ if (hasReasoning)
85
+ parts.push("reasoning");
86
+ return parts.join(" · ");
87
+ }
88
+ /** What the turn actually contains, read from the message's own parts. */
89
+ function useTurnOutcome(indices) {
90
+ const parts = useAuiState((s) => s.message.parts);
91
+ let tools = 0;
92
+ let failed = 0;
93
+ let hasReasoning = false;
94
+ for (const index of indices) {
95
+ const part = parts?.[index];
96
+ if (!part)
97
+ continue;
98
+ if (part.type === "reasoning") {
99
+ hasReasoning = true;
100
+ continue;
101
+ }
102
+ // Anything else under this node is a call the turn made. Counted by
103
+ // exclusion rather than by an allow-list of type names, so a new tool
104
+ // part type shows up in the count instead of vanishing from it.
105
+ tools++;
106
+ if (part.status?.type === "incomplete" || _detectErrorResult(part.result))
107
+ failed++;
108
+ }
109
+ return { tools, failed, hasReasoning };
110
+ }
@@ -67,6 +67,22 @@ export interface NavItem {
67
67
  contextInstructions?: string;
68
68
  /** Sub-navigation items (rendered indented under parent) */
69
69
  children?: NavItem[];
70
+ /**
71
+ * The row's children have not been fetched yet, so `children` being empty
72
+ * says nothing about whether it has any.
73
+ *
74
+ * A tree row normally shows one of two markers: a chevron if it branches, a
75
+ * dot if it is a leaf. That is two states for a question with THREE answers
76
+ * — has children, has none, and nobody has looked. A caller that loads
77
+ * subtrees lazily (only the active one, say) leaves every other row with an
78
+ * empty `children`, and the row then claims to be a leaf on the strength of
79
+ * a fetch that never happened.
80
+ *
81
+ * Set this while the answer is unknown and the row shows neither marker: no
82
+ * chevron to click into nothing, no dot asserting an emptiness no one
83
+ * checked. Clear it once the children are known, empty or not.
84
+ */
85
+ childrenUnloaded?: boolean;
70
86
  /** Inline action buttons rendered next to the nav item (e.g., + document, + folder) */
71
87
  actions?: NavItemAction[];
72
88
  /** Optional drag-and-drop behavior (provided by feature modules) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.14.2",
3
+ "version": "0.16.0",
4
4
  "license": "MIT",
5
5
  "description": "React agent UI core for Foundry UI — chat, composer, AG-UI adapter for assistant-ui, tool cards, panels, sidebar, theme runtime, and UI stores.",
6
6
  "keywords": [
@@ -70,8 +70,8 @@
70
70
  "tailwind-merge": "^3.5.0",
71
71
  "react-markdown": "^10.0.0",
72
72
  "remark-gfm": "^4.0.0",
73
- "@iloveagents/foundry-agent": "^0.14.2",
74
- "@iloveagents/foundry-web-primitives": "^0.14.2"
73
+ "@iloveagents/foundry-agent": "^0.16.0",
74
+ "@iloveagents/foundry-web-primitives": "^0.16.0"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@ag-ui/client": "^0.0.52",
@@ -1,29 +0,0 @@
1
- import type { ReactNode } from "react";
2
- /**
3
- * One row standing in for a run of adjacent tool calls, with the cards under
4
- * it behind a disclosure.
5
- *
6
- * A turn that calls eight tools used to render eight bordered cards with no
7
- * way to fold them away. The run is now one summary line — "8 tools · done" —
8
- * over a group the reader can collapse when the machinery is in the way.
9
- *
10
- * COLLAPSED by default. This was open at first, on the reasoning that a
11
- * developer watching an agent work wants the detail — but in a real
12
- * transcript the cards dominate the answer they were supposed to support,
13
- * and the reader scrolls past machinery to reach the point. The summary line
14
- * already carries what matters at a glance (how many calls, and whether they
15
- * worked); opening it is the deliberate act.
16
- *
17
- * The row shape is shared with the reasoning part via `MessageDisclosure`, so
18
- * the two foldaways in a message read as one family instead of two widgets.
19
- *
20
- * RED only when the whole run failed. One failure among eight successful
21
- * calls is not a red turn — the agent recovered, and painting the summary red
22
- * would report a broken run where there was a working one. A partial failure
23
- * still says so, in the count, without the alarm.
24
- */
25
- export declare function ToolRunGroup({ indices, isRunning, children, }: {
26
- indices: readonly number[];
27
- isRunning: boolean;
28
- children: ReactNode;
29
- }): import("react/jsx-runtime").JSX.Element;
@@ -1,56 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useState } from "react";
3
- import { useAuiState } from "@assistant-ui/react";
4
- import { Loader2, CheckCircle2, XCircle, AlertCircle } from "lucide-react";
5
- import { MessageDisclosure } from "./message-disclosure.js";
6
- import { _detectErrorResult } from "./tool-fallback.js";
7
- /**
8
- * One row standing in for a run of adjacent tool calls, with the cards under
9
- * it behind a disclosure.
10
- *
11
- * A turn that calls eight tools used to render eight bordered cards with no
12
- * way to fold them away. The run is now one summary line — "8 tools · done" —
13
- * over a group the reader can collapse when the machinery is in the way.
14
- *
15
- * COLLAPSED by default. This was open at first, on the reasoning that a
16
- * developer watching an agent work wants the detail — but in a real
17
- * transcript the cards dominate the answer they were supposed to support,
18
- * and the reader scrolls past machinery to reach the point. The summary line
19
- * already carries what matters at a glance (how many calls, and whether they
20
- * worked); opening it is the deliberate act.
21
- *
22
- * The row shape is shared with the reasoning part via `MessageDisclosure`, so
23
- * the two foldaways in a message read as one family instead of two widgets.
24
- *
25
- * RED only when the whole run failed. One failure among eight successful
26
- * calls is not a red turn — the agent recovered, and painting the summary red
27
- * would report a broken run where there was a working one. A partial failure
28
- * still says so, in the count, without the alarm.
29
- */
30
- export function ToolRunGroup({ indices, isRunning, children, }) {
31
- const { total, failed } = useToolRunOutcome(indices);
32
- // Every call in the run failed — the only state that earns the alarm colour.
33
- const allFailed = total > 0 && failed === total;
34
- const [isOpen, setIsOpen] = useState(false);
35
- const label = isRunning
36
- ? `Using ${total === 1 ? "1 tool" : `${total} tools`}…`
37
- : failed > 0
38
- ? `${total === 1 ? "1 tool" : `${total} tools`} · ${failed} failed`
39
- : `${total === 1 ? "1 tool" : `${total} tools`} · done`;
40
- return (_jsx(MessageDisclosure, { open: isOpen, onOpenChange: setIsOpen, tone: allFailed ? "destructive" : "muted", icon: isRunning ? (_jsx(Loader2, { className: "size-3.5 animate-spin text-primary" })) : allFailed ? (_jsx(XCircle, { className: "size-3.5 text-destructive" })) : failed > 0 ? (
41
- // Partial: the run produced a result, some of it the hard way.
42
- _jsx(AlertCircle, { className: "size-3.5 text-muted-foreground" })) : (_jsx(CheckCircle2, { className: "size-3.5 text-primary" })), label: label, "data-tool-run-group": true, children: _jsx("div", { className: "flex flex-col gap-2", children: children }) }));
43
- }
44
- function useToolRunOutcome(indices) {
45
- const parts = useAuiState((s) => s.message.parts);
46
- let failed = 0;
47
- for (const index of indices) {
48
- const part = parts?.[index];
49
- if (!part)
50
- continue;
51
- const status = part.status;
52
- if (status?.type === "incomplete" || _detectErrorResult(part.result))
53
- failed++;
54
- }
55
- return { total: indices.length, failed };
56
- }