@iloveagents/foundry-web-ui 0.1.0 → 0.1.2
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/CHANGELOG.md +36 -0
- package/package.json +3 -3
- package/src/components/__tests__/context-bar.test.ts +112 -0
- package/src/components/app-brand.tsx +1 -1
- package/src/components/assistant-chat.tsx +60 -22
- package/src/components/chat-bubble.tsx +48 -14
- package/src/components/composer-submit-bridge.tsx +19 -0
- package/src/components/context-badges.tsx +25 -13
- package/src/components/context-bar.tsx +38 -2
- package/src/components/global-selection-popover.tsx +165 -34
- package/src/components/markdown-text-citations.test.tsx +108 -0
- package/src/components/markdown-text.tsx +155 -26
- package/src/components/selection-popover.tsx +3 -3
- package/src/components/sidebar.test.tsx +144 -0
- package/src/components/sidebar.tsx +207 -41
- package/src/components/theme-runtime-provider.tsx +1 -1
- package/src/components/tool-panel.tsx +3 -3
- package/src/components/user-menu.tsx +1 -1
- package/src/index.ts +11 -1
- package/src/lib/__tests__/ag-ui-adapter.test.ts +94 -3
- package/src/lib/__tests__/app-store.test.ts +75 -0
- package/src/lib/__tests__/selection-context.test.ts +114 -0
- package/src/lib/ag-ui-adapter.ts +64 -14
- package/src/lib/app-store.ts +68 -1
- package/src/lib/composer-submit-store.ts +32 -0
- package/src/lib/nav-config.ts +5 -0
- package/src/lib/selection-context.ts +65 -0
- package/src/lib/theme-runtime.ts +2 -2
- package/src/lib/use-new-conversation.test.tsx +38 -0
- package/src/lib/use-new-conversation.ts +24 -3
- package/src/ui/dropdown-menu.tsx +5 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @iloveagents/foundry-web-ui
|
|
2
|
+
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [30a4346]
|
|
8
|
+
- @iloveagents/foundry-agent@0.1.2
|
|
9
|
+
- @iloveagents/foundry-web-primitives@0.1.2
|
|
10
|
+
|
|
11
|
+
## 0.1.1
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 689d3e9: feat(sidebar): distinct file-drop affordance for nav containers
|
|
16
|
+
|
|
17
|
+
`useNavItemDnd` now exposes `isFileDragOver` separately from `isDragOver`
|
|
18
|
+
so consuming nav items can render a prominent file-drop visual (dashed
|
|
19
|
+
primary outline + soft primary background + Upload icon) when the user
|
|
20
|
+
is dragging native files over the container. Previously file drags
|
|
21
|
+
shared the same subtle ring as entity-move drags, so users couldn't
|
|
22
|
+
tell that a folder accepted external files. Applies to all five nav-
|
|
23
|
+
item shapes (action-row leaf, button leaf, `NestedFolderItem`,
|
|
24
|
+
`CollapsibleNavItem` with children/actions, `NavLink` fallthrough) and
|
|
25
|
+
updates `ContainerDropZone` for visual parity.
|
|
26
|
+
|
|
27
|
+
Also fixes a related regression: the capture-phase
|
|
28
|
+
`onDragEnterCapture` / `onDragOverCapture` handlers were calling
|
|
29
|
+
`e.stopPropagation()`, which short-circuits React's synthetic dispatch
|
|
30
|
+
and prevented the bubble-phase `onDragEnter` (where state actually
|
|
31
|
+
mutates) from running. Removed — `preventDefault()` alone is enough to
|
|
32
|
+
mark the element as droppable.
|
|
33
|
+
|
|
34
|
+
- Updated dependencies [689d3e9]
|
|
35
|
+
- @iloveagents/foundry-agent@0.1.1
|
|
36
|
+
- @iloveagents/foundry-web-primitives@0.1.1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iloveagents/foundry-web-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"lucide-react": ">=0.400.0",
|
|
24
24
|
"@azure/msal-browser": "^5.0.0",
|
|
25
25
|
"@azure/msal-react": "^5.0.0",
|
|
26
|
-
"@iloveagents/foundry-agent": "0.1.
|
|
27
|
-
"@iloveagents/foundry-web-primitives": "0.1.
|
|
26
|
+
"@iloveagents/foundry-agent": "0.1.2",
|
|
27
|
+
"@iloveagents/foundry-web-primitives": "0.1.2"
|
|
28
28
|
},
|
|
29
29
|
"peerDependenciesMeta": {
|
|
30
30
|
"@azure/msal-browser": {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for ``getNavigablePath`` — the function powering
|
|
3
|
+
* click-to-navigate on pinned context chips. Two shapes the framework
|
|
4
|
+
* supports: page (uses payload.path) and ref (uses sourcePage as the
|
|
5
|
+
* deep-link URL set by the source module).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { getNavigablePath } from "../context-bar.tsx";
|
|
10
|
+
import type { ContextItem } from "../../lib/app-store.ts";
|
|
11
|
+
|
|
12
|
+
function pageItem(path: string): ContextItem {
|
|
13
|
+
return {
|
|
14
|
+
id: "i-1",
|
|
15
|
+
type: "page",
|
|
16
|
+
label: "Workspace",
|
|
17
|
+
payload: { kind: "page", path, label: "Workspace" },
|
|
18
|
+
sourcePage: path,
|
|
19
|
+
persistence: "persistent",
|
|
20
|
+
createdAt: 0,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function refItem(refId: string, sourcePage: string): ContextItem {
|
|
25
|
+
return {
|
|
26
|
+
id: "i-2",
|
|
27
|
+
type: "ref",
|
|
28
|
+
label: "Field · cell",
|
|
29
|
+
payload: { kind: "ref", refType: "block", refId },
|
|
30
|
+
sourcePage,
|
|
31
|
+
persistence: "persistent",
|
|
32
|
+
createdAt: 0,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("getNavigablePath", () => {
|
|
37
|
+
it("returns the page payload path for page items", () => {
|
|
38
|
+
expect(getNavigablePath(pageItem("/spaces/abc"))).toBe("/spaces/abc");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns the ref's sourcePage URL (incl. hash) for ref items", () => {
|
|
42
|
+
// The source module places the deep-link in sourcePage when it
|
|
43
|
+
// creates the ref; the framework surfaces it without knowing
|
|
44
|
+
// anything about the ref shape.
|
|
45
|
+
expect(
|
|
46
|
+
getNavigablePath(refItem("abc:b1", "/spaces/abc#block-b1")),
|
|
47
|
+
).toBe("/spaces/abc#block-b1");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns null when a ref has no sourcePage", () => {
|
|
51
|
+
const item = refItem("abc:b1", "");
|
|
52
|
+
expect(getNavigablePath(item)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns null for selection items (excerpts are not anchors)", () => {
|
|
56
|
+
const item: ContextItem = {
|
|
57
|
+
id: "i-3",
|
|
58
|
+
type: "selection",
|
|
59
|
+
label: "x",
|
|
60
|
+
payload: { kind: "text", text: "x" },
|
|
61
|
+
sourcePage: "/spaces/abc",
|
|
62
|
+
persistence: "ephemeral",
|
|
63
|
+
createdAt: 0,
|
|
64
|
+
};
|
|
65
|
+
expect(getNavigablePath(item)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns null for note items (use text payload — note shares it)", () => {
|
|
69
|
+
// ``ContextPayload`` is a closed union of ``text | page | ref``.
|
|
70
|
+
// Note items reuse the ``text`` payload (the ``type`` field is
|
|
71
|
+
// what distinguishes them from selections).
|
|
72
|
+
const item: ContextItem = {
|
|
73
|
+
id: "i-4",
|
|
74
|
+
type: "note",
|
|
75
|
+
label: "n",
|
|
76
|
+
payload: { kind: "text", text: "n" },
|
|
77
|
+
sourcePage: "/spaces/abc",
|
|
78
|
+
persistence: "ephemeral",
|
|
79
|
+
createdAt: 0,
|
|
80
|
+
};
|
|
81
|
+
expect(getNavigablePath(item)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns null for ref items whose sourcePage isn't a route-like path", () => {
|
|
85
|
+
// Some callers populate ``sourcePage`` with a free-form
|
|
86
|
+
// breadcrumb label rather than a URL. Guard against navigate()
|
|
87
|
+
// pushing a relative/invalid route.
|
|
88
|
+
const item: ContextItem = {
|
|
89
|
+
id: "i-5",
|
|
90
|
+
type: "ref",
|
|
91
|
+
label: "Field · x",
|
|
92
|
+
payload: { kind: "ref", refType: "block", refId: "abc:b1" },
|
|
93
|
+
sourcePage: "Workspace breadcrumb",
|
|
94
|
+
persistence: "persistent",
|
|
95
|
+
createdAt: 0,
|
|
96
|
+
};
|
|
97
|
+
expect(getNavigablePath(item)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("accepts hash-only sourcePages (same-page deep-link)", () => {
|
|
101
|
+
const item: ContextItem = {
|
|
102
|
+
id: "i-6",
|
|
103
|
+
type: "ref",
|
|
104
|
+
label: "Field · x",
|
|
105
|
+
payload: { kind: "ref", refType: "block", refId: "abc:b1" },
|
|
106
|
+
sourcePage: "#block-b1",
|
|
107
|
+
persistence: "persistent",
|
|
108
|
+
createdAt: 0,
|
|
109
|
+
};
|
|
110
|
+
expect(getNavigablePath(item)).toBe("#block-b1");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -14,7 +14,7 @@ export function AppBrand({
|
|
|
14
14
|
labelClassName?: string;
|
|
15
15
|
}) {
|
|
16
16
|
const runtime = useThemeRuntime();
|
|
17
|
-
const brandName = runtime.branding.appName || "
|
|
17
|
+
const brandName = runtime.branding.appName || "eomi";
|
|
18
18
|
const logoUrl =
|
|
19
19
|
runtime.mode === "dark"
|
|
20
20
|
? runtime.branding.darkLogoUrl || runtime.branding.logoUrl
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ErrorPrimitive,
|
|
9
9
|
useAuiState,
|
|
10
10
|
useComposerRuntime,
|
|
11
|
+
useThreadViewport,
|
|
11
12
|
} from "@assistant-ui/react";
|
|
12
13
|
import {
|
|
13
14
|
ArrowUp,
|
|
@@ -31,6 +32,7 @@ import { TooltipIconButton } from "./tooltip-icon-button.tsx";
|
|
|
31
32
|
import { userAttachmentComponents, composerAttachmentComponents } from "./chat-attachments.tsx";
|
|
32
33
|
import { SentContextBadges, ComposerContextBadges } from "./context-badges.tsx";
|
|
33
34
|
import { ContextPins } from "./context-bar.tsx";
|
|
35
|
+
import { ComposerSubmitBridge } from "./composer-submit-bridge.tsx";
|
|
34
36
|
import { useChatBubbleStore } from "../lib/chat-bubble-store.ts";
|
|
35
37
|
import { MarkdownText } from "./markdown-text.tsx";
|
|
36
38
|
import { ToolFallback } from "./tool-fallback.tsx";
|
|
@@ -106,7 +108,9 @@ const AssistantMessage: FC = () => (
|
|
|
106
108
|
<MessageError />
|
|
107
109
|
|
|
108
110
|
<div className="flex items-center gap-2 mt-1">
|
|
109
|
-
<
|
|
111
|
+
<div className="flex h-8 w-[4.25rem] items-center">
|
|
112
|
+
<AssistantActionBar />
|
|
113
|
+
</div>
|
|
110
114
|
<BranchPicker />
|
|
111
115
|
</div>
|
|
112
116
|
</div>
|
|
@@ -114,7 +118,14 @@ const AssistantMessage: FC = () => (
|
|
|
114
118
|
);
|
|
115
119
|
|
|
116
120
|
const AssistantActionBar: FC = () => (
|
|
117
|
-
<ActionBarPrimitive.Root
|
|
121
|
+
<ActionBarPrimitive.Root
|
|
122
|
+
hideWhenRunning
|
|
123
|
+
className={cn(
|
|
124
|
+
"pointer-events-none flex items-center gap-1 -ml-2 opacity-0 transition-opacity",
|
|
125
|
+
"group-hover:pointer-events-auto group-hover:opacity-100",
|
|
126
|
+
"group-focus-within:pointer-events-auto group-focus-within:opacity-100",
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
118
129
|
<ActionBarPrimitive.Copy asChild>
|
|
119
130
|
<TooltipIconButton tooltip="Copy" size="icon">
|
|
120
131
|
<MessagePrimitive.If copied={false}>
|
|
@@ -212,24 +223,34 @@ const ComposerActionButton: FC = () => {
|
|
|
212
223
|
);
|
|
213
224
|
};
|
|
214
225
|
|
|
215
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Default starter prompts shown on empty state. Override per-app by passing
|
|
228
|
+
* a `starterSuggestions` prop to `<ChatContent>`. An empty array (`[]`)
|
|
229
|
+
* suppresses the suggestion buttons entirely.
|
|
230
|
+
*/
|
|
231
|
+
export const DEFAULT_STARTER_SUGGESTIONS = [
|
|
232
|
+
"Create a new Contract Space",
|
|
233
|
+
"List all contracts I have access to",
|
|
234
|
+
"Show me what I can do here",
|
|
220
235
|
];
|
|
221
236
|
|
|
222
|
-
const ScrollToBottomButton: FC = () =>
|
|
223
|
-
|
|
237
|
+
const ScrollToBottomButton: FC = () => {
|
|
238
|
+
const isAtBottom = useThreadViewport((s) => s.isAtBottom);
|
|
239
|
+
const scrollToBottom = useThreadViewport((s) => s.scrollToBottom);
|
|
240
|
+
|
|
241
|
+
if (isAtBottom) return null;
|
|
242
|
+
|
|
243
|
+
return (
|
|
224
244
|
<Button
|
|
225
245
|
variant="outline"
|
|
226
246
|
size="icon"
|
|
227
247
|
className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 rounded-full shadow-md"
|
|
248
|
+
onClick={() => scrollToBottom({ behavior: "smooth" })}
|
|
228
249
|
>
|
|
229
250
|
<ChevronDown className="size-4" />
|
|
230
251
|
</Button>
|
|
231
|
-
|
|
232
|
-
|
|
252
|
+
);
|
|
253
|
+
};
|
|
233
254
|
|
|
234
255
|
const DropZone: FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
235
256
|
const [isDragging, setIsDragging] = useState(false);
|
|
@@ -289,11 +310,23 @@ const DropZone: FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
|
289
310
|
);
|
|
290
311
|
};
|
|
291
312
|
|
|
313
|
+
export interface ChatContentProps {
|
|
314
|
+
/**
|
|
315
|
+
* Starter prompts shown when the thread is empty. Defaults to
|
|
316
|
+
* `DEFAULT_STARTER_SUGGESTIONS`. Pass an empty array to hide the
|
|
317
|
+
* suggestion buttons entirely. Customer apps (e.g. ANDRITZ Contracts)
|
|
318
|
+
* can pass a workspace-aware list derived from app context.
|
|
319
|
+
*/
|
|
320
|
+
starterSuggestions?: string[];
|
|
321
|
+
}
|
|
322
|
+
|
|
292
323
|
/**
|
|
293
324
|
* Reusable chat content — used by ChatPage and ChatBubble.
|
|
294
325
|
* Renders inside a ThreadPrimitive.Root context.
|
|
295
326
|
*/
|
|
296
|
-
export function ChatContent(
|
|
327
|
+
export function ChatContent({
|
|
328
|
+
starterSuggestions = DEFAULT_STARTER_SUGGESTIONS,
|
|
329
|
+
}: ChatContentProps = {}) {
|
|
297
330
|
const isExpanded = useChatBubbleStore((s) => s.isExpanded);
|
|
298
331
|
const collapseChat = useChatBubbleStore((s) => s.collapse);
|
|
299
332
|
const showPagePanel = useChatBubbleStore((s) => s.showPagePanel);
|
|
@@ -340,15 +373,17 @@ export function ChatContent() {
|
|
|
340
373
|
<h1 className="text-[2rem] font-normal text-foreground text-center mb-6">
|
|
341
374
|
How can I help you today?
|
|
342
375
|
</h1>
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
<
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
376
|
+
{starterSuggestions.length > 0 && (
|
|
377
|
+
<div className="flex flex-wrap justify-center gap-2 max-w-lg">
|
|
378
|
+
{starterSuggestions.map((prompt) => (
|
|
379
|
+
<ThreadPrimitive.Suggestion key={prompt} prompt={prompt} send asChild>
|
|
380
|
+
<Button variant="outline" className="text-sm">
|
|
381
|
+
{prompt}
|
|
382
|
+
</Button>
|
|
383
|
+
</ThreadPrimitive.Suggestion>
|
|
384
|
+
))}
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
352
387
|
</div>
|
|
353
388
|
</ThreadPrimitive.Empty>
|
|
354
389
|
|
|
@@ -367,7 +402,9 @@ export function ChatContent() {
|
|
|
367
402
|
</div>
|
|
368
403
|
</ThreadPrimitive.If>
|
|
369
404
|
</ThreadPrimitive.Viewport>
|
|
370
|
-
<
|
|
405
|
+
<ThreadPrimitive.If empty={false}>
|
|
406
|
+
<ScrollToBottomButton />
|
|
407
|
+
</ThreadPrimitive.If>
|
|
371
408
|
</div>
|
|
372
409
|
|
|
373
410
|
<div className="shrink-0 bg-background">
|
|
@@ -389,6 +426,7 @@ export function ChatContent() {
|
|
|
389
426
|
"transition-all duration-300",
|
|
390
427
|
)}
|
|
391
428
|
>
|
|
429
|
+
<ComposerSubmitBridge />
|
|
392
430
|
<ComposerContextBadges />
|
|
393
431
|
<ComposerPrimitive.Attachments components={composerAttachmentComponents} />
|
|
394
432
|
<div className="flex items-center gap-2">
|
|
@@ -4,11 +4,14 @@ import {
|
|
|
4
4
|
ComposerPrimitive,
|
|
5
5
|
MessagePrimitive,
|
|
6
6
|
ActionBarPrimitive,
|
|
7
|
+
useAuiState,
|
|
8
|
+
useThreadViewport,
|
|
7
9
|
} from "@assistant-ui/react";
|
|
8
10
|
import {
|
|
9
11
|
MessageSquare,
|
|
10
12
|
X,
|
|
11
13
|
Maximize2,
|
|
14
|
+
SquarePen,
|
|
12
15
|
ArrowUp,
|
|
13
16
|
Square,
|
|
14
17
|
ChevronDown,
|
|
@@ -20,11 +23,13 @@ import { cn } from "@iloveagents/foundry-web-primitives";
|
|
|
20
23
|
import { Button } from "@iloveagents/foundry-web-primitives";
|
|
21
24
|
import { TooltipIconButton } from "./tooltip-icon-button.tsx";
|
|
22
25
|
import { ContextPins } from "./context-bar.tsx";
|
|
23
|
-
import { ComposerContextBadges } from "./context-badges.tsx";
|
|
26
|
+
import { ComposerContextBadges, SentContextBadges } from "./context-badges.tsx";
|
|
27
|
+
import { ComposerSubmitBridge } from "./composer-submit-bridge.tsx";
|
|
24
28
|
import { MarkdownText } from "./markdown-text.tsx";
|
|
25
29
|
import { LoadingIndicator } from "./loading-indicator.tsx";
|
|
26
30
|
import { useAppStore } from "../lib/app-store.ts";
|
|
27
31
|
import { useChatBubbleStore } from "../lib/chat-bubble-store.ts";
|
|
32
|
+
import { useNewConversation } from "../lib/use-new-conversation.ts";
|
|
28
33
|
import { RAIL_WIDTH, useSidebarStore } from "../lib/sidebar-store.ts";
|
|
29
34
|
|
|
30
35
|
const CHAT_BUBBLE_INSET = 8;
|
|
@@ -39,8 +44,15 @@ function BubbleAssistantMessage() {
|
|
|
39
44
|
}}
|
|
40
45
|
/>
|
|
41
46
|
</div>
|
|
42
|
-
<div className="flex items-center
|
|
43
|
-
<ActionBarPrimitive.Root
|
|
47
|
+
<div className="flex h-7 w-8 items-center mt-1">
|
|
48
|
+
<ActionBarPrimitive.Root
|
|
49
|
+
hideWhenRunning
|
|
50
|
+
className={cn(
|
|
51
|
+
"pointer-events-none flex items-center gap-1 opacity-0 transition-opacity",
|
|
52
|
+
"group-hover:pointer-events-auto group-hover:opacity-100",
|
|
53
|
+
"group-focus-within:pointer-events-auto group-focus-within:opacity-100",
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
44
56
|
<ActionBarPrimitive.Copy asChild>
|
|
45
57
|
<TooltipIconButton tooltip="Copy" size="icon" className="size-7">
|
|
46
58
|
<MessagePrimitive.If copied={false}>
|
|
@@ -58,9 +70,12 @@ function BubbleAssistantMessage() {
|
|
|
58
70
|
}
|
|
59
71
|
|
|
60
72
|
function BubbleUserMessage() {
|
|
73
|
+
const messageId = useAuiState((s) => s.message.id);
|
|
74
|
+
|
|
61
75
|
return (
|
|
62
76
|
<MessagePrimitive.Root className="w-full mb-2">
|
|
63
|
-
<div className="flex
|
|
77
|
+
<div className="flex flex-col items-end gap-1">
|
|
78
|
+
<SentContextBadges messageId={messageId} />
|
|
64
79
|
<div className="max-w-[85%] bg-muted rounded-2xl px-3 py-2 text-sm text-foreground">
|
|
65
80
|
<MessagePrimitive.Content />
|
|
66
81
|
</div>
|
|
@@ -69,12 +84,31 @@ function BubbleUserMessage() {
|
|
|
69
84
|
);
|
|
70
85
|
}
|
|
71
86
|
|
|
87
|
+
function BubbleScrollToBottomButton() {
|
|
88
|
+
const isAtBottom = useThreadViewport((s) => s.isAtBottom);
|
|
89
|
+
const scrollToBottom = useThreadViewport((s) => s.scrollToBottom);
|
|
90
|
+
|
|
91
|
+
if (isAtBottom) return null;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Button
|
|
95
|
+
variant="outline"
|
|
96
|
+
size="icon"
|
|
97
|
+
className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 rounded-full shadow-md size-7"
|
|
98
|
+
onClick={() => scrollToBottom({ behavior: "smooth" })}
|
|
99
|
+
>
|
|
100
|
+
<ChevronDown className="size-3" />
|
|
101
|
+
</Button>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
72
105
|
export function ChatBubble() {
|
|
73
106
|
const isOpen = useChatBubbleStore((s) => s.isOpen);
|
|
74
107
|
const isExpanded = useChatBubbleStore((s) => s.isExpanded);
|
|
75
108
|
const openBubble = useChatBubbleStore((s) => s.open);
|
|
76
109
|
const closeBubble = useChatBubbleStore((s) => s.close);
|
|
77
110
|
const expandChat = useChatBubbleStore((s) => s.expand);
|
|
111
|
+
const startNewThread = useNewConversation({ navigateToChat: false, preserveNavigation: true });
|
|
78
112
|
const isSidebarOpen = useSidebarStore((s) => s.isOpen);
|
|
79
113
|
const sidebarWidth = useSidebarStore((s) => s.width);
|
|
80
114
|
const pageLabel = useAppStore((s) => s.navContext.label);
|
|
@@ -208,6 +242,14 @@ export function ChatBubble() {
|
|
|
208
242
|
</span>
|
|
209
243
|
</div>
|
|
210
244
|
<div className="flex items-center gap-0.5">
|
|
245
|
+
<TooltipIconButton
|
|
246
|
+
tooltip="New Thread"
|
|
247
|
+
size="icon"
|
|
248
|
+
className="size-7"
|
|
249
|
+
onClick={startNewThread}
|
|
250
|
+
>
|
|
251
|
+
<SquarePen className="size-3.5" />
|
|
252
|
+
</TooltipIconButton>
|
|
211
253
|
<TooltipIconButton
|
|
212
254
|
tooltip="Expand chat"
|
|
213
255
|
size="icon"
|
|
@@ -253,17 +295,8 @@ export function ChatBubble() {
|
|
|
253
295
|
</div>
|
|
254
296
|
</ThreadPrimitive.If>
|
|
255
297
|
</ThreadPrimitive.Viewport>
|
|
256
|
-
|
|
257
298
|
<ThreadPrimitive.If empty={false}>
|
|
258
|
-
<
|
|
259
|
-
<Button
|
|
260
|
-
variant="outline"
|
|
261
|
-
size="icon"
|
|
262
|
-
className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 rounded-full shadow-md size-7"
|
|
263
|
-
>
|
|
264
|
-
<ChevronDown className="size-3" />
|
|
265
|
-
</Button>
|
|
266
|
-
</ThreadPrimitive.ScrollToBottom>
|
|
299
|
+
<BubbleScrollToBottomButton />
|
|
267
300
|
</ThreadPrimitive.If>
|
|
268
301
|
</div>
|
|
269
302
|
|
|
@@ -279,6 +312,7 @@ export function ChatBubble() {
|
|
|
279
312
|
"transition-all duration-200",
|
|
280
313
|
)}
|
|
281
314
|
>
|
|
315
|
+
<ComposerSubmitBridge />
|
|
282
316
|
<ComposerContextBadges />
|
|
283
317
|
<div className="flex items-center gap-2">
|
|
284
318
|
<div className="flex items-center -space-x-1 shrink-0">
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useComposerRuntime } from "@assistant-ui/react";
|
|
3
|
+
import { useComposerSubmitStore } from "../lib/composer-submit-store.ts";
|
|
4
|
+
|
|
5
|
+
export function ComposerSubmitBridge() {
|
|
6
|
+
const composerRuntime = useComposerRuntime();
|
|
7
|
+
const pending = useComposerSubmitStore((s) => s.pending);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!pending) return;
|
|
11
|
+
|
|
12
|
+
composerRuntime.setText(pending.text);
|
|
13
|
+
composerRuntime.send({ startRun: true });
|
|
14
|
+
useComposerSubmitStore.getState().consume(pending.id);
|
|
15
|
+
}, [composerRuntime, pending]);
|
|
16
|
+
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
@@ -78,19 +78,31 @@ const ComposerBadge: FC<{ item: ContextItem }> = ({ item }) => {
|
|
|
78
78
|
);
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
-
/** Read-only badge on a sent user message
|
|
82
|
-
const SentBadge: FC<{ item: ContextItem }> = ({ item }) =>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
81
|
+
/** Read-only badge on a sent user message. Mirrors composer context chips. */
|
|
82
|
+
const SentBadge: FC<{ item: ContextItem }> = ({ item }) => {
|
|
83
|
+
const isEphemeral = item.persistence === "ephemeral";
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Tooltip>
|
|
87
|
+
<TooltipTrigger asChild>
|
|
88
|
+
<div
|
|
89
|
+
className={cn(
|
|
90
|
+
"inline-flex items-center gap-1.5 max-w-70",
|
|
91
|
+
"rounded-lg bg-primary/5",
|
|
92
|
+
"px-2 py-1",
|
|
93
|
+
isEphemeral ? "border border-dashed border-primary/40" : "border border-primary/20",
|
|
94
|
+
)}
|
|
95
|
+
>
|
|
96
|
+
<ContextIcon type={item.type} className="size-3 text-primary shrink-0" />
|
|
97
|
+
<span className="text-[11px] leading-tight text-foreground truncate">{item.label}</span>
|
|
98
|
+
</div>
|
|
99
|
+
</TooltipTrigger>
|
|
100
|
+
<TooltipContent side="top" className="whitespace-pre-line">
|
|
101
|
+
{getBadgeTooltip(item)}
|
|
102
|
+
</TooltipContent>
|
|
103
|
+
</Tooltip>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
94
106
|
|
|
95
107
|
/** Renders ephemeral context badges in the composer (persistent items shown in ContextPins). */
|
|
96
108
|
export const ComposerContextBadges: FC = () => {
|
|
@@ -37,9 +37,45 @@ interface ContextPinsProps {
|
|
|
37
37
|
flyoutDirection?: "up" | "down";
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/** Resolve the URL a context item navigates to when clicked.
|
|
41
|
+
*
|
|
42
|
+
* Two shapes the framework knows about:
|
|
43
|
+
*
|
|
44
|
+
* 1. **Page** (``type: "page"``) — the canonical case the OSS shell
|
|
45
|
+
* creates for "Pin this page". The payload's ``path`` IS the
|
|
46
|
+
* navigation target.
|
|
47
|
+
* 2. **Ref** (``type: "ref"``) — created by feature modules when the
|
|
48
|
+
* user pins a structured reference (Spaces blocks, code symbols,
|
|
49
|
+
* DB rows, …). The framework stays agnostic about the ref shape;
|
|
50
|
+
* the source module places the canonical deep-link URL — including
|
|
51
|
+
* any anchor hash — in the chip's ``sourcePage`` field, which is
|
|
52
|
+
* already the conventional "where the user pinned from" breadcrumb.
|
|
53
|
+
* Surfacing it here makes every feature module's ref chips clickable
|
|
54
|
+
* without an extension-point registry, and without the framework
|
|
55
|
+
* knowing module-specific URL shapes.
|
|
56
|
+
*
|
|
57
|
+
* Other item types (``selection``, ``note``) intentionally stay
|
|
58
|
+
* non-clickable — they are excerpts / annotations, not anchors.
|
|
59
|
+
*
|
|
60
|
+
* @internal Exported for unit tests; not part of the public API.
|
|
61
|
+
*/
|
|
62
|
+
export function getNavigablePath(item: ContextItem): string | null {
|
|
42
63
|
if (item.type === "page" && item.payload.kind === "page") return item.payload.path;
|
|
64
|
+
if (
|
|
65
|
+
item.type === "ref" &&
|
|
66
|
+
item.payload.kind === "ref" &&
|
|
67
|
+
typeof item.sourcePage === "string" &&
|
|
68
|
+
// Guard: ``sourcePage`` is also used as a free-form breadcrumb
|
|
69
|
+
// label by some callers (e.g. "page-label" instead of "/page").
|
|
70
|
+
// Treat it as a navigation target only when it looks like an
|
|
71
|
+
// in-app path. Hash-only links (``#anchor``) are also valid —
|
|
72
|
+
// they keep the user on the current entity but scroll to the
|
|
73
|
+
// pinned atom (e.g. Spaces ``#block-{id}`` deep-link from the
|
|
74
|
+
// current page).
|
|
75
|
+
(item.sourcePage.startsWith("/") || item.sourcePage.startsWith("#"))
|
|
76
|
+
) {
|
|
77
|
+
return item.sourcePage;
|
|
78
|
+
}
|
|
43
79
|
return null;
|
|
44
80
|
}
|
|
45
81
|
|