@iloveagents/foundry-web-ui 0.29.0 → 0.30.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.
Files changed (39) hide show
  1. package/dist/components/ag-ui-runtime-provider.d.ts +8 -3
  2. package/dist/components/ag-ui-runtime-provider.js +76 -33
  3. package/dist/components/assistant-chat.d.ts +1 -15
  4. package/dist/components/assistant-chat.js +3 -4
  5. package/dist/components/chat-bubble.js +3 -4
  6. package/dist/components/chat-context-items.d.ts +6 -0
  7. package/dist/components/chat-context-items.js +25 -0
  8. package/dist/components/chat-context.js +10 -2
  9. package/dist/components/chat-empty-state.js +1 -1
  10. package/dist/components/chat-header.js +2 -1
  11. package/dist/components/composer-add-menu.d.ts +0 -19
  12. package/dist/components/composer-add-menu.js +2 -10
  13. package/dist/components/context-badges.d.ts +0 -14
  14. package/dist/components/context-badges.js +2 -29
  15. package/dist/components/context-bar.d.ts +1 -21
  16. package/dist/components/context-bar.js +3 -74
  17. package/dist/components/focus-chat-pane.js +1 -1
  18. package/dist/components/sidebar.js +39 -10
  19. package/dist/index.d.ts +4 -3
  20. package/dist/index.js +4 -3
  21. package/dist/lib/ag-ui-adapter.d.ts +3 -0
  22. package/dist/lib/ag-ui-adapter.js +75 -11
  23. package/dist/lib/chat-runs-store.d.ts +40 -0
  24. package/dist/lib/chat-runs-store.js +173 -0
  25. package/dist/lib/merge-chat-state.d.ts +3 -0
  26. package/dist/lib/merge-chat-state.js +21 -0
  27. package/dist/lib/nav-config.js +47 -20
  28. package/dist/lib/use-new-conversation.js +4 -8
  29. package/dist/workbench/chat-header.js +4 -3
  30. package/dist/workbench/conversation-history.d.ts +1 -1
  31. package/dist/workbench/conversation-history.js +75 -19
  32. package/dist/workbench/running-chats-menu.d.ts +3 -0
  33. package/dist/workbench/running-chats-menu.js +20 -0
  34. package/dist/workbench/use-workbench-state.d.ts +1 -2
  35. package/dist/workbench/use-workbench-state.js +15 -18
  36. package/dist/workbench/welcome-intro.js +1 -1
  37. package/dist/workbench/workbench-styles.js +63 -11
  38. package/dist/workbench/workbench.js +1 -1
  39. package/package.json +3 -3
