@polpo-ai/dashboard 0.1.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 (135) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +44 -0
  3. package/app/(dashboard)/error.tsx +47 -0
  4. package/app/(dashboard)/layout.tsx +41 -0
  5. package/app/(dashboard)/loading.tsx +30 -0
  6. package/app/(dashboard)/projects/[id]/agents/[name]/memory/view.tsx +36 -0
  7. package/app/(dashboard)/projects/[id]/agents/[name]/models-view.tsx +30 -0
  8. package/app/(dashboard)/projects/[id]/agents/[name]/page.tsx +51 -0
  9. package/app/(dashboard)/projects/[id]/agents/[name]/prompt/view.tsx +211 -0
  10. package/app/(dashboard)/projects/[id]/agents/[name]/skills/view.tsx +84 -0
  11. package/app/(dashboard)/projects/[id]/agents/[name]/tools/view.tsx +525 -0
  12. package/app/(dashboard)/projects/[id]/agents/[name]/vault/view.tsx +61 -0
  13. package/app/(dashboard)/projects/[id]/agents/page.tsx +39 -0
  14. package/app/(dashboard)/projects/[id]/agents/view.tsx +283 -0
  15. package/app/(dashboard)/projects/[id]/layout.tsx +11 -0
  16. package/app/(dashboard)/projects/[id]/logs/page.tsx +27 -0
  17. package/app/(dashboard)/projects/[id]/logs/view.tsx +184 -0
  18. package/app/(dashboard)/projects/[id]/memory/page.tsx +40 -0
  19. package/app/(dashboard)/projects/[id]/memory/view.tsx +17 -0
  20. package/app/(dashboard)/projects/[id]/missions/[missionId]/page.tsx +88 -0
  21. package/app/(dashboard)/projects/[id]/missions/[missionId]/view.tsx +569 -0
  22. package/app/(dashboard)/projects/[id]/missions/page.tsx +32 -0
  23. package/app/(dashboard)/projects/[id]/missions/view.tsx +282 -0
  24. package/app/(dashboard)/projects/[id]/onboarding-checklist.tsx +359 -0
  25. package/app/(dashboard)/projects/[id]/page.tsx +68 -0
  26. package/app/(dashboard)/projects/[id]/playbooks/[name]/page.tsx +5 -0
  27. package/app/(dashboard)/projects/[id]/playbooks/[name]/view.tsx +433 -0
  28. package/app/(dashboard)/projects/[id]/playbooks/page.tsx +5 -0
  29. package/app/(dashboard)/projects/[id]/playbooks/view.tsx +222 -0
  30. package/app/(dashboard)/projects/[id]/schedules/page.tsx +44 -0
  31. package/app/(dashboard)/projects/[id]/schedules/view.tsx +385 -0
  32. package/app/(dashboard)/projects/[id]/sessions/[sessionId]/page.tsx +55 -0
  33. package/app/(dashboard)/projects/[id]/sessions/[sessionId]/raw-view.tsx +373 -0
  34. package/app/(dashboard)/projects/[id]/sessions/[sessionId]/view.tsx +124 -0
  35. package/app/(dashboard)/projects/[id]/sessions/page.tsx +31 -0
  36. package/app/(dashboard)/projects/[id]/sessions/view.tsx +242 -0
  37. package/app/(dashboard)/projects/[id]/settings/page.tsx +52 -0
  38. package/app/(dashboard)/projects/[id]/skills/[name]/page.tsx +65 -0
  39. package/app/(dashboard)/projects/[id]/skills/[name]/view.tsx +227 -0
  40. package/app/(dashboard)/projects/[id]/skills/page.tsx +31 -0
  41. package/app/(dashboard)/projects/[id]/skills/view.tsx +243 -0
  42. package/app/(dashboard)/projects/[id]/storage/page.tsx +5 -0
  43. package/app/(dashboard)/projects/[id]/storage/view.tsx +385 -0
  44. package/app/(dashboard)/projects/[id]/tasks/[taskId]/activity/view.tsx +336 -0
  45. package/app/(dashboard)/projects/[id]/tasks/[taskId]/assessment/view.tsx +113 -0
  46. package/app/(dashboard)/projects/[id]/tasks/[taskId]/blueprint.ts +72 -0
  47. package/app/(dashboard)/projects/[id]/tasks/[taskId]/output/view.tsx +90 -0
  48. package/app/(dashboard)/projects/[id]/tasks/[taskId]/page.tsx +63 -0
  49. package/app/(dashboard)/projects/[id]/tasks/[taskId]/view.tsx +137 -0
  50. package/app/(dashboard)/projects/[id]/tasks/page.tsx +40 -0
  51. package/app/(dashboard)/projects/[id]/tasks/view.tsx +421 -0
  52. package/app/(dashboard)/projects/[id]/view.tsx +421 -0
  53. package/app/(dashboard)/projects/[id]/welcome-banner.tsx +156 -0
  54. package/app/(dashboard)/projects/projects-list.tsx +126 -0
  55. package/app/(playground)/error.tsx +42 -0
  56. package/app/(playground)/layout.tsx +27 -0
  57. package/app/(playground)/projects/[id]/playground/page.tsx +57 -0
  58. package/app/(playground)/projects/[id]/playground/view.tsx +167 -0
  59. package/app/(playground)/projects/[id]/playground-legacy/page.tsx +47 -0
  60. package/app/(playground)/projects/[id]/playground-legacy/skeleton.tsx +63 -0
  61. package/app/(playground)/projects/[id]/playground-legacy/view.tsx +237 -0
  62. package/app/favicon.ico +0 -0
  63. package/app/globals.css +385 -0
  64. package/app/icon.svg +6 -0
  65. package/app/layout.tsx +39 -0
  66. package/app/page.tsx +11 -0
  67. package/components/ai-elements/code-block.tsx +562 -0
  68. package/components/dashboard/agent-capabilities.tsx +326 -0
  69. package/components/dashboard/agent-identity-header.tsx +50 -0
  70. package/components/dashboard/agent-model-picker.tsx +195 -0
  71. package/components/dashboard/agent-model-selector.tsx +121 -0
  72. package/components/dashboard/agent-picker-card.tsx +46 -0
  73. package/components/dashboard/agent-studio.tsx +242 -0
  74. package/components/dashboard/agents-table.tsx +48 -0
  75. package/components/dashboard/breadcrumb.tsx +70 -0
  76. package/components/dashboard/builder-chat.tsx +418 -0
  77. package/components/dashboard/chat-shell.tsx +40 -0
  78. package/components/dashboard/client-picker.tsx +63 -0
  79. package/components/dashboard/command-snippet.tsx +77 -0
  80. package/components/dashboard/connect-dialog.tsx +680 -0
  81. package/components/dashboard/copy-button.tsx +45 -0
  82. package/components/dashboard/copy-card.tsx +48 -0
  83. package/components/dashboard/file-browser.tsx +161 -0
  84. package/components/dashboard/hint.tsx +22 -0
  85. package/components/dashboard/inference-mode.tsx +15 -0
  86. package/components/dashboard/manual-refresh-button.tsx +52 -0
  87. package/components/dashboard/markdown.tsx +31 -0
  88. package/components/dashboard/mcp-install-panel.tsx +196 -0
  89. package/components/dashboard/mission-graph.tsx +262 -0
  90. package/components/dashboard/nav-tabs.tsx +86 -0
  91. package/components/dashboard/polpo-chat.tsx +468 -0
  92. package/components/dashboard/project-copilot.tsx +253 -0
  93. package/components/dashboard/sdk-snippet-panel.tsx +194 -0
  94. package/components/dashboard/section-header.tsx +37 -0
  95. package/components/dashboard/settings-form.tsx +2477 -0
  96. package/components/dashboard/sidebar.tsx +385 -0
  97. package/components/dashboard/skeletons.tsx +1054 -0
  98. package/components/dashboard/skills-install-wizard.tsx +273 -0
  99. package/components/dashboard/swarm-runs.tsx +316 -0
  100. package/components/dashboard/tab-toggle.tsx +35 -0
  101. package/components/dashboard/task-studio.tsx +224 -0
  102. package/components/dashboard/top-header.tsx +203 -0
  103. package/components/dashboard/webhook-deliveries.tsx +234 -0
  104. package/components/icons/coding-agents.tsx +151 -0
  105. package/components/json-ld.tsx +8 -0
  106. package/components/providers.tsx +25 -0
  107. package/components/ui/badge.tsx +52 -0
  108. package/components/ui/button.tsx +60 -0
  109. package/components/ui/card.tsx +103 -0
  110. package/components/ui/chart.tsx +356 -0
  111. package/components/ui/command.tsx +196 -0
  112. package/components/ui/dialog.tsx +157 -0
  113. package/components/ui/input-group.tsx +158 -0
  114. package/components/ui/input.tsx +20 -0
  115. package/components/ui/multi-select.tsx +161 -0
  116. package/components/ui/popover.tsx +90 -0
  117. package/components/ui/select.tsx +201 -0
  118. package/components/ui/separator.tsx +25 -0
  119. package/components/ui/sheet.tsx +135 -0
  120. package/components/ui/skeleton.tsx +13 -0
  121. package/components/ui/tabs.tsx +56 -0
  122. package/components/ui/textarea.tsx +18 -0
  123. package/components/ui/tooltip.tsx +66 -0
  124. package/hooks/use-desktop-sidebar.tsx +50 -0
  125. package/hooks/use-mobile-sidebar.tsx +37 -0
  126. package/index.ts +14 -0
  127. package/lib/api.ts +194 -0
  128. package/lib/builder-context.ts +60 -0
  129. package/lib/data-client.ts +68 -0
  130. package/lib/get-query-client.ts +25 -0
  131. package/lib/polpo-client.tsx +49 -0
  132. package/lib/tool-catalog.ts +234 -0
  133. package/lib/use-event-catalog.ts +28 -0
  134. package/lib/utils.ts +6 -0
  135. package/package.json +99 -0
