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.
Files changed (53) hide show
  1. package/README.md +59 -23
  2. package/dist/cli.js +5 -0
  3. package/docs/.last-generated +1 -1
  4. package/docs/features/chat.md +54 -49
  5. package/docs/features/schedules.md +38 -32
  6. package/docs/features/settings.md +105 -50
  7. package/docs/manifest.json +8 -8
  8. package/docs/superpowers/specs/2026-03-27-chat-screenshot-display-design.md +303 -0
  9. package/package.json +3 -1
  10. package/src/app/api/chat/conversations/[id]/messages/route.ts +3 -2
  11. package/src/app/api/chat/entities/search/route.ts +97 -0
  12. package/src/app/api/documents/[id]/file/route.ts +4 -1
  13. package/src/app/api/projects/[id]/route.ts +119 -9
  14. package/src/app/api/projects/__tests__/delete-project.test.ts +170 -0
  15. package/src/app/api/settings/browser-tools/route.ts +68 -0
  16. package/src/app/settings/page.tsx +2 -0
  17. package/src/components/chat/chat-command-popover.tsx +277 -0
  18. package/src/components/chat/chat-input.tsx +85 -10
  19. package/src/components/chat/chat-message.tsx +9 -3
  20. package/src/components/chat/chat-shell.tsx +29 -5
  21. package/src/components/chat/screenshot-gallery.tsx +96 -0
  22. package/src/components/monitoring/log-entry.tsx +61 -27
  23. package/src/components/projects/project-detail.tsx +15 -2
  24. package/src/components/schedules/schedule-create-sheet.tsx +24 -330
  25. package/src/components/schedules/schedule-detail-sheet.tsx +37 -21
  26. package/src/components/schedules/schedule-edit-sheet.tsx +159 -0
  27. package/src/components/schedules/schedule-form.tsx +410 -0
  28. package/src/components/schedules/schedule-list.tsx +16 -0
  29. package/src/components/settings/browser-tools-section.tsx +247 -0
  30. package/src/components/settings/runtime-timeout-section.tsx +4 -4
  31. package/src/components/shared/command-palette.tsx +1 -30
  32. package/src/components/shared/screenshot-lightbox.tsx +151 -0
  33. package/src/hooks/use-caret-position.ts +104 -0
  34. package/src/hooks/use-chat-autocomplete.ts +290 -0
  35. package/src/lib/agents/__tests__/browser-mcp.test.ts +175 -0
  36. package/src/lib/agents/__tests__/claude-agent.test.ts +3 -0
  37. package/src/lib/agents/browser-mcp.ts +119 -0
  38. package/src/lib/agents/claude-agent.ts +66 -8
  39. package/src/lib/chat/command-data.ts +50 -0
  40. package/src/lib/chat/context-builder.ts +127 -3
  41. package/src/lib/chat/engine.ts +92 -11
  42. package/src/lib/chat/slash-commands.ts +191 -0
  43. package/src/lib/chat/tool-catalog.ts +185 -0
  44. package/src/lib/chat/tools/document-tools.ts +37 -0
  45. package/src/lib/chat/types.ts +11 -1
  46. package/src/lib/constants/settings.ts +4 -0
  47. package/src/lib/data/clear.ts +16 -4
  48. package/src/lib/db/bootstrap.ts +5 -0
  49. package/src/lib/db/migrations/0012_add_screenshot_columns.sql +5 -0
  50. package/src/lib/db/schema.ts +5 -0
  51. package/src/lib/screenshots/__tests__/persist.test.ts +104 -0
  52. package/src/lib/screenshots/persist.ts +114 -0
  53. 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 about your projects...";
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={(e) => {
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 and model label from completed assistant messages
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.status === "complete" && message.metadata) {
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: event.quickAccess?.length
316
- ? JSON.stringify({ quickAccess: event.quickAccess })
317
- : m.metadata,
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
+ }