@@ -0,0 +1,173 @@
1
+ import { create } from "zustand";
2
+ import { agentStateStore, citationStore, streamingStatusStore, } from "@iloveagents/foundry-agent";
3
+ export const isChatRunActive = (run) => run.status === "running" || run.status === "waiting";
4
+ /** Browser-session activity. Message persistence remains owned by the history adapter. */
5
+ export const useChatRuns = create(() => ({ foregroundId: null, runs: {}, deletedThreads: {} }));
6
+ const agentStates = new Map();
7
+ const appProjections = new Map();
8
+ export function getChatStateSnapshot(threadId) {
9
+ return { state: agentStates.get(threadId)?.state, projection: appProjections.get(threadId) };
10
+ }
11
+ export function retainChatProjection(threadId, projection) {
12
+ appProjections.set(threadId, structuredClone(projection));
13
+ }
14
+ export function publishChatAgentState(threadId, state, patch) {
15
+ if (useChatRuns.getState().deletedThreads[threadId])
16
+ return;
17
+ agentStates.set(threadId, { state, patch });
18
+ if (isForegroundChat(threadId))
19
+ agentStateStore.getState().setAgentState(state, patch);
20
+ }
21
+ const signals = new Map();
22
+ export function isForegroundChat(threadId) {
23
+ const foreground = useChatRuns.getState().foregroundId;
24
+ return foreground === null || foreground === threadId;
25
+ }
26
+ export function selectChatRun(threadId) {
27
+ useChatRuns.setState({ foregroundId: threadId });
28
+ citationStore.getState().selectThread(threadId);
29
+ const stored = agentStates.get(threadId);
30
+ if (stored)
31
+ agentStateStore.getState().setAgentState(stored.state, stored.patch);
32
+ else
33
+ agentStateStore.getState().reset();
34
+ streamingStatusStore.setState(signals.get(threadId) ?? {
35
+ streamingStatus: { status: "idle" },
36
+ runStartedAt: null,
37
+ lastSignalAt: null,
38
+ });
39
+ }
40
+ /** Call after a successful conversation deletion so session activity cannot restore it. */
41
+ export function removeChatRun(threadId) {
42
+ const run = useChatRuns.getState().runs[threadId];
43
+ if (run && isChatRunActive(run))
44
+ run.stop();
45
+ useChatRuns.setState((state) => {
46
+ const runs = { ...state.runs };
47
+ delete runs[threadId];
48
+ return { runs, deletedThreads: { ...state.deletedThreads, [threadId]: true } };
49
+ });
50
+ citationStore.getState().clear(threadId);
51
+ signals.delete(threadId);
52
+ agentStates.delete(threadId);
53
+ appProjections.delete(threadId);
54
+ if (useChatRuns.getState().foregroundId === threadId) {
55
+ agentStateStore.getState().reset();
56
+ streamingStatusStore.setState({
57
+ streamingStatus: { status: "idle" },
58
+ runStartedAt: null,
59
+ lastSignalAt: null,
60
+ });
61
+ }
62
+ }
63
+ export function beginChatRun(run) {
64
+ const startedAt = Date.now();
65
+ useChatRuns.setState((s) => ({
66
+ runs: {
67
+ ...s.runs,
68
+ [run.threadId]: { ...run, status: "running", startedAt },
69
+ },
70
+ }));
71
+ signals.set(run.threadId, {
72
+ streamingStatus: { status: "thinking" },
73
+ runStartedAt: startedAt,
74
+ lastSignalAt: startedAt,
75
+ });
76
+ touchChatRun(run.threadId);
77
+ }
78
+ export function touchChatRun(threadId, status) {
79
+ const previous = signals.get(threadId);
80
+ if (!previous)
81
+ return;
82
+ const next = {
83
+ ...previous,
84
+ lastSignalAt: Date.now(),
85
+ ...(status ? { streamingStatus: status } : {}),
86
+ };
87
+ signals.set(threadId, next);
88
+ if (isForegroundChat(threadId))
89
+ streamingStatusStore.setState(next);
90
+ }
91
+ export function updateChatRun(threadId, status, error) {
92
+ useChatRuns.setState((s) => {
93
+ const run = s.runs[threadId];
94
+ if (!run)
95
+ return s;
96
+ return {
97
+ runs: {
98
+ ...s.runs,
99
+ [threadId]: {
100
+ ...run,
101
+ status,
102
+ ...(error ? { error } : {}),
103
+ ...(["completed", "failed", "cancelled"].includes(status)
104
+ ? { finishedAt: Date.now() }
105
+ : {}),
106
+ },
107
+ },
108
+ };
109
+ });
110
+ if (status !== "running" && status !== "waiting") {
111
+ signals.delete(threadId);
112
+ if (isForegroundChat(threadId))
113
+ streamingStatusStore.setState({
114
+ streamingStatus: { status: "idle" },
115
+ runStartedAt: null,
116
+ lastSignalAt: null,
117
+ });
118
+ }
119
+ }
120
+ /** Browser tools need the conversation in front, so they cannot act on another chat's page. */
121
+ export async function waitForForegroundChat(threadId, signal) {
122
+ signal.throwIfAborted();
123
+ if (isForegroundChat(threadId))
124
+ return;
125
+ updateChatRun(threadId, "waiting");
126
+ await new Promise((resolve, reject) => {
127
+ const cleanup = () => {
128
+ unsubscribe();
129
+ signal.removeEventListener("abort", aborted);
130
+ };
131
+ const aborted = () => {
132
+ cleanup();
133
+ reject(signal.reason ?? new DOMException("Stopped", "AbortError"));
134
+ };
135
+ const unsubscribe = useChatRuns.subscribe(() => {
136
+ if (isForegroundChat(threadId)) {
137
+ cleanup();
138
+ resolve();
139
+ }
140
+ });
141
+ signal.addEventListener("abort", aborted, { once: true });
142
+ if (signal.aborted)
143
+ aborted();
144
+ else if (isForegroundChat(threadId)) {
145
+ cleanup();
146
+ resolve();
147
+ }
148
+ });
149
+ updateChatRun(threadId, "running");
150
+ }
151
+ export function warnBeforeChatUnload(event) {
152
+ if (!Object.values(useChatRuns.getState().runs).some(isChatRunActive))
153
+ return;
154
+ event.preventDefault();
155
+ event.returnValue = "";
156
+ }
157
+ /** Dispose browser-owned runs when the authenticated runtime is torn down. */
158
+ export function disposeChatRuns() {
159
+ for (const run of Object.values(useChatRuns.getState().runs)) {
160
+ if (isChatRunActive(run))
161
+ run.stop();
162
+ }
163
+ signals.clear();
164
+ agentStates.clear();
165
+ appProjections.clear();
166
+ citationStore.setState({ results: [], byThread: {}, threadId: null });
167
+ useChatRuns.setState({ foregroundId: null, runs: {}, deletedThreads: {} });
168
+ streamingStatusStore.setState({
169
+ streamingStatus: { status: "idle" },
170
+ runStartedAt: null,
171
+ lastSignalAt: null,
172
+ });
173
+ }
@@ -0,0 +1,3 @@
1
+ /** Apply changes in the visible UI to retained server state, preserving server
2
+ * updates when the corresponding UI value has not changed since the last turn. */
3
+ export declare function mergeChatState(server: Record<string, unknown>, previous: Record<string, unknown> | undefined, current: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,21 @@
1
+ /** Apply changes in the visible UI to retained server state, preserving server
2
+ * updates when the corresponding UI value has not changed since the last turn. */
3
+ export function mergeChatState(server, previous, current) {
4
+ const result = { ...server };
5
+ for (const key of new Set([...Object.keys(previous ?? {}), ...Object.keys(current)])) {
6
+ const before = previous?.[key];
7
+ const after = current[key];
8
+ if (previous && JSON.stringify(before) === JSON.stringify(after))
9
+ continue;
10
+ if (!(key in current)) {
11
+ delete result[key];
12
+ continue;
13
+ }
14
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
15
+ result[key] =
16
+ object(before) && object(after) && object(server[key])
17
+ ? mergeChatState(server[key], before, after)
18
+ : after;
19
+ }
20
+ return result;
21
+ }
@@ -92,23 +92,40 @@ export const DEFAULT_NAV_CONFIG = [
92
92
  // },
93
93
  ];
94
94
  function findExactNavItem(items, path) {
95
+ const target = new URL(path, "http://navigation.local");
96
+ let best = null;
97
+ let specificity = -1;
95
98
  for (const item of items) {
96
- if (item.to === path) {
97
- return item;
99
+ const route = new URL(item.to, target.origin);
100
+ if (route.pathname.replace(/\/+$/, "") === target.pathname.replace(/\/+$/, "") &&
101
+ [...route.searchParams].every(([key, value]) => target.searchParams.getAll(key).includes(value)) &&
102
+ route.searchParams.size > specificity) {
103
+ best = item;
104
+ specificity = route.searchParams.size;
98
105
  }
99
- if (item.children) {
100
- const nested = findExactNavItem(item.children, path);
101
- if (nested)
102
- return nested;
106
+ const nested = item.children && findExactNavItem(item.children, path);
107
+ if (nested) {
108
+ const nestedSpecificity = new URL(nested.to, target.origin).searchParams.size;
109
+ if (nestedSpecificity > specificity) {
110
+ best = nested;
111
+ specificity = nestedSpecificity;
112
+ }
103
113
  }
104
114
  }
105
- return null;
115
+ return best;
106
116
  }
107
117
  function findBestPrefixNavItem(items, path, currentBest = null) {
108
118
  let best = currentBest;
119
+ const target = new URL(path, "http://navigation.local");
109
120
  for (const item of items) {
110
- if (path.startsWith(item.to + "/") && item.to.length > (best?.length ?? 0)) {
111
- best = { item, length: item.to.length };
121
+ const route = new URL(item.to, target.origin);
122
+ const pathname = route.pathname.replace(/\/+$/, "");
123
+ const scope = route.searchParams.size;
124
+ if (target.pathname.startsWith(pathname + "/") &&
125
+ [...route.searchParams].every(([key, value]) => target.searchParams.getAll(key).includes(value)) &&
126
+ (pathname.length > (best?.length ?? 0) ||
127
+ (pathname.length === best?.length && scope > best.scope))) {
128
+ best = { item, length: pathname.length, scope };
112
129
  }
113
130
  if (item.children) {
114
131
  best = findBestPrefixNavItem(item.children, path, best);
@@ -117,22 +134,29 @@ function findBestPrefixNavItem(items, path, currentBest = null) {
117
134
  return best;
118
135
  }
119
136
  export function findNavItem(config, path) {
120
- // Pass 1: exact match on items
137
+ // Prefer matching scope parameters; unrelated table filters do not change the page.
138
+ let exact = null;
139
+ let specificity = -1;
121
140
  for (const group of config) {
122
- const base = {
123
- group: group.label,
124
- groupMeta: group.meta,
125
- groupDescription: group.description,
126
- groupInstructions: group.contextInstructions,
127
- };
128
141
  const match = findExactNavItem(group.items, path);
129
- if (match) {
130
- return { ...base, item: match };
142
+ const score = match ? new URL(match.to, "http://navigation.local").searchParams.size : -1;
143
+ if (match && score > specificity) {
144
+ specificity = score;
145
+ exact = {
146
+ group: group.label,
147
+ groupMeta: group.meta,
148
+ groupDescription: group.description,
149
+ groupInstructions: group.contextInstructions,
150
+ item: match,
151
+ };
131
152
  }
132
153
  }
154
+ if (exact)
155
+ return exact;
156
+ const pathname = new URL(path, "http://navigation.local").pathname;
133
157
  // Pass 2: group-level `to` match (e.g. /spaces matches Workspaces group)
134
158
  for (const group of config) {
135
- if (group.to && group.to === path) {
159
+ if (group.to && group.to === pathname) {
136
160
  return {
137
161
  group: group.label,
138
162
  groupMeta: group.meta,
@@ -145,6 +169,7 @@ export function findNavItem(config, path) {
145
169
  // Pass 3: longest-prefix match (for dynamic/parameterized routes)
146
170
  let best = null;
147
171
  let bestLen = 0;
172
+ let bestScope = -1;
148
173
  for (const group of config) {
149
174
  const base = {
150
175
  group: group.label,
@@ -153,9 +178,11 @@ export function findNavItem(config, path) {
153
178
  groupInstructions: group.contextInstructions,
154
179
  };
155
180
  const match = findBestPrefixNavItem(group.items, path);
156
- if (match && match.length > bestLen) {
181
+ if (match &&
182
+ (match.length > bestLen || (match.length === bestLen && match.scope > bestScope))) {
157
183
  best = { ...base, item: match.item };
158
184
  bestLen = match.length;
185
+ bestScope = match.scope;
159
186
  }
160
187
  }
161
188
  return best;
@@ -1,7 +1,5 @@
1
1
  import { useCallback } from "react";
2
2
  import { useNavigate } from "react-router";
3
- import { useAui } from "@assistant-ui/react";
4
- import { useStore } from "zustand";
5
3
  import { citationStore } from "@iloveagents/foundry-agent";
6
4
  import { useAGUIAdapter } from "../components/ag-ui-runtime-provider.js";
7
5
  import { useToolPanelStore } from "./tool-panel-store.js";
@@ -13,13 +11,11 @@ import { useChatLifecycleStore } from "./chat-lifecycle-store.js";
13
11
  * Used by ChatHeader and Sidebar to keep behavior in sync.
14
12
  */
15
13
  export function useNewConversation(options) {
16
- const aui = useAui();
17
14
  const { resetThread } = useAGUIAdapter();
18
15
  const navigate = useNavigate();
19
16
  const closePanel = useToolPanelStore((s) => s.closePanel);
20
17
  const resetApp = useAppStore((s) => s.resetAll);
21
18
  const closeMobile = useSidebarStore((s) => s.closeMobile);
22
- const clearCitations = useStore(citationStore, (s) => s.clear);
23
19
  return useCallback(() => {
24
20
  const preserved = options?.preserveNavigation
25
21
  ? {
@@ -28,12 +24,15 @@ export function useNewConversation(options) {
28
24
  }
29
25
  : null;
30
26
  closePanel();
27
+ const sentContext = useAppStore.getState().sentContext;
31
28
  resetApp();
29
+ useAppStore.setState({ sentContext });
32
30
  if (preserved) {
33
31
  useAppStore.setState(preserved);
34
32
  }
35
33
  closeMobile();
36
- clearCitations();
34
+ // Clear only the foreground projection; retained transcripts keep their sources.
35
+ citationStore.setState({ threadId: null, results: [] });
37
36
  // Notify the host app that a new thread is starting. Spaces wires
38
37
  // this to clear its active-chat sticky id so the AGUIRuntimeProvider's
39
38
  // ``effectiveThreadId`` advances to a freshly-minted UUID instead of
@@ -44,7 +43,6 @@ export function useNewConversation(options) {
44
43
  // where there is no ``/`` navigation to clear sticky for us.
45
44
  useChatLifecycleStore.getState().startNewConversation();
46
45
  resetThread();
47
- aui.threads().switchToNewThread();
48
46
  if (options?.navigateToChat ?? true) {
49
47
  navigate("/");
50
48
  }
@@ -53,9 +51,7 @@ export function useNewConversation(options) {
53
51
  resetApp,
54
52
  options?.preserveNavigation,
55
53
  closeMobile,
56
- clearCitations,
57
54
  resetThread,
58
- aui,
59
55
  options?.navigateToChat,
60
56
  navigate,
61
57
  ]);
@@ -1,12 +1,13 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { TooltipIconButton } from "../index.js";
3
- import { SquarePen, PanelLeft, MessageSquare, Menu, History, LoaderCircle } from "lucide-react";
3
+ import { SquarePen, PanelLeft, MessageSquare, Menu, MessagesSquare, LoaderCircle, } from "lucide-react";
4
+ import { RunningChatsMenu } from "./running-chats-menu.js";
4
5
  export function WorkbenchChatHeader({ collapsed, running, chatWidth, buttonClass, toggleNavigation, setCollapsed, showHistory, startChat, returnToChat, openHistory, chatOnly, setContentCollapsed, }) {
5
- return (_jsxs(_Fragment, { children: [_jsxs("div", { "data-chat-header": true, className: `relative flex shrink-0 items-center ${collapsed ? "justify-center" : "gap-1 px-2"}`, children: [!collapsed && !chatWidth.compact && (_jsx(TooltipIconButton, { tooltip: "Open navigation", className: `${buttonClass} preview-mobile-navigation`, "aria-label": "Open navigation", onClick: toggleNavigation, children: _jsx(Menu, { size: 18 }) })), collapsed ? (_jsx(TooltipIconButton, { tooltip: running ? "AI is working — expand chat" : "Expand chat", className: buttonClass, "aria-label": running ? "AI is working — expand chat" : "Expand chat", onClick: () => setCollapsed(false), children: running ? (_jsx(LoaderCircle, { size: 18, className: "animate-spin motion-reduce:animate-none text-primary" })) : (_jsx(MessageSquare, { size: 18 })) })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "min-w-0 flex-1 px-2 py-2", children: _jsx("div", { className: "text-sm font-semibold", children: showHistory ? "Conversations" : "Chat" }) }), _jsx(TooltipIconButton, { tooltip: "New chat", className: buttonClass, "aria-label": "New chat", onClick: startChat, children: _jsx(SquarePen, { size: 18 }) }), _jsx(TooltipIconButton, { tooltip: "Chat history", className: buttonClass, "aria-label": "Chat history", "aria-expanded": showHistory, onClick: () => (showHistory ? returnToChat() : openHistory()), children: _jsx(History, { size: 18 }) }), _jsx(TooltipIconButton, { tooltip: "Collapse chat", style: chatOnly || chatWidth.compact ? { display: "none" } : undefined, className: buttonClass, "aria-label": "Collapse chat", onClick: () => {
6
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { "data-chat-header": true, className: `relative flex shrink-0 items-center ${collapsed ? "justify-center" : "gap-1 px-2"}`, children: [!collapsed && !chatWidth.compact && (_jsx(TooltipIconButton, { tooltip: "Open navigation", className: `${buttonClass} preview-mobile-navigation`, "aria-label": "Open navigation", onClick: toggleNavigation, children: _jsx(Menu, { size: 18 }) })), collapsed ? (_jsx(TooltipIconButton, { tooltip: running ? "AI is working — expand chat" : "Expand chat", className: buttonClass, "aria-label": running ? "AI is working — expand chat" : "Expand chat", onClick: () => setCollapsed(false), children: running ? (_jsx(LoaderCircle, { size: 18, className: "animate-spin motion-reduce:animate-none text-primary" })) : (_jsx(MessageSquare, { size: 18 })) })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "min-w-0 flex-1 px-2 py-2", children: _jsx("div", { className: "text-sm font-semibold", children: showHistory ? "Conversations" : "Chat" }) }), _jsx(RunningChatsMenu, { onOpen: returnToChat }), _jsx(TooltipIconButton, { tooltip: "New chat", className: buttonClass, "aria-label": "New chat", onClick: startChat, children: _jsx(SquarePen, { size: 18 }) }), _jsx(TooltipIconButton, { tooltip: "Chat history", className: buttonClass, "aria-label": "Chat history", "aria-expanded": showHistory, onClick: () => (showHistory ? returnToChat() : openHistory()), children: _jsx(MessagesSquare, { size: 18 }) }), _jsx(TooltipIconButton, { tooltip: "Collapse chat", style: chatOnly || chatWidth.compact ? { display: "none" } : undefined, className: buttonClass, "aria-label": "Collapse chat", onClick: () => {
6
7
  setContentCollapsed(false);
7
8
  setCollapsed(true);
8
9
  }, children: _jsx(PanelLeft, { size: 18 }) })] }))] }), collapsed && (_jsxs("div", { "data-chat-rail": true, className: "flex flex-col items-center gap-1", children: [_jsx(TooltipIconButton, { tooltip: "Open navigation", className: `${buttonClass} preview-mobile-navigation`, "aria-label": "Open navigation", onClick: toggleNavigation, children: _jsx(Menu, { size: 18 }) }), _jsx(TooltipIconButton, { tooltip: "Chat history", className: buttonClass, "aria-label": "Chat history", onClick: () => {
9
10
  setCollapsed(false);
10
11
  openHistory();
11
- }, children: _jsx(History, { size: 18 }) })] }))] }));
12
+ }, children: _jsx(MessagesSquare, { size: 18 }) })] }))] }));
12
13
  }
@@ -1,4 +1,4 @@
1
- import type { NavGroup } from "../index.js";
1
+ import type { NavGroup } from "../lib/nav-config.js";
2
2
  /** History is a view of existing navigation targets, never a second chat runtime. */
3
3
  export declare function ConversationHistory({ groups, workspaceId, onClose, showBack, welcome, compact, onViewAll, entityId, rootContext, }: {
4
4
  groups: NavGroup[];
@@ -1,12 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, } from "../index.js";
2
+ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, } from "../ui/dropdown-menu.js";
3
3
  import { Button, Input } from "@iloveagents/foundry-web-primitives";
4
4
  import { Fragment, useEffect, useState } from "react";
5
5
  import { Link } from "react-router";
6
- import { ArrowLeft, MessageSquare, MoreHorizontal, Pin, Loader2, ChevronDown, Check, History, } from "lucide-react";
6
+ import { ArrowLeft, MessageSquare, MoreHorizontal, Pin, Loader2, ChevronDown, Check, MessagesSquare, PauseCircle, Copy, } from "lucide-react";
7
+ import { useChatRuns, isChatRunActive } from "../lib/chat-runs-store.js";
7
8
  /** History is a view of existing navigation targets, never a second chat runtime. */
8
9
  export function ConversationHistory({ groups, workspaceId, onClose, showBack = true, welcome = false, compact = false, onViewAll, entityId, rootContext = false, }) {
9
10
  const [loadingTo, setLoadingTo] = useState(null);
11
+ const [copiedId, setCopiedId] = useState(null);
10
12
  const [loadError, setLoadError] = useState(null);
11
13
  const [all, setAll] = useState(!workspaceId);
12
14
  useEffect(() => setAll(!workspaceId), [workspaceId]);
@@ -16,28 +18,68 @@ export function ConversationHistory({ groups, workspaceId, onClose, showBack = t
16
18
  groups
17
19
  .filter((g) => g.meta?.type === "recent-chats" || g.meta?.type === "pinned-chats")
18
20
  .flatMap((g) => g.items);
21
+ const runs = useChatRuns((s) => s.runs);
22
+ const runFor = (item) => Object.values(runs).find((run) => (run.conversationUrl ?? `/chat/${encodeURIComponent(run.threadId)}`) === item.to);
19
23
  const workspaceItems = all || !workspaceId
20
24
  ? allItems
21
25
  : (scoped?.meta?.workspaceConversations ?? scoped?.items ?? []);
22
- const scopedItems = compact && workspaceId && !rootContext
23
- ? workspaceItems.filter((item) => Boolean(entityId) && item.meta?.previewEntityId === entityId)
24
- : workspaceItems;
25
- const items = [...new Map(scopedItems.map((item) => [item.to, item])).values()]
26
- .filter((item) => item.meta?.type !== "show-all-chats")
26
+ // In-memory runs appear immediately, before the server refreshes its nav rows.
27
+ // A persisted title/actions win, while the live run owns its current location.
28
+ const merged = new Map(workspaceItems.map((item) => [item.to, item]));
29
+ for (const run of Object.values(runs)) {
30
+ const to = run.conversationUrl ?? `/chat/${encodeURIComponent(run.threadId)}`;
31
+ const saved = allItems.find((item) => item.to === to);
32
+ if (saved && !isChatRunActive(run))
33
+ continue;
34
+ if (!all && workspaceId && !merged.has(to) && run.contextMeta?.spacesRootId !== workspaceId) {
35
+ merged.delete(to);
36
+ continue;
37
+ }
38
+ merged.set(to, {
39
+ ...saved,
40
+ to,
41
+ label: saved?.label ?? run.title,
42
+ icon: MessageSquare,
43
+ meta: {
44
+ ...saved?.meta,
45
+ previewEntityId: run.contextMeta?.spacesEntityId ?? saved?.meta?.previewEntityId,
46
+ previewEntityName: run.pageLabel ?? saved?.meta?.previewEntityName,
47
+ },
48
+ });
49
+ }
50
+ const items = [...merged.values()]
51
+ .filter((item) => !(compact && workspaceId && !rootContext) ||
52
+ (Boolean(entityId) && item.meta?.previewEntityId === entityId))
53
+ .filter((item) => !["show-all-chats", "no-chats-placeholder"].includes(String(item.meta?.type)))
27
54
  .filter((item) => item.label.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
28
- .sort((a, b) => Number(Boolean(b.meta?.pinned)) - Number(Boolean(a.meta?.pinned)));
55
+ .sort((a, b) => {
56
+ const active = (item) => {
57
+ const run = runFor(item);
58
+ return run && isChatRunActive(run) ? 1 : 0;
59
+ };
60
+ return (active(b) - active(a) || Number(Boolean(b.meta?.pinned)) - Number(Boolean(a.meta?.pinned)));
61
+ });
29
62
  const visibleItems = compact ? items.slice(0, 3) : items;
30
- const pinnedItems = visibleItems.filter((item) => item.meta?.pinned);
31
- const sections = pinnedItems.length
32
- ? [
63
+ const activeItems = visibleItems.filter((item) => {
64
+ const run = runFor(item);
65
+ return run && isChatRunActive(run);
66
+ });
67
+ const idleItems = visibleItems.filter((item) => !activeItems.includes(item));
68
+ const pinnedItems = idleItems.filter((item) => item.meta?.pinned);
69
+ const sections = compact
70
+ ? [{ label: "", items: visibleItems }]
71
+ : [
72
+ { label: "Running", items: activeItems },
33
73
  { label: "Pinned", items: pinnedItems },
34
- { label: "Recent chats", items: visibleItems.filter((item) => !item.meta?.pinned) },
35
- ]
36
- : [{ label: "", items: visibleItems }];
74
+ {
75
+ label: activeItems.length || pinnedItems.length ? "Recent chats" : "",
76
+ items: idleItems.filter((item) => !item.meta?.pinned),
77
+ },
78
+ ];
37
79
  return (_jsxs("section", { "data-conversation-history": true, "aria-label": compact ? "Recent conversations" : "Chat history", className: compact ? "w-full" : "mx-auto flex min-h-0 w-full max-w-3xl flex-1 flex-col px-4 pb-4", children: [welcome && (_jsxs("div", { className: "pb-6 pt-8", children: [_jsx("h1", { className: "text-xl font-semibold", children: "How can I help?" }), _jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: "Start something new or continue a recent chat." })] })), showBack && (_jsxs("button", { onClick: onClose, className: "mb-4 flex items-center gap-2 py-2 text-xs text-muted-foreground hover:text-foreground", children: [_jsx(ArrowLeft, { size: 14 }), " Back to chat"] })), (!compact || items.length > 0) && (_jsx("div", { className: "mb-2 mt-2 flex items-center justify-between", children: _jsx("h2", { className: compact ? "text-xs font-medium text-muted-foreground" : "text-base font-semibold", children: compact ? "Recent conversations" : "Conversation history" }) })), Boolean(workspaceId) && !compact && (_jsx("div", { className: "mb-3", children: _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", "aria-label": "History filter", className: "w-full justify-between font-normal shadow-none", children: [_jsx("span", { className: "truncate", children: all ? "All conversations" : "Last used in this workspace" }), _jsx(ChevronDown, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true" })] }) }), _jsx(DropdownMenuContent, { align: "start", className: "w-[var(--radix-dropdown-menu-trigger-width)]", children: [
38
80
  { value: false, label: "Last used in this workspace" },
39
81
  { value: true, label: "All conversations" },
40
- ].map((option) => (_jsxs(DropdownMenuItem, { role: "menuitemradio", "aria-checked": all === option.value, onSelect: () => setAll(option.value), children: [_jsx("span", { className: "flex-1", children: option.label }), all === option.value && _jsx(Check, { className: "size-4", "aria-hidden": "true" })] }, option.label))) })] }) })), !compact && (_jsx(Input, { "aria-label": "Search chats", placeholder: "Search chats", value: search, onChange: (event) => setSearch(event.target.value), className: "mb-3" })), loadError && (_jsx("p", { role: "alert", className: "mb-2 text-xs text-destructive", children: loadError })), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto", "aria-busy": Boolean(loadingTo), children: [sections
82
+ ].map((option) => (_jsxs(DropdownMenuItem, { role: "menuitemradio", "aria-checked": all === option.value, onSelect: () => setAll(option.value), children: [_jsx("span", { className: "flex-1", children: option.label }), all === option.value && _jsx(Check, { className: "size-4", "aria-hidden": "true" })] }, option.label))) })] }) })), !compact && (_jsx(Input, { "aria-label": "Search chats", placeholder: "Search chats", value: search, onChange: (event) => setSearch(event.target.value), className: "mb-3" })), _jsx("span", { className: "sr-only", role: "status", children: copiedId ? "Chat ID copied" : "" }), loadError && (_jsx("p", { role: "alert", className: "mb-2 text-xs text-destructive", children: loadError })), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto", "aria-busy": Boolean(loadingTo), children: [sections
41
83
  .filter((section) => section.items.length)