@@ -0,0 +1,55 @@
1
+ import { Suspense } from "react";
2
+ import { dataApi } from "#/lib/api";
3
+ import { SessionDetailSkeleton } from "#/components/dashboard/skeletons";
4
+ import SessionDetailView, {
5
+ type Attachment,
6
+ type Message,
7
+ type Session,
8
+ } from "./view";
9
+
10
+ async function SessionData({ id, sessionId }: { id: string; sessionId: string }) {
11
+ let session: Session | null = null;
12
+ let messages: Message[] = [];
13
+ let attachments: Attachment[] = [];
14
+
15
+ const [detailRes, attachmentsRes] = await Promise.allSettled([
16
+ dataApi<{ ok: boolean; data: { session: Session; messages: Message[] } }>(
17
+ id,
18
+ `/v1/chat/sessions/${sessionId}/messages`,
19
+ ),
20
+ dataApi<{ ok: boolean; data: Attachment[] }>(
21
+ id,
22
+ `/v1/attachments?sessionId=${sessionId}`,
23
+ ),
24
+ ]);
25
+ if (detailRes.status === "fulfilled") {
26
+ session = detailRes.value.data?.session ?? null;
27
+ messages = detailRes.value.data?.messages ?? [];
28
+ }
29
+ if (attachmentsRes.status === "fulfilled") {
30
+ attachments = attachmentsRes.value.data ?? [];
31
+ }
32
+
33
+ return (
34
+ <SessionDetailView
35
+ projectId={id}
36
+ session={session}
37
+ messages={messages}
38
+ attachments={attachments}
39
+ />
40
+ );
41
+ }
42
+
43
+ export default async function SessionDetailPage({
44
+ params,
45
+ }: {
46
+ params: Promise<{ id: string; sessionId: string }>;
47
+ }) {
48
+ const { id, sessionId } = await params;
49
+
50
+ return (
51
+ <Suspense fallback={<SessionDetailSkeleton />}>
52
+ <SessionData id={id} sessionId={sessionId} />
53
+ </Suspense>
54
+ );
55
+ }
@@ -0,0 +1,373 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Paperclip, FileText, Image as ImageIcon, FileSpreadsheet, Copy, Check } from "lucide-react";
5
+ import type { Attachment, ContentPart, Message } from "./view";
6
+
7
+ function formatBytes(bytes: number): string {
8
+ if (bytes < 1024) return `${bytes} B`;
9
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
10
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
11
+ }
12
+
13
+ function AttachmentIcon({ mimeType }: { mimeType: string }) {
14
+ if (mimeType.startsWith("image/")) return <ImageIcon className="h-3.5 w-3.5" />;
15
+ if (mimeType.includes("spreadsheet") || mimeType.includes("csv")) return <FileSpreadsheet className="h-3.5 w-3.5" />;
16
+ return <FileText className="h-3.5 w-3.5" />;
17
+ }
18
+
19
+ /** Extract plain text from content (string or ContentPart[]) */
20
+ function extractText(content: string | ContentPart[]): string {
21
+ if (typeof content === "string") return content;
22
+ if (!Array.isArray(content)) return String(content ?? "");
23
+ return content
24
+ .filter((p) => p.type === "text")
25
+ .map((p) => p.text ?? "")
26
+ .join("\n");
27
+ }
28
+
29
+ /** Extract non-text parts (images, files) */
30
+ function extractParts(content: string | ContentPart[]): ContentPart[] {
31
+ if (typeof content === "string" || !Array.isArray(content)) return [];
32
+ return content.filter((p) => p.type !== "text");
33
+ }
34
+
35
+ export default function RawView({ messages, attachments, projectId }: { messages: Message[]; attachments: Attachment[]; projectId: string }) {
36
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
37
+ // Tracks which "<rowId>:<field>" key was most recently copied so the
38
+ // matching button can flash a checkmark for 1.5s. Single string keeps
39
+ // the state tiny vs. a Set + per-block timers.
40
+ const [copiedKey, setCopiedKey] = useState<string | null>(null);
41
+
42
+ const toggle = (id: string) => {
43
+ setExpanded((prev) => {
44
+ const next = new Set(prev);
45
+ if (next.has(id)) next.delete(id); else next.add(id);
46
+ return next;
47
+ });
48
+ };
49
+
50
+ const copyValue = async (e: React.MouseEvent, value: string, key: string) => {
51
+ e.stopPropagation();
52
+ try {
53
+ await navigator.clipboard.writeText(value);
54
+ setCopiedKey(key);
55
+ setTimeout(() => setCopiedKey((k) => (k === key ? null : k)), 1500);
56
+ } catch {
57
+ // Clipboard API can fail in non-secure contexts; silently swallow.
58
+ }
59
+ };
60
+
61
+ // Flatten: messages + their tool calls as separate rows
62
+ interface RawRow {
63
+ id: string;
64
+ type: "message" | "tool_call";
65
+ role?: string;
66
+ ts: string;
67
+ name?: string;
68
+ content?: string;
69
+ contentParts?: ContentPart[];
70
+ state?: string;
71
+ args?: Record<string, unknown>;
72
+ argsStr?: string;
73
+ result?: string;
74
+ parentId?: string;
75
+ messageId?: string;
76
+ attachments?: Attachment[];
77
+ }
78
+
79
+ const rows: RawRow[] = [];
80
+ const firstUserIdx = messages.findIndex(m => m.role === "user");
81
+
82
+ for (let mi = 0; mi < messages.length; mi++) {
83
+ const msg = messages[mi];
84
+ // Collect attachments for this message
85
+ const msgAtts = attachments.filter(a => a.messageId === msg.id);
86
+ const unlinked = mi === firstUserIdx ? attachments.filter(a => !a.messageId) : [];
87
+ const allAtts = [...msgAtts, ...unlinked];
88
+
89
+ const textContent = extractText(msg.content);
90
+ const contentParts = extractParts(msg.content);
91
+
92
+ // An assistant message that starts with `Error:` is the way the
93
+ // runner surfaces a failed LLM call into the session transcript.
94
+ // Without flagging it as `state: "error"` here, the row renders
95
+ // with a neutral "—" badge and looks identical to a normal
96
+ // assistant reply — making it easy to scroll past. Tagging it
97
+ // turns on the red dot + error styling that tool errors already
98
+ // use, so a failed session is visually obvious.
99
+ const isAssistantError = msg.role === "assistant"
100
+ && typeof textContent === "string"
101
+ && /^\s*Error:\s/i.test(textContent);
102
+
103
+ rows.push({
104
+ id: msg.id,
105
+ type: "message",
106
+ role: msg.role,
107
+ ts: msg.ts,
108
+ content: textContent || undefined,
109
+ contentParts: contentParts.length > 0 ? contentParts : undefined,
110
+ state: isAssistantError ? "error" : undefined,
111
+ messageId: msg.id,
112
+ attachments: allAtts.length > 0 ? allAtts : undefined,
113
+ });
114
+ if (msg.toolCalls) {
115
+ for (let ti = 0; ti < msg.toolCalls.length; ti++) {
116
+ const tc = msg.toolCalls[ti];
117
+ rows.push({
118
+ id: tc.id || `${msg.id}-tc-${ti}`,
119
+ type: "tool_call",
120
+ ts: msg.ts,
121
+ name: tc.name,
122
+ state: tc.state || undefined,
123
+ args: tc.arguments,
124
+ argsStr: tc.arguments ? JSON.stringify(tc.arguments, null, 2) : undefined,
125
+ result: tc.result || undefined,
126
+ content: undefined,
127
+ parentId: msg.id,
128
+ messageId: msg.id,
129
+ });
130
+ }
131
+ }
132
+ }
133
+
134
+ return (
135
+ <div className="border border-border overflow-hidden">
136
+ <table className="w-full text-xs">
137
+ <thead>
138
+ <tr className="border-b border-border bg-muted/40 text-muted-foreground">
139
+ <th className="px-3 py-2 text-left font-medium w-16">Type</th>
140
+ <th className="px-3 py-2 text-left font-medium w-28">Role / Tool</th>
141
+ <th className="px-3 py-2 text-left font-medium w-20">Time</th>
142
+ <th className="px-3 py-2 text-left font-medium w-20">State</th>
143
+ <th className="px-3 py-2 text-left font-medium w-48 hidden md:table-cell">Summary</th>
144
+ <th className="px-3 py-2 text-left font-medium">Content / Result</th>
145
+ <th className="px-3 py-2 text-left font-medium w-40">Attachments</th>
146
+ </tr>
147
+ </thead>
148
+ <tbody className="font-mono">
149
+ {rows.map((row) => {
150
+ const isExpanded = expanded.has(row.id);
151
+ const isToolCall = row.type === "tool_call";
152
+
153
+ return (
154
+ <tr
155
+ key={row.id}
156
+ onClick={() => toggle(row.id)}
157
+ className={`border-b border-border last:border-0 cursor-pointer transition-colors hover:bg-muted/20 ${
158
+ isToolCall ? "bg-muted/10" : ""
159
+ }`}
160
+ >
161
+ <td className="px-3 py-2 align-top">
162
+ <span className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium ${
163
+ isToolCall
164
+ ? "bg-purple-500/10 text-purple-400"
165
+ : row.role === "user"
166
+ ? "bg-blue-500/10 text-blue-400"
167
+ : "bg-green-500/10 text-green-400"
168
+ }`}>
169
+ {isToolCall ? "tool" : "msg"}
170
+ </span>
171
+ </td>
172
+ <td className="px-3 py-2 align-top text-muted-foreground">
173
+ {isToolCall ? row.name : row.role}
174
+ </td>
175
+ <td className="px-3 py-2 align-top text-muted-foreground/60">
176
+ {new Date(row.ts).toLocaleTimeString("en-US", {
177
+ hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
178
+ })}
179
+ </td>
180
+ <td className="px-3 py-2 align-top">
181
+ {row.state ? (
182
+ <span className={`inline-flex items-center gap-1 text-[10px] ${
183
+ row.state === "completed" ? "text-green-500" : row.state === "error" ? "text-red-400" : "text-yellow-500"
184
+ }`}>
185
+ <span className={`h-1.5 w-1.5 rounded-full ${
186
+ row.state === "completed" ? "bg-green-500" : row.state === "error" ? "bg-red-500" : "bg-yellow-500"
187
+ }`} />
188
+ {row.state}
189
+ </span>
190
+ ) : (
191
+ <span className="text-muted-foreground/30">—</span>
192
+ )}
193
+ </td>
194
+ {/* Summary — key args or content preview, always visible */}
195
+ <td className="px-3 py-2 align-top text-muted-foreground/70 hidden md:table-cell">
196
+ {isToolCall && row.args ? (
197
+ <span className="truncate block">
198
+ {(row.args as Record<string, unknown>).path as string
199
+ || (row.args as Record<string, unknown>).command as string
200
+ || (row.args as Record<string, unknown>).pattern as string
201
+ || (row.args as Record<string, unknown>).url as string
202
+ || (row.args as Record<string, unknown>).content
203
+ ? `${String(Object.values(row.args)[0]).substring(0, 40)}`
204
+ : Object.keys(row.args).join(", ")
205
+ }
206
+ </span>
207
+ ) : !isToolCall && row.content ? (
208
+ <span className="truncate block">
209
+ {row.content.substring(0, 50)}{row.content.length > 50 ? "…" : ""}
210
+ </span>
211
+ ) : (
212
+ <span className="text-muted-foreground/20">—</span>
213
+ )}
214
+ </td>
215
+ {/* Content / Result — expandable */}
216
+ <td className="px-3 py-2 align-top">
217
+ {isExpanded ? (
218
+ <div className="space-y-3 pb-2">
219
+ {/* ID — click to copy */}
220
+ <div className="flex gap-2 text-[10px]">
221
+ <span className="text-muted-foreground/40 uppercase w-12 shrink-0">id</span>
222
+ <button
223
+ type="button"
224
+ onClick={(e) => copyValue(e, row.id, `${row.id}:id`)}
225
+ className="group inline-flex items-center gap-1.5 text-muted-foreground/60 hover:text-foreground break-all text-left"
226
+ title="Click to copy id"
227
+ >
228
+ <span className="break-all">{row.id}</span>
229
+ {copiedKey === `${row.id}:id` ? (
230
+ <Check className="h-3 w-3 text-emerald-500 shrink-0" />
231
+ ) : (
232
+ <Copy className="h-3 w-3 opacity-0 group-hover:opacity-60 transition-opacity shrink-0" />
233
+ )}
234
+ </button>
235
+ </div>
236
+ {/* Content (messages) — red on error, hover-copy button */}
237
+ {row.content && (
238
+ <div className="group/block relative">
239
+ <span className={`text-[10px] uppercase tracking-wider ${row.state === "error" ? "text-red-400" : "text-muted-foreground"}`}>
240
+ {row.state === "error" ? "error" : "content"}
241
+ </span>
242
+ <button
243
+ type="button"
244
+ onClick={(e) => copyValue(e, row.content!, `${row.id}:content`)}
245
+ className="absolute right-1 top-3 inline-flex items-center gap-1 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-muted-foreground opacity-0 group-hover/block:opacity-100 hover:text-foreground transition-opacity"
246
+ title="Copy content"
247
+ >
248
+ {copiedKey === `${row.id}:content` ? (
249
+ <><Check className="h-3 w-3 text-emerald-500" /> copied</>
250
+ ) : (
251
+ <><Copy className="h-3 w-3" /> copy</>
252
+ )}
253
+ </button>
254
+ <pre className={`mt-1 whitespace-pre-wrap break-words rounded bg-muted/50 p-2 max-h-60 overflow-auto ${
255
+ row.state === "error" ? "text-red-400" : "text-foreground"
256
+ }`}>{row.content}</pre>
257
+ </div>
258
+ )}
259
+ {/* File/image parts */}
260
+ {row.contentParts && row.contentParts.length > 0 && (
261
+ <div>
262
+ <span className="text-[10px] text-muted-foreground uppercase tracking-wider">parts</span>
263
+ <div className="mt-1 space-y-1">
264
+ {row.contentParts.map((part, pi) => (
265
+ <div key={pi} className="flex items-center gap-2 rounded bg-muted/50 px-2 py-1.5">
266
+ {part.type === "image_url" && (
267
+ <>
268
+ <ImageIcon className="h-3 w-3 text-blue-400" />
269
+ <a href={part.image_url?.url} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:underline truncate">{part.image_url?.url}</a>
270
+ </>
271
+ )}
272
+ {part.type === "file" && (
273
+ <>
274
+ <FileText className="h-3 w-3 text-amber-400" />
275
+ <span className="font-mono text-amber-400">{part.file?.filename ?? part.file?.path}</span>
276
+ {part.file?.mimeType && <span className="text-muted-foreground/40">{part.file.mimeType}</span>}
277
+ </>
278
+ )}
279
+ </div>
280
+ ))}
281
+ </div>
282
+ </div>
283
+ )}
284
+ {/* Args (tool calls) — hover-copy */}
285
+ {row.argsStr && (
286
+ <div className="group/block relative">
287
+ <span className="text-[10px] text-muted-foreground uppercase tracking-wider">arguments</span>
288
+ <button
289
+ type="button"
290
+ onClick={(e) => copyValue(e, row.argsStr!, `${row.id}:args`)}
291
+ className="absolute right-1 top-3 inline-flex items-center gap-1 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-muted-foreground opacity-0 group-hover/block:opacity-100 hover:text-foreground transition-opacity"
292
+ title="Copy arguments"
293
+ >
294
+ {copiedKey === `${row.id}:args` ? (
295
+ <><Check className="h-3 w-3 text-emerald-500" /> copied</>
296
+ ) : (
297
+ <><Copy className="h-3 w-3" /> copy</>
298
+ )}
299
+ </button>
300
+ <pre className="mt-1 whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-muted-foreground max-h-40 overflow-auto">{row.argsStr}</pre>
301
+ </div>
302
+ )}
303
+ {/* Result (tool calls) — hover-copy */}
304
+ {row.result && (
305
+ <div className="group/block relative">
306
+ <span className={`text-[10px] uppercase tracking-wider ${row.state === "error" ? "text-red-400" : "text-muted-foreground"}`}>
307
+ {row.state === "error" ? "error" : "result"}
308
+ </span>
309
+ <button
310
+ type="button"
311
+ onClick={(e) => copyValue(e, row.result!, `${row.id}:result`)}
312
+ className="absolute right-1 top-3 inline-flex items-center gap-1 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-muted-foreground opacity-0 group-hover/block:opacity-100 hover:text-foreground transition-opacity"
313
+ title="Copy result"
314
+ >
315
+ {copiedKey === `${row.id}:result` ? (
316
+ <><Check className="h-3 w-3 text-emerald-500" /> copied</>
317
+ ) : (
318
+ <><Copy className="h-3 w-3" /> copy</>
319
+ )}
320
+ </button>
321
+ <pre className={`mt-1 whitespace-pre-wrap break-words rounded bg-muted/50 p-2 max-h-60 overflow-auto ${
322
+ row.state === "error" ? "text-red-400" : "text-muted-foreground"
323
+ }`}>{row.result}</pre>
324
+ </div>
325
+ )}
326
+ {/* No data */}
327
+ {!row.content && !row.argsStr && !row.result && (
328
+ <span className="text-muted-foreground/40 italic">No data</span>
329
+ )}
330
+ </div>
331
+ ) : (
332
+ <span className="text-muted-foreground truncate block">
333
+ {isToolCall
334
+ ? row.result
335
+ ? row.result.substring(0, 80) + (row.result.length > 80 ? "…" : "")
336
+ : (<span className="text-muted-foreground/30 italic">no result</span>)
337
+ : row.content
338
+ ? row.content.substring(0, 100) + (row.content.length > 100 ? "…" : "")
339
+ : (<span className="text-muted-foreground/30 italic">empty</span>)
340
+ }
341
+ </span>
342
+ )}
343
+ </td>
344
+ <td className="px-3 py-2 align-top">
345
+ {row.attachments && row.attachments.length > 0 ? (
346
+ <div className="flex flex-col gap-1">
347
+ {row.attachments.map(att => (
348
+ <a
349
+ key={att.id}
350
+ href={`${process.env.NEXT_PUBLIC_API_URL}/v1/projects/${projectId}/data/v1/attachments/${att.id}/download`}
351
+ target="_blank"
352
+ rel="noopener noreferrer"
353
+ onClick={(e) => e.stopPropagation()}
354
+ className="inline-flex items-center gap-1 text-[10px] text-amber-400 hover:text-amber-300 transition-colors"
355
+ >
356
+ <Paperclip className="h-2.5 w-2.5" />
357
+ <span className="font-mono">{att.filename}</span>
358
+ <span className="text-muted-foreground/40">{formatBytes(att.size)}</span>
359
+ </a>
360
+ ))}
361
+ </div>
362
+ ) : (
363
+ <span className="text-muted-foreground/20">&mdash;</span>
364
+ )}
365
+ </td>
366
+ </tr>
367
+ );
368
+ })}
369
+ </tbody>
370
+ </table>
371
+ </div>
372
+ );
373
+ }
@@ -0,0 +1,124 @@
1
+ import Link from "next/link";
2
+ import { ArrowLeft, ExternalLink } from "lucide-react";
3
+ import { ManualRefreshButton } from "#/components/dashboard/manual-refresh-button";
4
+ import RawView from "./raw-view";
5
+
6
+ export interface Attachment {
7
+ id: string;
8
+ sessionId: string;
9
+ messageId?: string;
10
+ filename: string;
11
+ mimeType: string;
12
+ size: number;
13
+ path: string;
14
+ createdAt: string;
15
+ }
16
+
17
+ export interface ToolCallInfo {
18
+ id: string;
19
+ name: string;
20
+ arguments?: Record<string, unknown>;
21
+ result?: string;
22
+ state: string;
23
+ }
24
+
25
+ export interface ContentPart {
26
+ type: "text" | "image_url" | "file";
27
+ text?: string;
28
+ image_url?: { url: string };
29
+ file?: { path: string; filename?: string; mimeType?: string };
30
+ }
31
+
32
+ export interface Message {
33
+ id: string;
34
+ role: "user" | "assistant";
35
+ content: string | ContentPart[];
36
+ ts: string;
37
+ toolCalls?: ToolCallInfo[];
38
+ }
39
+
40
+ export interface Session {
41
+ id: string;
42
+ title?: string;
43
+ agent?: string;
44
+ createdAt: string;
45
+ messageCount: number;
46
+ }
47
+
48
+ export default function SessionDetailView({
49
+ projectId,
50
+ session,
51
+ messages,
52
+ attachments,
53
+ }: {
54
+ projectId: string;
55
+ session: Session | null;
56
+ messages: Message[];
57
+ attachments: Attachment[];
58
+ }) {
59
+ if (!session) {
60
+ return (
61
+ <div data-testid="session-not-found" className="mt-4 border border-border p-8 text-center text-sm text-muted-foreground">
62
+ Session not found.
63
+ </div>
64
+ );
65
+ }
66
+
67
+ return (
68
+ <div>
69
+ {/* Back link */}
70
+ <Link
71
+ href={`/projects/${projectId}/sessions`}
72
+ className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
73
+ >
74
+ <ArrowLeft className="h-3 w-3" />
75
+ Sessions
76
+ </Link>
77
+
78
+ {/* Header */}
79
+ <div className="mt-4 flex items-start justify-between gap-4">
80
+ <div className="min-w-0">
81
+ <h2 className="text-lg font-semibold tracking-tight truncate">
82
+ {session.title ?? "Untitled session"}
83
+ </h2>
84
+ <div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
85
+ <span className="font-mono">{session.id}</span>
86
+ {session.agent && (
87
+ <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-mono">
88
+ {session.agent}
89
+ </span>
90
+ )}
91
+ <span>{new Date(session.createdAt).toLocaleString()}</span>
92
+ <span>{messages.length} messages</span>
93
+ </div>
94
+ </div>
95
+ <div className="flex shrink-0 items-center gap-3">
96
+ <ManualRefreshButton />
97
+ {/* Resume in playground — opens a new tab with ?session=<id>.
98
+ Lets the user continue an existing conversation interactively
99
+ from the read-only session detail. */}
100
+ <Link
101
+ href={`/projects/${projectId}/playground?session=${session.id}`}
102
+ target="_blank"
103
+ rel="noopener noreferrer"
104
+ className="inline-flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground"
105
+ >
106
+ <ExternalLink className="h-3.5 w-3.5" />
107
+ Open in playground
108
+ </Link>
109
+ </div>
110
+ </div>
111
+
112
+ {/* Content */}
113
+ {messages.length > 0 ? (
114
+ <div data-testid="session-messages" className="mt-5">
115
+ <RawView messages={messages} attachments={attachments} projectId={projectId} />
116
+ </div>
117
+ ) : (
118
+ <div data-testid="session-messages-empty" className="mt-6 border border-border p-8 text-center text-sm text-muted-foreground">
119
+ No messages in this session.
120
+ </div>
121
+ )}
122
+ </div>
123
+ );
124
+ }
@@ -0,0 +1,31 @@
1
+ import { Suspense } from "react";
2
+ import { dataApi } from "#/lib/api";
3
+ import { SessionsListSkeleton } from "#/components/dashboard/skeletons";
4
+ import SessionsView, { type Session } from "./view";
5
+
6
+ async function SessionsData({ id }: { id: string }) {
7
+ let sessions: Session[] = [];
8
+ try {
9
+ const res = await dataApi<{ ok: boolean; data: { sessions: Session[] } }>(
10
+ id,
11
+ "/v1/chat/sessions",
12
+ );
13
+ sessions = res.data?.sessions ?? [];
14
+ } catch {}
15
+
16
+ return <SessionsView initialSessions={sessions} />;
17
+ }
18
+
19
+ export default async function SessionsPage({
20
+ params,
21
+ }: {
22
+ params: Promise<{ id: string }>;
23
+ }) {
24
+ const { id } = await params;
25
+
26
+ return (
27
+ <Suspense fallback={<SessionsListSkeleton />}>
28
+ <SessionsData id={id} />
29
+ </Suspense>
30
+ );
31
+ }