@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.
- package/dist/components/ag-ui-runtime-provider.d.ts +8 -3
- package/dist/components/ag-ui-runtime-provider.js +76 -33
- package/dist/components/assistant-chat.d.ts +1 -15
- package/dist/components/assistant-chat.js +3 -4
- package/dist/components/chat-bubble.js +3 -4
- package/dist/components/chat-context-items.d.ts +6 -0
- package/dist/components/chat-context-items.js +25 -0
- package/dist/components/chat-context.js +10 -2
- package/dist/components/chat-empty-state.js +1 -1
- package/dist/components/chat-header.js +2 -1
- package/dist/components/composer-add-menu.d.ts +0 -19
- package/dist/components/composer-add-menu.js +2 -10
- package/dist/components/context-badges.d.ts +0 -14
- package/dist/components/context-badges.js +2 -29
- package/dist/components/context-bar.d.ts +1 -21
- package/dist/components/context-bar.js +3 -74
- package/dist/components/focus-chat-pane.js +1 -1
- package/dist/components/sidebar.js +39 -10
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/lib/ag-ui-adapter.d.ts +3 -0
- package/dist/lib/ag-ui-adapter.js +75 -11
- package/dist/lib/chat-runs-store.d.ts +40 -0
- package/dist/lib/chat-runs-store.js +173 -0
- package/dist/lib/merge-chat-state.d.ts +3 -0
- package/dist/lib/merge-chat-state.js +21 -0
- package/dist/lib/nav-config.js +47 -20
- package/dist/lib/use-new-conversation.js +4 -8
- package/dist/workbench/chat-header.js +4 -3
- package/dist/workbench/conversation-history.d.ts +1 -1
- package/dist/workbench/conversation-history.js +75 -19
- package/dist/workbench/running-chats-menu.d.ts +3 -0
- package/dist/workbench/running-chats-menu.js +20 -0
- package/dist/workbench/use-workbench-state.d.ts +1 -2
- package/dist/workbench/use-workbench-state.js +15 -18
- package/dist/workbench/welcome-intro.js +1 -1
- package/dist/workbench/workbench-styles.js +63 -11
- package/dist/workbench/workbench.js +1 -1
- 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
|
+
}
|
package/dist/lib/nav-config.js
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
130
|
-
|
|
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 ===
|
|
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 &&
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
12
|
+
}, children: _jsx(MessagesSquare, { size: 18 }) })] }))] }));
|
|
12
13
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { NavGroup } from "../
|
|
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 "../
|
|
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,
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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) =>
|
|
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
|
|
31
|
-
|
|
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
|
-
{
|
|
35
|
-
|
|
36
|
-
|
|
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" })] })] }),
|
|
84
|
-
|
|
85
|
-
|
|
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,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;
|