42
84
  .map((section) => (_jsxs("section", { "aria-label": section.label || "Recent chats", className: "mb-4 last:mb-0", children: [section.label && (_jsx("h3", { "data-conversation-section-label": true, className: "px-2 py-2 text-xs font-medium text-muted-foreground", children: section.label })), section.items.map((item) => item.disabled ? (_jsx("p", { className: "py-3 text-sm text-muted-foreground", children: item.label }, item.to)) : (_jsxs("div", { className: "group flex items-center rounded-lg hover:bg-muted", children: [_jsxs(Link, { to: item.to, onClick: (event) => {
43
85
  if (loadingTo) {
@@ -69,7 +111,7 @@ export function ConversationHistory({ groups, workspaceId, onClose, showBack = t
69
111
  }
70
112
  setLoadingTo(null);
71
113
  onClose();
72
- }, "data-conversation-row": true, className: "flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-muted focus-visible:outline-2 focus-visible:outline-primary", children: [loadingTo === item.to ? (_jsx(Loader2, { className: "size-4 shrink-0 animate-spin text-muted-foreground", "aria-label": "Opening chat" })) : item.meta?.pinned ? (_jsx(Pin, { className: "size-4 shrink-0 text-muted-foreground", "aria-label": "Pinned chat" })) : (_jsx(MessageSquare, { className: "size-4 shrink-0 text-muted-foreground" })), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate", children: item.label }), " ", _jsx("span", { className: "block truncate text-xs text-muted-foreground", children: !all &&
114
+ }, "data-conversation-row": true, className: "flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-muted focus-visible:outline-2 focus-visible:outline-primary", children: [loadingTo === item.to ? (_jsx(Loader2, { className: "size-4 shrink-0 animate-spin text-muted-foreground", "aria-label": "Opening chat" })) : runFor(item)?.status === "running" ? (_jsx(Loader2, { className: "size-4 shrink-0 animate-spin text-primary motion-reduce:animate-none", "aria-hidden": true })) : runFor(item)?.status === "waiting" ? (_jsx(PauseCircle, { className: "size-4 shrink-0 text-primary", "aria-hidden": true })) : item.meta?.pinned ? (_jsx(Pin, { className: "size-4 shrink-0 text-muted-foreground", "aria-label": "Pinned chat" })) : (_jsx(MessageSquare, { className: "size-4 shrink-0 text-muted-foreground" })), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate", children: item.label }), " ", runFor(item) && isChatRunActive(runFor(item)) ? (_jsx("span", { className: "block text-xs text-primary", children: runFor(item)?.status === "waiting" ? "Waiting for you" : "Running" })) : ((!compact || rootContext) && (_jsx("span", { className: "block truncate text-xs text-muted-foreground", children: !all &&
73
115
  typeof item.meta?.previewEntityName === "string" &&
74
116
  item.meta.previewEntityName
75
117
  ? `Last at ${item.meta.previewEntityName}`
@@ -80,7 +122,21 @@ export function ConversationHistory({ groups, workspaceId, onClose, showBack = t
80
122
  ? `Last in ${item.meta.previewSpaceName}`
81
123
  : item.meta?.previewContextUnavailable
82
124
  ? "Location unavailable"
83
- : "Last outside a workspace" })] })] }), Boolean(item.actions?.length) && (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": `Actions for ${item.label}`, className: "mr-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 data-[state=open]:opacity-100 [@media(hover:none)]:opacity-100 hover:bg-background", children: _jsx(MoreHorizontal, { className: "size-4" }) }) }), _jsx(DropdownMenuContent, { align: "end", children: item.actions?.map((action) => (_jsxs(Fragment, { children: [action.separator && _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onSelect: () => {
84
- void action.handler();
85
- }, className: action.destructive ? "text-destructive" : undefined, children: [_jsx(action.icon, { className: "mr-2 size-4" }), action.label] })] }, action.label))) })] }))] }, item.to)))] }, section.label))), items.length === 0 && !compact && (_jsx("p", { className: "py-3 text-sm text-muted-foreground", children: search ? "No matching chats" : "No chats yet" }))] }), compact && onViewAll && (_jsxs(Button, { variant: "ghost", size: "sm", onClick: onViewAll, className: "mt-1 h-9 w-fit gap-2 px-2 text-sm font-normal text-muted-foreground [@media(pointer:coarse)]:min-h-11", children: [_jsx(History, { className: "size-4", "aria-hidden": true }), "Conversation history"] }))] }));
125
+ : "Last outside a workspace" })))] })] }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": `Actions for ${item.label}`, className: "mr-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 data-[state=open]:opacity-100 [@media(hover:none)]:opacity-100 hover:bg-background", children: _jsx(MoreHorizontal, { className: "size-4" }) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [_jsxs(DropdownMenuItem, { onSelect: () => {
126
+ const rawId = runFor(item)?.threadId ??
127
+ item.meta?.conversationId ??
128
+ item.to.split("/chat/")[1]?.split(/[?#]/)[0];
129
+ if (typeof rawId !== "string" || !rawId)
130
+ return;
131
+ const id = rawId;
132
+ void Promise.resolve()
133
+ .then(() => navigator.clipboard.writeText(decodeURIComponent(String(id))))
134
+ .then(() => {
135
+ setCopiedId(id);
136
+ setLoadError(null);
137
+ })
138
+ .catch(() => setLoadError(`Could not copy. Chat ID: ${decodeURIComponent(id)}`));
139
+ }, children: [_jsx(Copy, { className: "size-4" }), "Copy chat ID"] }), Boolean(item.actions?.length) && _jsx(DropdownMenuSeparator, {}), item.actions?.map((action) => (_jsxs(Fragment, { children: [action.separator && _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onSelect: () => {
140
+ void action.handler();
141
+ }, className: action.destructive ? "text-destructive" : undefined, children: [_jsx(action.icon, { className: "mr-2 size-4" }), action.label] })] }, action.label)))] })] })] }, item.to)))] }, section.label))), items.length === 0 && !compact && (_jsx("p", { className: "py-3 text-sm text-muted-foreground", children: search ? "No matching chats" : "No chats yet" }))] }), compact && onViewAll && (_jsxs(Button, { variant: "ghost", size: "sm", onClick: onViewAll, className: "mt-1 h-9 w-fit gap-2 px-2 text-sm font-normal text-muted-foreground [@media(pointer:coarse)]:min-h-11", children: [_jsx(MessagesSquare, { className: "size-4", "aria-hidden": true }), "Conversation history"] }))] }));
86
142
  }
