deepspace 0.19.5 → 0.21.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 (43) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/{chunk-A7UFIOWO.js → chunk-KDCDRHBA.js} +7 -2
  3. package/dist/chunk-KDCDRHBA.js.map +1 -0
  4. package/dist/cli.js +219 -94
  5. package/dist/cli.js.map +1 -1
  6. package/dist/documentation-client-core.d.ts +3 -2
  7. package/dist/documentation-client-core.js +1 -1
  8. package/dist/documentation-default-renderer.cjs +48 -48
  9. package/dist/documentation-react.d.ts +2 -1
  10. package/dist/documentation-react.js +1 -1
  11. package/dist/documentation-runtime.js +7 -7
  12. package/dist/documentation-server-core.d.ts +3 -2
  13. package/dist/documentation-server-core.js +1 -1
  14. package/dist/documentation.d.ts +40 -12
  15. package/dist/documentation.js +40 -7
  16. package/dist/documentation.js.map +1 -1
  17. package/dist/index.d.ts +225 -21
  18. package/dist/index.js +262 -112
  19. package/dist/index.js.map +1 -1
  20. package/dist/{public-CFAj1hL2.d.ts → public-DenQ1TsZ.d.ts} +36 -11
  21. package/dist/server.d.ts +46 -29
  22. package/dist/server.js +1041 -254
  23. package/dist/server.js.map +1 -1
  24. package/dist/testing-mcp.d.ts +69 -0
  25. package/dist/testing-mcp.js +99 -0
  26. package/dist/testing-mcp.js.map +1 -0
  27. package/dist/testing.d.ts +17 -20
  28. package/dist/testing.js +47 -25
  29. package/dist/testing.js.map +1 -1
  30. package/dist/worker.d.ts +73 -32
  31. package/dist/worker.js +1094 -244
  32. package/dist/worker.js.map +1 -1
  33. package/features/ai-chat/feature.json +2 -2
  34. package/features/ai-chat/src/AiChatPage.tsx +56 -21
  35. package/features/ai-chat/src/ChatPanel.tsx +33 -10
  36. package/features/ai-chat/src/__tests__/ChatPanel.lifecycle.test.tsx +156 -0
  37. package/features/cron/src/CronLogPage.tsx +107 -69
  38. package/features/file-attachments/feature.json +2 -1
  39. package/features/file-manager/feature.json +2 -1
  40. package/features/file-manager/src/FileManagerPage.tsx +227 -194
  41. package/features/integration-test/src/IntegrationTestPage.tsx +54 -34
  42. package/package.json +5 -1
  43. package/dist/chunk-A7UFIOWO.js.map +0 -1
@@ -29,13 +29,13 @@
29
29
  "highlight.js": "^11.10.0"
30
30
  },
31
31
  "instructions": [
32
- "Embed ChatPanel standalone (without the conversation rail) for sidebar / modal / popover use. Pass `chatId={null}` to auto-create on first send; use `onChatCreated` to track the new id.",
32
+ "Embed ChatPanel standalone (without the conversation rail) for sidebar / modal / popover use. Pass `chatId={null}` to auto-create on first send — the panel keeps the created chat itself; `onChatCreated` is an optional notification for parents that track ids.",
33
33
  "All chat data lives in the app's RecordRoom DO under the ai-chats and ai-messages collections. RBAC is enforced — users only see their own chats and messages.",
34
34
  "The backend route POST /api/ai/chat takes { chatId, userMessageId, content, modelId? } and runs the full server-owned pipeline: load history, compact if needed, stream assistant, persist on finish.",
35
35
  "Compaction (summarizing older history when the conversation grows large) is server-side and cached on the chat row. No client-side compaction logic."
36
36
  ],
