stagent 0.4.0 → 0.5.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/README.md +59 -23
- package/dist/cli.js +5 -0
- package/docs/.last-generated +1 -1
- package/docs/features/chat.md +54 -49
- package/docs/features/schedules.md +38 -32
- package/docs/features/settings.md +105 -50
- package/docs/manifest.json +8 -8
- package/docs/superpowers/specs/2026-03-27-chat-screenshot-display-design.md +303 -0
- package/package.json +3 -1
- package/src/app/api/chat/conversations/[id]/messages/route.ts +3 -2
- package/src/app/api/chat/entities/search/route.ts +97 -0
- package/src/app/api/documents/[id]/file/route.ts +4 -1
- package/src/app/api/projects/[id]/route.ts +119 -9
- package/src/app/api/projects/__tests__/delete-project.test.ts +170 -0
- package/src/app/api/settings/browser-tools/route.ts +68 -0
- package/src/app/settings/page.tsx +2 -0
- package/src/components/chat/chat-command-popover.tsx +277 -0
- package/src/components/chat/chat-input.tsx +85 -10
- package/src/components/chat/chat-message.tsx +9 -3
- package/src/components/chat/chat-shell.tsx +29 -5
- package/src/components/chat/screenshot-gallery.tsx +96 -0
- package/src/components/monitoring/log-entry.tsx +61 -27
- package/src/components/projects/project-detail.tsx +15 -2
- package/src/components/schedules/schedule-create-sheet.tsx +24 -330
- package/src/components/schedules/schedule-detail-sheet.tsx +37 -21
- package/src/components/schedules/schedule-edit-sheet.tsx +159 -0
- package/src/components/schedules/schedule-form.tsx +410 -0
- package/src/components/schedules/schedule-list.tsx +16 -0
- package/src/components/settings/browser-tools-section.tsx +247 -0
- package/src/components/settings/runtime-timeout-section.tsx +4 -4
- package/src/components/shared/command-palette.tsx +1 -30
- package/src/components/shared/screenshot-lightbox.tsx +151 -0
- package/src/hooks/use-caret-position.ts +104 -0
- package/src/hooks/use-chat-autocomplete.ts +290 -0
- package/src/lib/agents/__tests__/browser-mcp.test.ts +175 -0
- package/src/lib/agents/__tests__/claude-agent.test.ts +3 -0
- package/src/lib/agents/browser-mcp.ts +119 -0
- package/src/lib/agents/claude-agent.ts +66 -8
- package/src/lib/chat/command-data.ts +50 -0
- package/src/lib/chat/context-builder.ts +127 -3
- package/src/lib/chat/engine.ts +92 -11
- package/src/lib/chat/slash-commands.ts +191 -0
- package/src/lib/chat/tool-catalog.ts +185 -0
- package/src/lib/chat/tools/document-tools.ts +37 -0
- package/src/lib/chat/types.ts +11 -1
- package/src/lib/constants/settings.ts +4 -0
- package/src/lib/data/clear.ts +16 -4
- package/src/lib/db/bootstrap.ts +5 -0
- package/src/lib/db/migrations/0012_add_screenshot_columns.sql +5 -0
- package/src/lib/db/schema.ts +5 -0
- package/src/lib/screenshots/__tests__/persist.test.ts +104 -0
- package/src/lib/screenshots/persist.ts +114 -0
- package/src/lib/utils/stagent-paths.ts +4 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import {
|
|
6
|
+
Command,
|
|
7
|
+
CommandEmpty,
|
|
8
|
+
CommandGroup,
|
|
9
|
+
CommandInput,
|
|
10
|
+
CommandItem,
|
|
11
|
+
CommandList,
|
|
12
|
+
} from "@/components/ui/command";
|
|
13
|
+
import {
|
|
14
|
+
FolderKanban,
|
|
15
|
+
ListTodo,
|
|
16
|
+
GitBranch,
|
|
17
|
+
FileText,
|
|
18
|
+
Bot,
|
|
19
|
+
Clock,
|
|
20
|
+
Loader2,
|
|
21
|
+
} from "lucide-react";
|
|
22
|
+
import type { LucideIcon } from "lucide-react";
|
|
23
|
+
import {
|
|
24
|
+
getToolCatalog,
|
|
25
|
+
groupToolCatalog,
|
|
26
|
+
TOOL_GROUP_ICONS,
|
|
27
|
+
TOOL_GROUP_ORDER,
|
|
28
|
+
type ToolCatalogEntry,
|
|
29
|
+
} from "@/lib/chat/tool-catalog";
|
|
30
|
+
import type { AutocompleteMode, EntitySearchResult } from "@/hooks/use-chat-autocomplete";
|
|
31
|
+
|
|
32
|
+
interface ChatCommandPopoverProps {
|
|
33
|
+
open: boolean;
|
|
34
|
+
mode: AutocompleteMode;
|
|
35
|
+
query: string;
|
|
36
|
+
anchorRect: { top: number; left: number; height: number } | null;
|
|
37
|
+
entityResults: EntitySearchResult[];
|
|
38
|
+
entityLoading: boolean;
|
|
39
|
+
onSelect: (item: {
|
|
40
|
+
type: "slash" | "mention";
|
|
41
|
+
id: string;
|
|
42
|
+
label: string;
|
|
43
|
+
text?: string;
|
|
44
|
+
entityType?: string;
|
|
45
|
+
entityId?: string;
|
|
46
|
+
}) => void;
|
|
47
|
+
onClose: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ENTITY_ICONS: Record<string, LucideIcon> = {
|
|
51
|
+
project: FolderKanban,
|
|
52
|
+
task: ListTodo,
|
|
53
|
+
workflow: GitBranch,
|
|
54
|
+
document: FileText,
|
|
55
|
+
profile: Bot,
|
|
56
|
+
schedule: Clock,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ENTITY_LABELS: Record<string, string> = {
|
|
60
|
+
project: "Projects",
|
|
61
|
+
task: "Tasks",
|
|
62
|
+
workflow: "Workflows",
|
|
63
|
+
document: "Documents",
|
|
64
|
+
profile: "Profiles",
|
|
65
|
+
schedule: "Schedules",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function groupByType(results: EntitySearchResult[]): Record<string, EntitySearchResult[]> {
|
|
69
|
+
const groups: Record<string, EntitySearchResult[]> = {};
|
|
70
|
+
for (const r of results) {
|
|
71
|
+
if (!groups[r.entityType]) groups[r.entityType] = [];
|
|
72
|
+
groups[r.entityType].push(r);
|
|
73
|
+
}
|
|
74
|
+
return groups;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function ChatCommandPopover({
|
|
78
|
+
open,
|
|
79
|
+
mode,
|
|
80
|
+
query,
|
|
81
|
+
anchorRect,
|
|
82
|
+
entityResults,
|
|
83
|
+
entityLoading,
|
|
84
|
+
onSelect,
|
|
85
|
+
onClose,
|
|
86
|
+
}: ChatCommandPopoverProps) {
|
|
87
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
88
|
+
|
|
89
|
+
// Close on click outside
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!open) return;
|
|
92
|
+
function handleClick(e: MouseEvent) {
|
|
93
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
94
|
+
onClose();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
document.addEventListener("mousedown", handleClick);
|
|
98
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
99
|
+
}, [open, onClose]);
|
|
100
|
+
|
|
101
|
+
if (!open || !anchorRect || !mode) return null;
|
|
102
|
+
|
|
103
|
+
// Position above the caret
|
|
104
|
+
const style: React.CSSProperties = {
|
|
105
|
+
position: "fixed",
|
|
106
|
+
left: Math.max(8, anchorRect.left),
|
|
107
|
+
bottom: window.innerHeight - anchorRect.top + 4,
|
|
108
|
+
zIndex: 50,
|
|
109
|
+
width: 360,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const content = (
|
|
113
|
+
<div
|
|
114
|
+
ref={containerRef}
|
|
115
|
+
style={style}
|
|
116
|
+
data-chat-autocomplete=""
|
|
117
|
+
className="rounded-lg border bg-popover text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2"
|
|
118
|
+
>
|
|
119
|
+
<Command shouldFilter={mode === "slash"} loop>
|
|
120
|
+
{/* Hidden input for cmdk filtering — synced to query */}
|
|
121
|
+
<div className="sr-only">
|
|
122
|
+
<CommandInput value={query} />
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<CommandList className="max-h-[320px]">
|
|
126
|
+
<CommandEmpty>
|
|
127
|
+
{mode === "slash" ? "No matching tools" : "No matching entities"}
|
|
128
|
+
</CommandEmpty>
|
|
129
|
+
|
|
130
|
+
{mode === "slash" && <ToolCatalogItems onSelect={onSelect} />}
|
|
131
|
+
|
|
132
|
+
{mode === "mention" && (
|
|
133
|
+
<MentionItems
|
|
134
|
+
results={entityResults}
|
|
135
|
+
loading={entityLoading}
|
|
136
|
+
query={query}
|
|
137
|
+
onSelect={onSelect}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
</CommandList>
|
|
141
|
+
</Command>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return createPortal(content, document.body);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function ToolCatalogItems({
|
|
149
|
+
onSelect,
|
|
150
|
+
}: {
|
|
151
|
+
onSelect: ChatCommandPopoverProps["onSelect"];
|
|
152
|
+
}) {
|
|
153
|
+
const catalog = getToolCatalog({ includeBrowser: true });
|
|
154
|
+
const groups = groupToolCatalog(catalog);
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<>
|
|
158
|
+
{TOOL_GROUP_ORDER.map((groupName) => {
|
|
159
|
+
const items = groups[groupName];
|
|
160
|
+
if (!items?.length) return null;
|
|
161
|
+
const GroupIcon = TOOL_GROUP_ICONS[groupName];
|
|
162
|
+
return (
|
|
163
|
+
<CommandGroup key={groupName} heading={groupName}>
|
|
164
|
+
{items.map((entry) => (
|
|
165
|
+
<CommandItem
|
|
166
|
+
key={entry.name}
|
|
167
|
+
value={`${entry.name} ${entry.description} ${entry.group}`}
|
|
168
|
+
onSelect={() =>
|
|
169
|
+
onSelect({
|
|
170
|
+
type: "slash",
|
|
171
|
+
id: entry.name,
|
|
172
|
+
label: entry.name,
|
|
173
|
+
text: entry.behavior === "execute_immediately"
|
|
174
|
+
? entry.name
|
|
175
|
+
: `Use ${entry.name} to `,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
>
|
|
179
|
+
<GroupIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
180
|
+
<div className="flex flex-col min-w-0">
|
|
181
|
+
<span className="truncate text-sm font-medium">{entry.name}</span>
|
|
182
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
183
|
+
{entry.description}
|
|
184
|
+
</span>
|
|
185
|
+
</div>
|
|
186
|
+
{entry.paramHint && (
|
|
187
|
+
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground/60 font-mono">
|
|
188
|
+
{entry.paramHint}
|
|
189
|
+
</span>
|
|
190
|
+
)}
|
|
191
|
+
</CommandItem>
|
|
192
|
+
))}
|
|
193
|
+
</CommandGroup>
|
|
194
|
+
);
|
|
195
|
+
})}
|
|
196
|
+
</>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function MentionItems({
|
|
201
|
+
results,
|
|
202
|
+
loading,
|
|
203
|
+
query,
|
|
204
|
+
onSelect,
|
|
205
|
+
}: {
|
|
206
|
+
results: EntitySearchResult[];
|
|
207
|
+
loading: boolean;
|
|
208
|
+
query: string;
|
|
209
|
+
onSelect: ChatCommandPopoverProps["onSelect"];
|
|
210
|
+
}) {
|
|
211
|
+
if (loading && results.length === 0) {
|
|
212
|
+
return (
|
|
213
|
+
<div className="flex items-center gap-2 px-3 py-4 text-sm text-muted-foreground">
|
|
214
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
215
|
+
Searching...
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!query) {
|
|
221
|
+
return (
|
|
222
|
+
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
|
|
223
|
+
Type to search entities...
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const grouped = groupByType(results);
|
|
229
|
+
const entityTypes = Object.keys(grouped);
|
|
230
|
+
|
|
231
|
+
if (entityTypes.length === 0 && !loading) {
|
|
232
|
+
return null; // CommandEmpty will show
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<>
|
|
237
|
+
{entityTypes.map((type) => {
|
|
238
|
+
const Icon = ENTITY_ICONS[type] ?? FileText;
|
|
239
|
+
const groupLabel = ENTITY_LABELS[type] ?? type;
|
|
240
|
+
return (
|
|
241
|
+
<CommandGroup key={type} heading={groupLabel}>
|
|
242
|
+
{grouped[type].map((entity) => (
|
|
243
|
+
<CommandItem
|
|
244
|
+
key={`${entity.entityType}-${entity.entityId}`}
|
|
245
|
+
value={`${entity.entityType} ${entity.label}`}
|
|
246
|
+
onSelect={() =>
|
|
247
|
+
onSelect({
|
|
248
|
+
type: "mention",
|
|
249
|
+
id: entity.entityType,
|
|
250
|
+
label: entity.label,
|
|
251
|
+
entityType: entity.entityType,
|
|
252
|
+
entityId: entity.entityId,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
>
|
|
256
|
+
<Icon className="h-4 w-4 shrink-0" />
|
|
257
|
+
<div className="flex flex-col min-w-0">
|
|
258
|
+
<span className="flex-1 truncate">{entity.label}</span>
|
|
259
|
+
{entity.description && (
|
|
260
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
261
|
+
{entity.description}
|
|
262
|
+
</span>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
{entity.status && (
|
|
266
|
+
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
|
267
|
+
{entity.status}
|
|
268
|
+
</span>
|
|
269
|
+
)}
|
|
270
|
+
</CommandItem>
|
|
271
|
+
))}
|
|
272
|
+
</CommandGroup>
|
|
273
|
+
);
|
|
274
|
+
})}
|
|
275
|
+
</>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -5,10 +5,13 @@ import { Button } from "@/components/ui/button";
|
|
|
5
5
|
import { Square } from "lucide-react";
|
|
6
6
|
import { cn } from "@/lib/utils";
|
|
7
7
|
import { ChatModelSelector } from "./chat-model-selector";
|
|
8
|
+
import { ChatCommandPopover } from "./chat-command-popover";
|
|
9
|
+
import { useChatAutocomplete, type MentionReference } from "@/hooks/use-chat-autocomplete";
|
|
10
|
+
import { getToolCatalog } from "@/lib/chat/tool-catalog";
|
|
8
11
|
import type { ChatModelOption } from "@/lib/chat/types";
|
|
9
12
|
|
|
10
13
|
interface ChatInputProps {
|
|
11
|
-
onSend: (content: string) => void;
|
|
14
|
+
onSend: (content: string, mentions?: MentionReference[]) => void;
|
|
12
15
|
onStop: () => void;
|
|
13
16
|
isStreaming: boolean;
|
|
14
17
|
isHeroMode: boolean;
|
|
@@ -30,6 +33,12 @@ export function ChatInput({
|
|
|
30
33
|
}: ChatInputProps) {
|
|
31
34
|
const [value, setValue] = useState("");
|
|
32
35
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
36
|
+
const autocomplete = useChatAutocomplete();
|
|
37
|
+
|
|
38
|
+
// Sync textarea ref with autocomplete hook
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
autocomplete.setTextareaRef(textareaRef.current);
|
|
41
|
+
}, [autocomplete.setTextareaRef]);
|
|
33
42
|
|
|
34
43
|
// Auto-focus on mount and after sending
|
|
35
44
|
useEffect(() => {
|
|
@@ -39,15 +48,20 @@ export function ChatInput({
|
|
|
39
48
|
const handleSend = useCallback(() => {
|
|
40
49
|
const trimmed = value.trim();
|
|
41
50
|
if (!trimmed || isStreaming) return;
|
|
42
|
-
onSend(trimmed);
|
|
51
|
+
onSend(trimmed, autocomplete.mentions.length > 0 ? autocomplete.mentions : undefined);
|
|
43
52
|
setValue("");
|
|
44
53
|
if (textareaRef.current) {
|
|
45
54
|
textareaRef.current.style.height = "auto";
|
|
46
55
|
}
|
|
47
|
-
}, [value, isStreaming, onSend]);
|
|
56
|
+
}, [value, isStreaming, onSend, autocomplete.mentions]);
|
|
48
57
|
|
|
49
58
|
const handleKeyDown = useCallback(
|
|
50
|
-
(e: React.KeyboardEvent) => {
|
|
59
|
+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
60
|
+
// Let autocomplete handle keys first when popover is open
|
|
61
|
+
if (autocomplete.handleKeyDown(e)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
51
65
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
52
66
|
e.preventDefault();
|
|
53
67
|
handleSend();
|
|
@@ -56,7 +70,7 @@ export function ChatInput({
|
|
|
56
70
|
textareaRef.current?.blur();
|
|
57
71
|
}
|
|
58
72
|
},
|
|
59
|
-
[handleSend]
|
|
73
|
+
[handleSend, autocomplete.handleKeyDown]
|
|
60
74
|
);
|
|
61
75
|
|
|
62
76
|
// Auto-resize textarea
|
|
@@ -67,8 +81,60 @@ export function ChatInput({
|
|
|
67
81
|
textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px";
|
|
68
82
|
}, []);
|
|
69
83
|
|
|
84
|
+
const handleChange = useCallback(
|
|
85
|
+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
86
|
+
const newValue = e.target.value;
|
|
87
|
+
setValue(newValue);
|
|
88
|
+
handleInput();
|
|
89
|
+
// Notify autocomplete of text changes (must happen after setValue so selectionStart is current)
|
|
90
|
+
requestAnimationFrame(() => {
|
|
91
|
+
autocomplete.handleChange(newValue, textareaRef.current);
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
[handleInput, autocomplete.handleChange]
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const handlePopoverSelect = useCallback(
|
|
98
|
+
(item: {
|
|
99
|
+
type: "slash" | "mention";
|
|
100
|
+
id: string;
|
|
101
|
+
label: string;
|
|
102
|
+
text?: string;
|
|
103
|
+
entityType?: string;
|
|
104
|
+
entityId?: string;
|
|
105
|
+
}) => {
|
|
106
|
+
if (item.type === "slash") {
|
|
107
|
+
const entry = getToolCatalog({ includeBrowser: true }).find((t) => t.name === item.id);
|
|
108
|
+
if (entry?.behavior === "execute_immediately") {
|
|
109
|
+
autocomplete.close();
|
|
110
|
+
if (entry.name === "toggle_theme") {
|
|
111
|
+
const isDark = document.documentElement.classList.contains("dark");
|
|
112
|
+
document.documentElement.classList.toggle("dark");
|
|
113
|
+
localStorage.setItem("stagent-theme", isDark ? "light" : "dark");
|
|
114
|
+
} else if (entry.name === "mark_all_read") {
|
|
115
|
+
fetch("/api/notifications/mark-all-read", { method: "PATCH" });
|
|
116
|
+
}
|
|
117
|
+
setValue("");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// For insert_template slash commands and mentions, update textarea value
|
|
123
|
+
const newValue = autocomplete.handleSelect(item);
|
|
124
|
+
if (newValue !== undefined) {
|
|
125
|
+
setValue(newValue);
|
|
126
|
+
handleInput();
|
|
127
|
+
// Refocus textarea
|
|
128
|
+
requestAnimationFrame(() => {
|
|
129
|
+
textareaRef.current?.focus();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
[autocomplete, handleInput]
|
|
134
|
+
);
|
|
135
|
+
|
|
70
136
|
// Show preview text in placeholder when hovering a suggestion
|
|
71
|
-
const placeholder = previewText || "Ask anything
|
|
137
|
+
const placeholder = previewText || "Ask anything... (/ for tools, @ for mentions)";
|
|
72
138
|
|
|
73
139
|
return (
|
|
74
140
|
<div
|
|
@@ -94,10 +160,7 @@ export function ChatInput({
|
|
|
94
160
|
<textarea
|
|
95
161
|
ref={textareaRef}
|
|
96
162
|
value={value}
|
|
97
|
-
onChange={
|
|
98
|
-
setValue(e.target.value);
|
|
99
|
-
handleInput();
|
|
100
|
-
}}
|
|
163
|
+
onChange={handleChange}
|
|
101
164
|
onKeyDown={handleKeyDown}
|
|
102
165
|
placeholder={placeholder}
|
|
103
166
|
className={cn(
|
|
@@ -134,6 +197,18 @@ export function ChatInput({
|
|
|
134
197
|
</div>
|
|
135
198
|
</div>
|
|
136
199
|
</div>
|
|
200
|
+
|
|
201
|
+
{/* Autocomplete popover — rendered via portal */}
|
|
202
|
+
<ChatCommandPopover
|
|
203
|
+
open={autocomplete.state.open}
|
|
204
|
+
mode={autocomplete.state.mode}
|
|
205
|
+
query={autocomplete.state.query}
|
|
206
|
+
anchorRect={autocomplete.state.anchorRect}
|
|
207
|
+
entityResults={autocomplete.entityResults}
|
|
208
|
+
entityLoading={autocomplete.entityLoading}
|
|
209
|
+
onSelect={handlePopoverSelect}
|
|
210
|
+
onClose={autocomplete.close}
|
|
211
|
+
/>
|
|
137
212
|
</div>
|
|
138
213
|
);
|
|
139
214
|
}
|
|
@@ -6,8 +6,9 @@ import { ChatMessageMarkdown } from "./chat-message-markdown";
|
|
|
6
6
|
import { ChatPermissionRequest } from "./chat-permission-request";
|
|
7
7
|
import { ChatQuestionInline } from "./chat-question";
|
|
8
8
|
import { ChatQuickAccess } from "./chat-quick-access";
|
|
9
|
+
import { ScreenshotGallery } from "./screenshot-gallery";
|
|
9
10
|
import { AlertCircle } from "lucide-react";
|
|
10
|
-
import { resolveModelLabel, type ChatQuestion, type QuickAccessItem } from "@/lib/chat/types";
|
|
11
|
+
import { resolveModelLabel, type ChatQuestion, type QuickAccessItem, type ScreenshotAttachment } from "@/lib/chat/types";
|
|
11
12
|
|
|
12
13
|
interface ChatMessageProps {
|
|
13
14
|
message: ChatMessageRow;
|
|
@@ -75,13 +76,15 @@ export function ChatMessage({ message, isStreaming, conversationId, onStatusChan
|
|
|
75
76
|
// Skip rendering system messages without valid metadata
|
|
76
77
|
if (isSystem) return null;
|
|
77
78
|
|
|
78
|
-
// Extract Quick Access pills
|
|
79
|
+
// Extract Quick Access pills, model label, and screenshot attachments from assistant messages
|
|
79
80
|
let quickAccess: QuickAccessItem[] = [];
|
|
81
|
+
let attachments: ScreenshotAttachment[] = [];
|
|
80
82
|
let modelLabel: string | null = null;
|
|
81
|
-
if (!isUser && message.
|
|
83
|
+
if (!isUser && message.metadata) {
|
|
82
84
|
try {
|
|
83
85
|
const meta = JSON.parse(message.metadata);
|
|
84
86
|
if (Array.isArray(meta.quickAccess)) quickAccess = meta.quickAccess;
|
|
87
|
+
if (Array.isArray(meta.attachments)) attachments = meta.attachments;
|
|
85
88
|
if (meta.modelId) modelLabel = resolveModelLabel(meta.modelId);
|
|
86
89
|
} catch {
|
|
87
90
|
// Invalid metadata
|
|
@@ -125,6 +128,9 @@ export function ChatMessage({ message, isStreaming, conversationId, onStatusChan
|
|
|
125
128
|
})()}
|
|
126
129
|
</span>
|
|
127
130
|
) : null}
|
|
131
|
+
{attachments.length > 0 && (
|
|
132
|
+
<ScreenshotGallery attachments={attachments} />
|
|
133
|
+
)}
|
|
128
134
|
{isStreaming && message.content && (
|
|
129
135
|
<span className="inline-block w-0.5 h-4 bg-foreground animate-pulse ml-0.5 align-text-bottom" />
|
|
130
136
|
)}
|
|
@@ -9,6 +9,7 @@ import { usePersistedState } from "@/hooks/use-persisted-state";
|
|
|
9
9
|
import { ConversationList } from "./conversation-list";
|
|
10
10
|
import { ChatMessageList } from "./chat-message-list";
|
|
11
11
|
import { ChatInput } from "./chat-input";
|
|
12
|
+
import type { MentionReference } from "@/hooks/use-chat-autocomplete";
|
|
12
13
|
import { ChatEmptyState } from "./chat-empty-state";
|
|
13
14
|
import { ChatActivityIndicator } from "./chat-activity-indicator";
|
|
14
15
|
import { Button } from "@/components/ui/button";
|
|
@@ -206,7 +207,7 @@ export function ChatShell({
|
|
|
206
207
|
// ── Message Sending ──────────────────────────────────────────────────
|
|
207
208
|
|
|
208
209
|
const handleSend = useCallback(
|
|
209
|
-
async (content: string) => {
|
|
210
|
+
async (content: string, mentions?: MentionReference[]) => {
|
|
210
211
|
let conversationId = activeId;
|
|
211
212
|
|
|
212
213
|
// Create conversation on first message if none active
|
|
@@ -262,7 +263,7 @@ export function ChatShell({
|
|
|
262
263
|
{
|
|
263
264
|
method: "POST",
|
|
264
265
|
headers: { "Content-Type": "application/json" },
|
|
265
|
-
body: JSON.stringify({ content }),
|
|
266
|
+
body: JSON.stringify({ content, mentions }),
|
|
266
267
|
signal: controller.signal,
|
|
267
268
|
}
|
|
268
269
|
);
|
|
@@ -312,9 +313,15 @@ export function ChatShell({
|
|
|
312
313
|
...m,
|
|
313
314
|
id: event.messageId,
|
|
314
315
|
status: "complete",
|
|
315
|
-
metadata:
|
|
316
|
-
|
|
317
|
-
|
|
316
|
+
metadata: (() => {
|
|
317
|
+
const existing = m.metadata
|
|
318
|
+
? (() => { try { return JSON.parse(m.metadata!); } catch { return {}; } })()
|
|
319
|
+
: {};
|
|
320
|
+
if (event.quickAccess?.length) {
|
|
321
|
+
existing.quickAccess = event.quickAccess;
|
|
322
|
+
}
|
|
323
|
+
return JSON.stringify(existing);
|
|
324
|
+
})(),
|
|
318
325
|
}
|
|
319
326
|
: m
|
|
320
327
|
)
|
|
@@ -351,6 +358,23 @@ export function ChatShell({
|
|
|
351
358
|
createdAt: new Date(),
|
|
352
359
|
};
|
|
353
360
|
setMessages((prev) => [...prev, systemMsg]);
|
|
361
|
+
} else if (event.type === "screenshot") {
|
|
362
|
+
// Append screenshot attachment to assistant message metadata
|
|
363
|
+
setMessages((prev) =>
|
|
364
|
+
prev.map((m) => {
|
|
365
|
+
if (m.id !== assistantMsgId) return m;
|
|
366
|
+
const meta = m.metadata ? (() => { try { return JSON.parse(m.metadata!); } catch { return {}; } })() : {};
|
|
367
|
+
const attachments = Array.isArray(meta.attachments) ? meta.attachments : [];
|
|
368
|
+
attachments.push({
|
|
369
|
+
documentId: event.documentId,
|
|
370
|
+
thumbnailUrl: event.thumbnailUrl,
|
|
371
|
+
originalUrl: event.originalUrl,
|
|
372
|
+
width: event.width,
|
|
373
|
+
height: event.height,
|
|
374
|
+
});
|
|
375
|
+
return { ...m, metadata: JSON.stringify({ ...meta, attachments }) };
|
|
376
|
+
})
|
|
377
|
+
);
|
|
354
378
|
} else if (event.type === "error") {
|
|
355
379
|
setMessages((prev) =>
|
|
356
380
|
prev.map((m) =>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { ScreenshotAttachment } from "@/lib/chat/types";
|
|
5
|
+
import { ScreenshotLightbox } from "@/components/shared/screenshot-lightbox";
|
|
6
|
+
import { ImageIcon, ChevronDown, ChevronUp } from "lucide-react";
|
|
7
|
+
|
|
8
|
+
interface ScreenshotGalleryProps {
|
|
9
|
+
attachments: ScreenshotAttachment[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const COLLAPSED_LIMIT = 2;
|
|
13
|
+
|
|
14
|
+
export function ScreenshotGallery({ attachments }: ScreenshotGalleryProps) {
|
|
15
|
+
const [lightbox, setLightbox] = useState<ScreenshotAttachment | null>(null);
|
|
16
|
+
const [expanded, setExpanded] = useState(false);
|
|
17
|
+
|
|
18
|
+
if (attachments.length === 0) return null;
|
|
19
|
+
|
|
20
|
+
const visible = expanded ? attachments : attachments.slice(0, COLLAPSED_LIMIT);
|
|
21
|
+
const hiddenCount = attachments.length - COLLAPSED_LIMIT;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<div className="flex flex-col gap-3 mt-2">
|
|
26
|
+
{visible.map((att) => (
|
|
27
|
+
<button
|
|
28
|
+
key={att.documentId}
|
|
29
|
+
type="button"
|
|
30
|
+
className="relative rounded-lg overflow-hidden border border-border hover:border-primary transition-colors cursor-pointer group w-full"
|
|
31
|
+
onClick={() => setLightbox(att)}
|
|
32
|
+
>
|
|
33
|
+
<img
|
|
34
|
+
src={att.thumbnailUrl}
|
|
35
|
+
alt={`Screenshot ${att.width}×${att.height}`}
|
|
36
|
+
className="object-contain w-full"
|
|
37
|
+
style={{ maxHeight: 400 }}
|
|
38
|
+
loading="lazy"
|
|
39
|
+
onError={(e) => {
|
|
40
|
+
// Fallback to original if thumbnail fails
|
|
41
|
+
const img = e.currentTarget;
|
|
42
|
+
if (!img.src.includes(att.originalUrl)) {
|
|
43
|
+
img.src = att.originalUrl;
|
|
44
|
+
} else {
|
|
45
|
+
// Both failed — show placeholder
|
|
46
|
+
img.style.display = "none";
|
|
47
|
+
img.parentElement?.classList.add("bg-muted");
|
|
48
|
+
}
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
{/* Hover overlay */}
|
|
52
|
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
|
53
|
+
<ImageIcon className="h-5 w-5 text-white opacity-0 group-hover:opacity-70 transition-opacity" />
|
|
54
|
+
</div>
|
|
55
|
+
{/* Dimensions badge */}
|
|
56
|
+
<span className="absolute bottom-1 right-1 text-[9px] bg-black/50 text-white px-1.5 py-0.5 rounded">
|
|
57
|
+
{att.width}×{att.height}
|
|
58
|
+
</span>
|
|
59
|
+
</button>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{/* Expand/collapse toggle for 4+ screenshots */}
|
|
64
|
+
{hiddenCount > 0 && (
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
className="text-xs text-muted-foreground hover:text-foreground mt-1 flex items-center gap-1"
|
|
68
|
+
onClick={() => setExpanded(!expanded)}
|
|
69
|
+
>
|
|
70
|
+
{expanded ? (
|
|
71
|
+
<>
|
|
72
|
+
<ChevronUp className="h-3 w-3" />
|
|
73
|
+
Show fewer
|
|
74
|
+
</>
|
|
75
|
+
) : (
|
|
76
|
+
<>
|
|
77
|
+
<ChevronDown className="h-3 w-3" />
|
|
78
|
+
Show {hiddenCount} more screenshot{hiddenCount > 1 ? "s" : ""}
|
|
79
|
+
</>
|
|
80
|
+
)}
|
|
81
|
+
</button>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{/* Lightbox overlay */}
|
|
85
|
+
{lightbox && (
|
|
86
|
+
<ScreenshotLightbox
|
|
87
|
+
open={!!lightbox}
|
|
88
|
+
onClose={() => setLightbox(null)}
|
|
89
|
+
imageUrl={lightbox.originalUrl}
|
|
90
|
+
width={lightbox.width}
|
|
91
|
+
height={lightbox.height}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
}
|