@@ -0,0 +1,3 @@
1
+ export declare function RunningChatsMenu({ onOpen }?: {
2
+ onOpen?: () => void;
3
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useAGUIAdapter } from "../components/ag-ui-runtime-provider.js";
3
+ import { useNavigate } from "react-router";
4
+ import { LoaderCircle, MessageSquare, PauseCircle } from "lucide-react";
5
+ import { useChatRuns, isChatRunActive } from "../lib/chat-runs-store.js";
6
+ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, } from "../ui/dropdown-menu.js";
7
+ export function RunningChatsMenu({ onOpen } = {}) {
8
+ const runs = useChatRuns((s) => s.runs);
9
+ const navigate = useNavigate();
10
+ const { selectRetainedThread } = useAGUIAdapter();
11
+ const active = Object.values(runs).filter(isChatRunActive);
12
+ if (!active.length)
13
+ return null;
14
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": `${active.length} running conversations`, className: "inline-flex items-center gap-1.5 px-2 text-xs text-primary", children: [_jsx(LoaderCircle, { className: "size-3.5 animate-spin motion-reduce:animate-none" }), _jsx("span", { children: active.length })] }) }), _jsxs(DropdownMenuContent, { align: "end", className: "max-w-80", children: [_jsx(DropdownMenuLabel, { children: "Running in this browser" }), active.map((run) => (_jsxs(DropdownMenuItem, { onSelect: () => {
15
+ if (!selectRetainedThread(run.threadId)) {
16
+ navigate(run.conversationUrl ?? `/chat/${encodeURIComponent(run.threadId)}`);
17
+ }
18
+ onOpen?.();
19
+ }, children: [run.status === "waiting" ? (_jsx(PauseCircle, { className: "size-4 shrink-0" })) : (_jsx(MessageSquare, { className: "size-4 shrink-0" })), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block truncate", children: run.title }), run.status === "waiting" && (_jsx("span", { className: "block text-xs text-muted-foreground", children: "Open to continue" }))] })] }, run.threadId)))] })] }));
20
+ }
@@ -1,4 +1,3 @@
1
- import type { NavItem } from "../lib/nav-config.js";
2
1
  export declare function normalizeWorkbenchPath(path: string): string;
3
2
  export declare function useWorkbenchState(chatRoute: boolean): {
4
3
  panelOpen: boolean;
@@ -38,7 +37,7 @@ export declare function useWorkbenchState(chatRoute: boolean): {
38
37
  label: string;
39
38
  tree?: boolean;
40
39
  icon?: import("lucide-react").LucideIcon;
41
- items: NavItem[];
40
+ items: import("../index.js").NavItem[];
42
41
  priority?: number;
43
42
  defaultCollapsed?: boolean;
44
43
  to?: string;