37
37
  "patterns": [
38
- "ChatPanel takes `chatId` (or null). Parent owns chat lifecycle (create / select / delete); pass `disabled` while a parent-driven create is in flight so the panel doesn't kick off its own auto-create.",
38
+ "ChatPanel takes `chatId` (or null, in which case the panel creates and keeps its own chat — remount with `key` for a new one). A parent that manages chats (create / select / delete) passes real ids, and `disabled` while a parent-driven create is in flight so the panel doesn't kick off its own auto-create.",
39
39
  "ChatPanel and the server agent both consume the SDK's canonical model catalog; never add a second model allowlist in app code.",
40
40
  "Compaction summary is stored on the ai-chats row (compactedSummary, compactedThroughId). The summarizer is user-billed by default; pass `makeDefaultSummarizer(c.env)` with no authToken in worker.ts to absorb the cost as the developer instead.",
41
41
  "Tool execution is server-side and per-user RBAC-enforced (records.* tools see only what the caller would see). The canonical runner enforces at most 20 tool calls per turn."
@@ -82,6 +82,9 @@ export default function AiChatPage() {
82
82
  const [dragging, setDragging] = useState(false)
83
83
  const [creatingChat, setCreatingChat] = useState(false)
84
84
  const [createError, setCreateError] = useState<string | null>(null)
85
+ // Delete/rename failures live apart from createError: its Retry re-runs
86
+ // create, which is the wrong offer after a failed delete or rename.
87
+ const [actionError, setActionError] = useState<string | null>(null)
85
88
 
86
89
  const chatCardRef = useRef<HTMLDivElement>(null)
87
90
 
@@ -193,6 +196,7 @@ export default function AiChatPage() {
193
196
  // any in-flight stream when activeChatId flips id→null, so the orphan
194
197
  // assistant write we'd otherwise produce on cascade-delete is prevented
195
198
  // by F1 (abort on id→null) — not by ordering this call before the fetch.
199
+ setActionError(null)
196
200
  try {
197
201
  const token = await getAuthToken()
198
202
  const headers: Record<string, string> = {}
@@ -202,11 +206,12 @@ export default function AiChatPage() {
202
206
  setActiveChatId((cur) => (cur === id ? null : cur))
203
207
  } catch (err) {
204
208
  console.error('[ai-chat-page] delete failed:', err)
205
- setCreateError(err instanceof Error ? `Couldn't delete chat: ${err.message}` : "Couldn't delete chat")
209
+ setActionError(err instanceof Error ? `Couldn't delete chat: ${err.message}` : "Couldn't delete chat")
206
210
  }
207
211
  }, [])
208
212
 
209
213
  const handleRename = useCallback(async (id: string, title: string) => {
214
+ setActionError(null)
210
215
  try {
211
216
  const token = await getAuthToken()
212
217
  const headers: Record<string, string> = { 'Content-Type': 'application/json' }
@@ -219,6 +224,7 @@ export default function AiChatPage() {
219
224
  if (!res.ok) throw new Error(`rename failed: ${res.status}`)
220
225
  } catch (err) {
221
226
  console.error('[ai-chat-page] rename failed:', err)
227
+ setActionError(err instanceof Error ? `Couldn't rename chat: ${err.message}` : "Couldn't rename chat")
222
228
  }
223
229
  }, [])
224
230
 
@@ -303,26 +309,17 @@ export default function AiChatPage() {
303
309
  onHistory={() => setHistoryOpen(true)}
304
310
  />
305
311
  {createError && (
306
- <div role="alert" className="mx-4 mb-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
307
- <div className="flex items-start gap-2">
308
- <span className="flex-1 leading-relaxed">{createError}</span>
309
- <button
310
- type="button"
311
- onClick={() => { void handleNew() }}
312
- className="rounded-md border border-destructive/30 px-2 py-0.5 text-[12px] font-medium hover:bg-destructive/10"
313
- >
314
- Retry
315
- </button>
316
- <button
317
- type="button"
318
- onClick={() => setCreateError(null)}
319
- aria-label="Dismiss"
320
- className="rounded-md px-1 text-[14px] leading-none hover:bg-destructive/10"
321
- >
322
- ×
323
- </button>
324
- </div>
325
- </div>
312
+ <ErrorBanner
313
+ message={createError}
314
+ onRetry={() => { void handleNew() }}
315
+ onDismiss={() => setCreateError(null)}
316
+ />
317
+ )}
318
+ {actionError && (
319
+ <ErrorBanner
320
+ message={actionError}
321
+ onDismiss={() => setActionError(null)}
322
+ />
326
323
  )}
327
324
  </>
328
325
  }
@@ -358,6 +355,44 @@ export default function AiChatPage() {
358
355
  )
359
356
  }
360
357
 
358
+ // ============================================================================
359
+ // Error banner — dismissible destructive strip under the header. Retry is
360
+ // opt-in so a failed delete/rename never offers an unrelated create action.
361
+ // ============================================================================
362
+
363
+ function ErrorBanner({
364
+ message, onRetry, onDismiss,
365
+ }: {
366
+ message: string
367
+ onRetry?: () => void
368
+ onDismiss: () => void
369
+ }) {
370
+ return (
371
+ <div role="alert" className="mx-4 mb-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
372
+ <div className="flex items-start gap-2">
373
+ <span className="flex-1 leading-relaxed">{message}</span>
374
+ {onRetry && (
375
+ <button
376
+ type="button"
377
+ onClick={onRetry}
378
+ className="rounded-md border border-destructive/30 px-2 py-0.5 text-[12px] font-medium hover:bg-destructive/10"
379
+ >
380
+ Retry
381
+ </button>
382
+ )}
383
+ <button
384
+ type="button"
385
+ onClick={onDismiss}
386
+ aria-label="Dismiss"
387
+ className="rounded-md px-1 text-[14px] leading-none hover:bg-destructive/10"
388
+ >
389
+ ×
390
+ </button>
391
+ </div>
392
+ </div>
393
+ )
394
+ }
395
+
361
396
  // ============================================================================
362
397
  // Chat header bar — title on the left, action icons on the right.
363
398
  // Mirrors Miyagi's FloatingChatHeader (`title | new chat | history | close`).
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * DO-backed AI chat surface.
3
3
  *
4
- * The parent owns chat lifecycle. Pass `chatId={null}` to create on first send
5
- * and update the parent from `onChatCreated`.
4
+ * Pass `chatId={null}` to create a chat on first send. The panel keeps the
5
+ * created id itself; `onChatCreated` merely notifies, and a parent-passed
6
+ * `chatId` — including a change back to null for "new chat" — always wins.
6
7
  */
7
8
 
8
9
  import {
10
+ useCallback,
9
11
  useEffect,
10
12
  useMemo,
11
13
  useRef,
@@ -46,11 +48,11 @@ type RenderMessage = {
46
48
  }
47
49
 
48
50
  export type ChatPanelProps = {
49
- /** Active chat. `null` creates one on first send. */
51
+ /** Active chat. `null` creates one on first send and the panel keeps it — a never-passed `chatId` means one chat per mount; remount with `key` for a new one. */
50
52
  chatId: string | null
51
53
  /** Current user id; scopes the messages query as defense in depth. */
52
54
  userId: string
53
- /** Required in practice when `chatId` is null so the parent tracks the new chat. */
55
+ /** Notified with the id when the panel creates a chat on first send. */
54
56
  onChatCreated?: (chatId: string) => void
55
57
  /** Models shown in the picker. */
56
58
  models?: ModelOption[]
@@ -108,15 +110,36 @@ export function ChatPanel({
108
110
 
109
111
  const groupedModels = useMemo(() => groupModelsByProvider(models), [models])
110
112
  const selectedModel = models.find((model) => model.id === modelId)
113
+
114
+ // Uncontrolled fallback: with `chatId={null}` the panel keeps the chat it
115
+ // auto-creates on first send, so the overlay, the messages query, and later
116
+ // sends all target that chat. Any prop change — including back to null,
117
+ // which means "new chat" — takes precedence and clears it.
118
+ const [ownChatId, setOwnChatId] = useState<string | null>(null)
119
+ const [seenChatId, setSeenChatId] = useState(chatId)
120
+ if (seenChatId !== chatId) {
121
+ setSeenChatId(chatId)
122
+ setOwnChatId(null)
123
+ }
124
+ const activeChatId = chatId ?? ownChatId
125
+
126
+ const handleChatCreated = useCallback(
127
+ (id: string) => {
128
+ setOwnChatId(id)
129
+ onChatCreated?.(id)
130
+ },
131
+ [onChatCreated],
132
+ )
133
+
111
134
  const { send, stop, retry, isLoading, error, inFlight } = useStreamingChat({
112
- chatId,
135
+ chatId: activeChatId,
113
136
  modelId,
114
- onChatCreated,
137
+ onChatCreated: handleChatCreated,
115
138
  })
116
139
 
117
140
  const queryWhere = useMemo(
118
- () => ({ chatId: chatId ?? '__none__', userId }),
119
- [chatId, userId],
141
+ () => ({ chatId: activeChatId ?? '__none__', userId }),
142
+ [activeChatId, userId],
120
143
  )
121
144
  const { records } = useQuery<AiMessageData>('ai-messages', {
122
145
  where: queryWhere,
@@ -138,12 +161,12 @@ export function ChatPanel({
138
161
  )
139
162
  const messages = useMemo<RenderMessage[]>(() => {
140
163
  const overlay = inFlight.filter((message) => {
141
- if (message.forChatId !== chatId) return false
164
+ if (message.forChatId !== activeChatId) return false
142
165
  if (persistedIds.has(message.id)) return false
143
166
  return !message.serverId || !persistedIds.has(message.serverId)
144
167
  })
145
168
  return [...persisted, ...overlay]
146
- }, [chatId, inFlight, persisted, persistedIds])
169
+ }, [activeChatId, inFlight, persisted, persistedIds])
147
170
 
148
171
  useEffect(() => {
149
172
  const element = scrollRef.current
@@ -0,0 +1,156 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act } from 'react'
4
+ import { createRoot, type Root } from 'react-dom/client'
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+ import { ChatPanel } from '../ChatPanel'
7
+
8
+ const { queryWheres } = vi.hoisted(() => ({
9
+ queryWheres: [] as Array<{ chatId?: string; userId?: string }>,
10
+ }))
11
+
12
+ vi.mock('deepspace', async () => {
13
+ // The SSE codec is pure; run the real one so the stubbed wire bytes below
14
+ // exercise the same decode path production streams take.
15
+ const stream = await vi.importActual<typeof import('@/client/ai-stream')>('@/client/ai-stream')
16
+ return {
17
+ decodeAiStreamChunk: stream.decodeAiStreamChunk,
18
+ parseSseLine: stream.parseSseLine,
19
+ getAuthToken: async () => null,
20
+ listDeepSpaceAgentModels: () => [
21
+ { id: 'model-a', label: 'Model A', providerLabel: 'Provider' },
22
+ ],
23
+ useQuery: (_collection: string, options: { where: { chatId?: string; userId?: string } }) => {
24
+ queryWheres.push(options.where)
25
+ return { records: [] as Array<{ recordId: string; data: Record<string, unknown> }> }
26
+ },
27
+ }
28
+ })
29
+
30
+ // The turn renderer pulls app-level markdown deps (react-markdown,
31
+ // remark-breaks) that feature.json ships to scaffolded apps but the SDK
32
+ // workspace does not install. This suite is about ChatPanel.tsx's chat
33
+ // lifecycle, not rendering — substitute minimal, queryable turns.
34
+ vi.mock('../ChatPanel.messages', () => ({
35
+ EmptyState: () => <div data-empty-state="">Empty state</div>,
36
+ MessageTurn: ({ role, content }: { role: string; content: string }) => (
37
+ <div data-turn={role}>{content}</div>
38
+ ),
39
+ ThinkingIndicator: () => <div data-thinking="" />,
40
+ }))
41
+ ;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
42
+
43
+ let container: HTMLDivElement
44
+ let root: Root
45
+ let parentRenders = 0
46
+ let chatCreates = 0
47
+ let streamBodies: Array<Record<string, unknown>> = []
48
+
49
+ /**
50
+ * A parent stuck at `chatId={null}` — no `onChatCreated`, no state. The panel
51
+ * must run the whole first-chat lifecycle without a single parent rerender.
52
+ */
53
+ function Parent() {
54
+ parentRenders += 1
55
+ return <ChatPanel chatId={null} userId="user-1" />
56
+ }
57
+
58
+ beforeEach(() => {
59
+ parentRenders = 0
60
+ chatCreates = 0
61
+ streamBodies = []
62
+ queryWheres.length = 0
63
+
64
+ window.requestAnimationFrame = (callback) => window.setTimeout(callback, 0)
65
+ window.cancelAnimationFrame = (handle) => window.clearTimeout(handle)
66
+ // jsdom implements no element scrolling; the panel pins its log to the bottom.
67
+ window.HTMLElement.prototype.scrollTo = () => {}
68
+
69
+ vi.stubGlobal(
70
+ 'fetch',
71
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
72
+ const url = String(input)
73
+ if (url === '/api/ai/chats') {
74
+ chatCreates += 1
75
+ return Response.json({ chat: { recordId: 'chat-new' } })
76
+ }
77
+ if (url === '/api/ai/chat') {
78
+ streamBodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>)
79
+ return new Response(
80
+ `data: ${JSON.stringify({ type: 'text-delta', delta: 'Hi.' })}\n\ndata: [DONE]\n\n`,
81
+ { headers: { 'X-Asst-Id': `asst-${streamBodies.length}` } },
82
+ )
83
+ }
84
+ throw new Error(`unexpected fetch: ${url}`)
85
+ }),
86
+ )
87
+
88
+ container = document.createElement('div')
89
+ document.body.appendChild(container)
90
+ root = createRoot(container)
91
+ })
92
+
93
+ afterEach(() => {
94
+ act(() => root.unmount())
95
+ container.remove()
96
+ vi.unstubAllGlobals()
97
+ })
98
+
99
+ /** Drain the send's microtask chain (create → stream → settle) until idle. */
100
+ async function flushUntil(ready: () => boolean): Promise<void> {
101
+ for (let attempt = 0; attempt < 20; attempt += 1) {
102
+ if (ready()) return
103
+ await act(async () => {
104
+ await Promise.resolve()
105
+ })
106
+ }
107
+ throw new Error('panel never settled after send')
108
+ }
109
+
110
+ async function sendMessage(content: string): Promise<void> {
111
+ const textarea = container.querySelector('textarea')
112
+ expect(textarea).not.toBeNull()
113
+ await act(async () => {
114
+ const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
115
+ setValue?.call(textarea, content)
116
+ textarea?.dispatchEvent(new Event('input', { bubbles: true }))
117
+ })
118
+ await act(async () => {
119
+ container.querySelector('form')?.dispatchEvent(
120
+ new Event('submit', { bubbles: true, cancelable: true }),
121
+ )
122
+ })
123
+ await flushUntil(() => container.querySelector('[aria-label="Send message"]') !== null)
124
+ }
125
+
126
+ describe('ChatPanel first-chat lifecycle with chatId={null}', () => {
127
+ it('renders the first exchange from its own chat without a parent rerender', async () => {
128
+ await act(async () => root.render(<Parent />))
129
+ expect(container.querySelector('[data-empty-state]')).not.toBeNull()
130
+
131
+ await sendMessage('Hello from a fresh mount')
132
+
133
+ expect(container.querySelector('[data-empty-state]')).toBeNull()
134
+ const turns = [...container.querySelectorAll('[data-turn]')]
135
+ expect(turns.map((turn) => turn.getAttribute('data-turn'))).toEqual(['user', 'assistant'])
136
+ expect(turns[0]?.textContent).toBe('Hello from a fresh mount')
137
+ expect(turns[1]?.textContent).toBe('Hi.')
138
+ expect(parentRenders).toBe(1)
139
+
140
+ // The persisted-messages query re-keys onto the promoted chat id.
141
+ expect(queryWheres[0]?.chatId).toBe('__none__')
142
+ expect(queryWheres.at(-1)?.chatId).toBe('chat-new')
143
+ })
144
+
145
+ it('reuses the promoted chat for the second send instead of creating another', async () => {
146
+ await act(async () => root.render(<Parent />))
147
+
148
+ await sendMessage('First message')
149
+ await sendMessage('Second message')
150
+
151
+ expect(chatCreates).toBe(1)
152
+ expect(streamBodies.map((body) => body.chatId)).toEqual(['chat-new', 'chat-new'])
153
+ const userTurns = [...container.querySelectorAll('[data-turn="user"]')]
154
+ expect(userTurns.at(-1)?.textContent).toBe('Second message')
155
+ })
156
+ })
@@ -2,20 +2,26 @@
2
2
  * Cron Log Page
3
3
  *
4
4
  * Subscribes to the app's CronRoom via `useCronMonitor` and renders live
5
- * task state + execution history. The DO is keyed by `SCOPE_ID`
6
- * (`app:${APP_ID}`) so a single shared CronRoom DO instance backs the
7
- * whole app — same pattern as RecordRoom.
5
+ * task state + execution history with the scaffold's ui primitives and
6
+ * theme tokens. The DO is keyed by `SCOPE_ID` (`app:${APP_ID}`) so a
7
+ * single shared CronRoom DO instance backs the whole app — same pattern
8
+ * as RecordRoom.
8
9
  *
9
10
  * Used both as a UI surface for verifying that scheduled tasks are firing
10
11
  * in production, and as the data source for the cron e2e spec at
11
- * tests/feature-tests/tests/cron.spec.ts.
12
+ * tests/feature-tests/tests/cron.spec.ts — keep the data-testid and
13
+ * data-* attributes stable.
12
14
  */
13
15
 
14
16
  import { useCronMonitor } from 'deepspace'
17
+ import { Badge, EmptyState } from '@/components/ui'
15
18
  import { SCOPE_ID } from '../constants'
16
19
 
20
+ const TH = 'px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground'
21
+ const TD = 'px-4 py-3 text-sm'
22
+
17
23
  export default function CronLogPage() {
18
- const { tasks, history, connected } = useCronMonitor(SCOPE_ID)
24
+ const { tasks, history, connected, lastError } = useCronMonitor(SCOPE_ID)
19
25
 
20
26
  // Newest first.
21
27
  const sorted = [...history].sort((a, b) => {
@@ -23,77 +29,109 @@ export default function CronLogPage() {
23
29
  })
24
30
 
25
31
  return (
26
- <div data-testid="cron-log-page" style={{ padding: 24 }}>
27
- <h1>Cron Log</h1>
28
- <p>
29
- Each row is one tick of a scheduled task fired by the AppCronRoom DO.
30
- Heartbeat ticks every minute once the DO alarm picks up the registered
31
- config.
32
- </p>
33
-
34
- <div data-testid="cron-log-status" style={{ marginBottom: 12 }}>
35
- Connection: {connected ? 'live' : 'connecting…'}
32
+ <div data-testid="cron-log-page" className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
33
+ <div className="flex flex-wrap items-start justify-between gap-4">
34
+ <div>
35
+ <h1 className="text-2xl font-bold text-foreground">Cron Log</h1>
36
+ <p className="mt-1 max-w-xl text-sm text-muted-foreground">
37
+ Each row is one tick of a scheduled task fired by the AppCronRoom DO.
38
+ Heartbeat ticks every minute once the DO alarm picks up the registered
39
+ config.
40
+ </p>
41
+ </div>
42
+ <div
43
+ data-testid="cron-log-status"
44
+ className="flex shrink-0 items-center gap-2 text-sm text-muted-foreground"
45
+ >
46
+ Connection:
47
+ <Badge variant={connected ? 'success' : 'secondary'}>
48
+ {connected ? 'live' : 'connecting…'}
49
+ </Badge>
50
+ </div>
36
51
  </div>
37
52
 
38
- <h2>Tasks</h2>
39
- <table data-testid="cron-tasks" style={{ borderCollapse: 'collapse', width: '100%' }}>
40
- <thead>
41
- <tr>
42
- <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Name</th>
43
- <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Schedule</th>
44
- <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Last run</th>
45
- <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Next run</th>
46
- </tr>
47
- </thead>
48
- <tbody>
49
- {tasks.map((task) => (
50
- <tr key={task.name} data-testid="cron-task-row" data-task={task.name}>
51
- <td style={{ padding: 8 }}>{task.name}</td>
52
- <td style={{ padding: 8 }}>
53
- {task.schedule
54
- ? `${task.schedule} (${task.timezone ?? 'UTC'})`
55
- : `every ${task.intervalMinutes ?? '?'} min`}
56
- </td>
57
- <td style={{ padding: 8 }}>{task.lastRunAt ?? '—'}</td>
58
- <td style={{ padding: 8 }}>{task.nextRunAt ?? '—'}</td>
59
- </tr>
60
- ))}
61
- </tbody>
62
- </table>
53
+ {lastError && (
54
+ <p data-testid="cron-log-error" className="mt-2 text-sm text-destructive">
55
+ error: {lastError}
56
+ </p>
57
+ )}
63
58
 
64
- <h2 style={{ marginTop: 24 }}>History</h2>
65
- <div data-testid="cron-log-count">{sorted.length} entries</div>
59
+ <h2 className="mt-8 text-lg font-semibold text-foreground">Tasks</h2>
60
+ <div className="mt-3 overflow-x-auto rounded-xl border border-border bg-card/60">
61
+ <table data-testid="cron-tasks" className="w-full min-w-160">
62
+ <thead>
63
+ <tr className="border-b border-border">
64
+ <th className={TH}>Name</th>
65
+ <th className={TH}>Schedule</th>
66
+ <th className={TH}>Last run</th>
67
+ <th className={TH}>Next run</th>
68
+ </tr>
69
+ </thead>
70
+ <tbody className="divide-y divide-border/50">
71
+ {tasks.map((task) => (
72
+ <tr key={task.name} data-testid="cron-task-row" data-task={task.name}>
73
+ <td className={`${TD} font-medium text-foreground`}>{task.name}</td>
74
+ <td className={`${TD} text-foreground`}>
75
+ {task.schedule
76
+ ? `${task.schedule} (${task.timezone ?? 'UTC'})`
77
+ : `every ${task.intervalMinutes ?? '?'} min`}
78
+ </td>
79
+ <td className={`${TD} font-mono text-muted-foreground`}>{task.lastRunAt ?? '—'}</td>
80
+ <td className={`${TD} font-mono text-muted-foreground`}>{task.nextRunAt ?? '—'}</td>
81
+ </tr>
82
+ ))}
83
+ </tbody>
84
+ </table>
85
+ </div>
66
86
 
67
- <table style={{ marginTop: 16, borderCollapse: 'collapse', width: '100%' }}>
68
- <thead>
69
- <tr>
70
- <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Task</th>
71
- <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Started (UTC)</th>
72
- <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Duration</th>
73
- <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Outcome</th>
74
- </tr>
75
- </thead>
76
- <tbody data-testid="cron-log-rows">
77
- {sorted.map((entry, idx) => (
78
- <tr
79
- key={`${entry.taskName}-${entry.startedAt}-${idx}`}
80
- data-testid="cron-log-row"
81
- data-task={entry.taskName}
82
- data-success={entry.success ? '1' : '0'}
83
- >
84
- <td style={{ padding: 8 }}>{entry.taskName}</td>
85
- <td style={{ padding: 8 }}>{entry.startedAt}</td>
86
- <td style={{ padding: 8 }}>{entry.durationMs} ms</td>
87
- <td style={{ padding: 8 }}>{entry.success ? 'ok' : `error: ${entry.error ?? '?'}`}</td>
87
+ <div className="mt-8 flex items-baseline justify-between gap-4">
88
+ <h2 className="text-lg font-semibold text-foreground">History</h2>
89
+ <span data-testid="cron-log-count" className="text-sm text-muted-foreground">
90
+ {sorted.length} entries
91
+ </span>
92
+ </div>
93
+ <div className="mt-3 overflow-x-auto rounded-xl border border-border bg-card/60">
94
+ <table className="w-full min-w-160">
95
+ <thead>
96
+ <tr className="border-b border-border">
97
+ <th className={TH}>Task</th>
98
+ <th className={TH}>Started (UTC)</th>
99
+ <th className={TH}>Duration</th>
100
+ <th className={TH}>Outcome</th>
88
101
  </tr>
89
- ))}
90
- </tbody>
91
- </table>
102
+ </thead>
103
+ <tbody data-testid="cron-log-rows" className="divide-y divide-border/50">
104
+ {sorted.map((entry, idx) => (
105
+ <tr
106
+ key={`${entry.taskName}-${entry.startedAt}-${idx}`}
107
+ data-testid="cron-log-row"
108
+ data-task={entry.taskName}
109
+ data-success={entry.success ? '1' : '0'}
110
+ >
111
+ <td className={`${TD} font-medium text-foreground`}>{entry.taskName}</td>
112
+ <td className={`${TD} font-mono text-muted-foreground`}>{entry.startedAt}</td>
113
+ <td className={`${TD} text-foreground`}>{entry.durationMs} ms</td>
114
+ <td className={TD}>
115
+ {entry.success ? (
116
+ <Badge variant="success" size="sm">
117
+ ok
118
+ </Badge>
119
+ ) : (
120
+ <span className="text-destructive">error: {entry.error ?? '?'}</span>
121
+ )}
122
+ </td>
123
+ </tr>
124
+ ))}
125
+ </tbody>
126
+ </table>
127
+ </div>
92
128
 
93
129
  {sorted.length === 0 && connected && (
94
- <p data-testid="cron-log-empty" style={{ marginTop: 24, opacity: 0.6 }}>
95
- No cron ticks recorded yet. The first one should appear within ~90s of deploy.
96
- </p>
130
+ <EmptyState
131
+ data-testid="cron-log-empty"
132
+ title="No cron ticks recorded yet"
133
+ description="The first one should appear within ~90s of deploy."
134
+ />
97
135
  )}
98
136
  </div>
99
137
  )
@@ -38,7 +38,8 @@
38
38
  "patterns": [
39
39
  "useMimeTypeDetection() → { detectMimeType, getFileCategory, getExtension, canPreview }",
40
40
  "useFilePreview({ url, fileName, mimeType }) → { category, canPreview, textContent, csvData, codeEditorKit, xlsxData, ... }",
41
- "useR2Files() → { upload, downloadFile, readFile, deleteFile, list, getUrl, isUploading }",
41
+ "useR2Files() → { upload, downloadFile, readFile, deleteFile, list, listPage, getUrl, isUploading }",
42
+ "list() throws on failure (render the error); listPage() returns { files, cursor, truncated } for pagination; entries carry relativeKey",
42
43
  "File metadata stored in collection, binary stored in R2",
43
44
  "Dynamic imports: PDF.js for PDF, CodeMirror for code, PapaParse for CSV, docx-preview for DOCX, ExcelJS for XLSX"
44
45
  ]
@@ -18,7 +18,8 @@
18
18
  "instructions": [],
19
19
  "patterns": [
20
20
  "useR2Files() for upload, list, download, delete",
21
+ "list() throws on failure (page renders the error with a retry); listPage() returns { files, cursor, truncated }; entries carry relativeKey",
21
22
  "Drag-and-drop file upload zone",
22
- "Inline image preview with getUrl()"
23
+ "Authenticated inline image preview with readFile()"
23
24
  ]
24
25
  }