@flowdular/sandbox 0.2.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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +696 -0
  3. package/bin/flowdular-sandbox.mjs +394 -0
  4. package/bin/register-types.mjs +53 -0
  5. package/dist/client/assets/App-ClMOrpyg.js +11 -0
  6. package/dist/client/assets/PreviewHost-BHiHrfbQ.js +1 -0
  7. package/dist/client/assets/auto-buGEq6de.js +3 -0
  8. package/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  9. package/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  10. package/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  11. package/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  12. package/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  13. package/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  14. package/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  15. package/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  16. package/dist/client/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  17. package/dist/client/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  18. package/dist/client/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  19. package/dist/client/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  20. package/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  21. package/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  22. package/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  23. package/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  24. package/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  25. package/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  26. package/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  27. package/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  28. package/dist/client/assets/ibm-plex-sans-cyrillic-ext-wght-normal-d45eAU9y.woff2 +0 -0
  29. package/dist/client/assets/ibm-plex-sans-cyrillic-wght-normal-BAAhND-U.woff2 +0 -0
  30. package/dist/client/assets/ibm-plex-sans-greek-wght-normal-CmyJS8uq.woff2 +0 -0
  31. package/dist/client/assets/ibm-plex-sans-latin-ext-wght-normal-CIII54If.woff2 +0 -0
  32. package/dist/client/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
  33. package/dist/client/assets/ibm-plex-sans-vietnamese-wght-normal-Dg1JeJN0.woff2 +0 -0
  34. package/dist/client/assets/index-CvwhlQ87.js +4 -0
  35. package/dist/client/assets/styles-BPWaGf55.js +3 -0
  36. package/dist/client/assets/styles-Dx1_KOag.css +1 -0
  37. package/dist/client/favicon.svg +1 -0
  38. package/dist/server/assets/auto-CVGNYyb3.js +12785 -0
  39. package/dist/server/assets/chunk-DDJLRBDX-3OUNo51g.js +4125 -0
  40. package/dist/server/assets/nodefs-DVSI7kG0.js +24 -0
  41. package/dist/server/assets/opfs-ahp-D56iokt1.js +388 -0
  42. package/dist/server/assets/token-Bvaq7Anz.js +55 -0
  43. package/dist/server/assets/token-util-BqSvdIPQ.js +3 -0
  44. package/dist/server/assets/token-util-CktFFT4z.js +494 -0
  45. package/dist/server/entry.js +91671 -0
  46. package/dist/server/favicon.svg +1 -0
  47. package/dist/server/index.html +161 -0
  48. package/index.html +159 -0
  49. package/internal/coding-agent/package.json +44 -0
  50. package/internal/coding-agent/src/drivers/byok.ts +322 -0
  51. package/internal/coding-agent/src/drivers/claude-code.ts +290 -0
  52. package/internal/coding-agent/src/drivers/codex.ts +281 -0
  53. package/internal/coding-agent/src/index.ts +56 -0
  54. package/internal/coding-agent/src/registry.ts +119 -0
  55. package/internal/coding-agent/src/roles/contract.ts +60 -0
  56. package/internal/coding-agent/src/roles/defaults.ts +134 -0
  57. package/internal/coding-agent/src/roles/handoff.ts +41 -0
  58. package/internal/coding-agent/src/roles/registry.ts +156 -0
  59. package/internal/coding-agent/src/roles/skills.ts +72 -0
  60. package/internal/coding-agent/src/roles/sync.ts +88 -0
  61. package/internal/coding-agent/src/types.ts +102 -0
  62. package/internal/coding-agent/src/workspace.ts +275 -0
  63. package/octane.config.ts +36 -0
  64. package/package.json +68 -0
  65. package/public/favicon.svg +1 -0
  66. package/public/og.png +0 -0
  67. package/src/App.tsrx +924 -0
  68. package/src/client/ApprovalHandoff.tsrx +39 -0
  69. package/src/client/ChatPane.tsrx +694 -0
  70. package/src/client/ConnectView.tsrx +86 -0
  71. package/src/client/DiffPane.tsrx +150 -0
  72. package/src/client/EjectModal.tsrx +571 -0
  73. package/src/client/GateResults.tsrx +60 -0
  74. package/src/client/GitHubSettingsModal.tsrx +219 -0
  75. package/src/client/NewSessionForm.tsrx +214 -0
  76. package/src/client/SessionBar.tsrx +151 -0
  77. package/src/client/SessionPicker.tsrx +472 -0
  78. package/src/client/SpecEditor.tsrx +109 -0
  79. package/src/client/SpecReviewCard.tsrx +321 -0
  80. package/src/client/WorkCard.tsrx +128 -0
  81. package/src/client/WorkbenchModal.tsrx +161 -0
  82. package/src/client/WorkspaceMenu.tsrx +100 -0
  83. package/src/client/api.ts +797 -0
  84. package/src/client/attachments.ts +73 -0
  85. package/src/client/dashboard.ts +46 -0
  86. package/src/client/github-settings.ts +48 -0
  87. package/src/client/highlight.ts +105 -0
  88. package/src/client/i18n.ts +32 -0
  89. package/src/client/locales/en.json +466 -0
  90. package/src/client/locales/pl.json +466 -0
  91. package/src/client/session-labels.ts +77 -0
  92. package/src/client/state.ts +108 -0
  93. package/src/index.ts +10 -0
  94. package/src/preview/PreviewHost.tsrx +373 -0
  95. package/src/preview/load-module.ts +16 -0
  96. package/src/server/attachments.ts +308 -0
  97. package/src/server/auto-review.ts +181 -0
  98. package/src/server/checkpoints.ts +90 -0
  99. package/src/server/config.ts +460 -0
  100. package/src/server/dashboard.ts +245 -0
  101. package/src/server/delivery/configuration.ts +193 -0
  102. package/src/server/delivery/git-pr.ts +1473 -0
  103. package/src/server/delivery/index.ts +83 -0
  104. package/src/server/delivery/local.ts +151 -0
  105. package/src/server/delivery/official-modules.ts +516 -0
  106. package/src/server/delivery/plan.ts +218 -0
  107. package/src/server/delivery/policies.ts +239 -0
  108. package/src/server/delivery/record.ts +41 -0
  109. package/src/server/delivery/steps.ts +464 -0
  110. package/src/server/delivery/types.ts +158 -0
  111. package/src/server/dependencies.ts +88 -0
  112. package/src/server/diff.ts +230 -0
  113. package/src/server/gates.ts +388 -0
  114. package/src/server/index.ts +139 -0
  115. package/src/server/path-guard.ts +282 -0
  116. package/src/server/planning.ts +749 -0
  117. package/src/server/platform-client.ts +189 -0
  118. package/src/server/preview-database-host.ts +340 -0
  119. package/src/server/preview-database-protocol.ts +163 -0
  120. package/src/server/preview-database-proxy.ts +356 -0
  121. package/src/server/preview-database.ts +109 -0
  122. package/src/server/preview-ipc.ts +15 -0
  123. package/src/server/preview-modules.ts +83 -0
  124. package/src/server/preview-revision.ts +44 -0
  125. package/src/server/preview-runtime.ts +467 -0
  126. package/src/server/preview-worker-manager.ts +477 -0
  127. package/src/server/preview-worker.ts +168 -0
  128. package/src/server/reference.ts +251 -0
  129. package/src/server/routes.ts +2258 -0
  130. package/src/server/runtime.ts +188 -0
  131. package/src/server/session-owner.ts +23 -0
  132. package/src/server/sessions.ts +849 -0
  133. package/src/server/spec.ts +471 -0
  134. package/src/server/turn-lifecycle.ts +56 -0
  135. package/src/server/turns.ts +993 -0
  136. package/src/server/workspace-install.ts +266 -0
  137. package/src/server/workspace-root.ts +73 -0
  138. package/src/styles.css +1573 -0
  139. package/vite.config.ts +134 -0
@@ -0,0 +1,472 @@
1
+ import { useState } from 'octane';
2
+ import {
3
+ Alert,
4
+ Button,
5
+ ConfirmDialog,
6
+ Drawer,
7
+ Filters,
8
+ FormField,
9
+ Icon,
10
+ Kpi,
11
+ PageHeader,
12
+ SearchField,
13
+ TableCard,
14
+ Tag,
15
+ type TableColumn,
16
+ } from '@flowdular/sdk/ui';
17
+ import type { SandboxSession } from '../server/sessions.ts';
18
+ import type {
19
+ DashboardRow,
20
+ SandboxDashboard,
21
+ WorkStatus,
22
+ } from '../server/dashboard.ts';
23
+ import type { DriverSummary, RoleSummary } from './api.ts';
24
+ import { NewSessionForm } from './NewSessionForm.tsrx';
25
+ import { filterWork, formatCost, formatTokens } from './dashboard.ts';
26
+ import { activeLocale, t, useTranslation } from './i18n.ts';
27
+
28
+ export interface SessionPickerProps {
29
+ readonly onDeliverySettings: () => void;
30
+ readonly sessions: readonly SandboxSession[];
31
+ readonly dashboard?: SandboxDashboard | undefined;
32
+ readonly onRefresh: () => void;
33
+ readonly onReject: (id: string) => void;
34
+ /* Ids of the sessions with a turn in flight; those cannot be deleted. */
35
+ readonly running: readonly string[];
36
+ readonly roles: readonly RoleSummary[];
37
+ readonly drivers: readonly DriverSummary[];
38
+ readonly driver: string;
39
+ readonly busy: boolean;
40
+ /* The card whose action menu is open, and the one waiting for a delete
41
+ confirmation; empty when none. */
42
+ readonly openMenu: string;
43
+ readonly confirmDelete: string;
44
+ readonly showArchived: boolean;
45
+ readonly onDriver: (driver: string) => void;
46
+ readonly onOpen: (id: string) => void;
47
+ readonly onCreate: (brief: string, driver: string, attachments: readonly File[]) => void;
48
+ readonly onMenu: (id: string) => void;
49
+ readonly onShowArchived: (show: boolean) => void;
50
+ readonly onArchive: (id: string) => void;
51
+ readonly onRestore: (id: string) => void;
52
+ readonly onConfirmDelete: (id: string) => void;
53
+ readonly onDelete: (id: string) => void;
54
+ }
55
+
56
+ const statuses: readonly WorkStatus[] = [
57
+ 'draft',
58
+ 'working',
59
+ 'approval',
60
+ 'approved',
61
+ 'changes',
62
+ 'rejected',
63
+ 'attention',
64
+ 'review',
65
+ 'delivered',
66
+ 'archived',
67
+ ];
68
+
69
+ function tone(
70
+ status: WorkStatus,
71
+ ): 'neutral' | 'success' | 'warning' | 'danger' | 'info' {
72
+ if (status === 'rejected' || status === 'attention') return 'danger';
73
+ if (status === 'approved' || status === 'delivered') return 'success';
74
+ if (status === 'approval' || status === 'changes') return 'warning';
75
+ return status === 'working' || status === 'review' ? 'info' : 'neutral';
76
+ }
77
+
78
+ function columns(): readonly TableColumn<DashboardRow>[] {
79
+ return [
80
+ {
81
+ key: 'work',
82
+ header: t('sandbox.dashboard.work'),
83
+ width: '32%',
84
+ cell: (row) => <span class="ui-cell">
85
+ <b>{row.session.title}</b>
86
+ <small>
87
+ {t(
88
+ row.session.kind === 'new-module'
89
+ ? 'sandbox.sessions.kind.new'
90
+ : 'sandbox.sessions.kind.change',
91
+ )}
92
+ ·
93
+ {t('sandbox.dashboard.modules', {
94
+ count: row.session.modules.length,
95
+ })}
96
+ </small>
97
+ {row.personal
98
+ ? null
99
+ : <small>{t('sandbox.dashboard.legacyRow')}</small>}
100
+ </span>,
101
+ },
102
+ {
103
+ key: 'status',
104
+ header: t('sandbox.dashboard.status'),
105
+ width: '20%',
106
+ cell: (row) =>
107
+ <Tag tone={tone(row.status)} dot>{t(
108
+ 'sandbox.dashboard.status.' + row.status,
109
+ )}</Tag>,
110
+ },
111
+ {
112
+ key: 'tokens',
113
+ header: t('sandbox.dashboard.table.tokens'),
114
+ width: '13%',
115
+ numeric: true,
116
+ cell: (row) => formatTokens(row.usage, activeLocale()),
117
+ },
118
+ {
119
+ key: 'cost',
120
+ header: t('sandbox.dashboard.table.cost'),
121
+ width: '13%',
122
+ numeric: true,
123
+ cell: (row) => formatCost(row.usage, activeLocale()),
124
+ },
125
+ {
126
+ key: 'updated',
127
+ header: t('sandbox.dashboard.table.updated'),
128
+ width: '12%',
129
+ cell: (row) => new Intl.DateTimeFormat(activeLocale(), {
130
+ day: 'numeric',
131
+ month: 'short',
132
+ }).format(row.session.updatedAt),
133
+ },
134
+ ];
135
+ }
136
+
137
+ export function SessionPicker(props: SessionPickerProps) @{
138
+ const { t } = useTranslation();
139
+ const [creating, setCreating] = useState(false);
140
+ const [query, setQuery] = useState('');
141
+ const [status, setStatus] = useState('');
142
+ const [filtersOpen, setFiltersOpen] = useState(false);
143
+ const [detailId, setDetailId] = useState('');
144
+ const [confirmation, setConfirmation] = useState<'reject' | 'delete' | ''>(
145
+ '',
146
+ );
147
+ const dashboard = props.dashboard;
148
+ const rows = filterWork(
149
+ dashboard?.rows ?? [],
150
+ query,
151
+ status,
152
+ props.showArchived,
153
+ activeLocale(),
154
+ );
155
+ const detail =
156
+ dashboard?.rows.find((row) => row.session.id === detailId) ?? null;
157
+ const counts = dashboard?.counts;
158
+ const usage = dashboard?.usage;
159
+ const locked =
160
+ props.busy || detail !== null && props.running.includes(detail.session.id);
161
+ const number = (value: number) =>
162
+ new Intl.NumberFormat(activeLocale()).format(value);
163
+ const closeDetail = () => {
164
+ setDetailId('');
165
+ setConfirmation('');
166
+ };
167
+
168
+ <div class="sessions">
169
+ <PageHeader
170
+ eyebrow={t('sandbox.dashboard.eyebrow')}
171
+ title={t('sandbox.dashboard.title')}
172
+ description={t('sandbox.dashboard.description')}
173
+ >
174
+ <div class="sessions__actions">
175
+ <Button size="sm" disabled={props.busy} onClick={props.onRefresh}>
176
+ <Icon name="refresh" size={14} />
177
+ {t('sandbox.dashboard.refresh')}
178
+ </Button>
179
+ <Button size="sm" onClick={props.onDeliverySettings}>
180
+ <Icon name="settings" size={14} />
181
+ {t('sandbox.github.dashboardAction')}
182
+ </Button>
183
+ <Button
184
+ size="sm"
185
+ variant="primary"
186
+ disabled={props.busy}
187
+ onClick={() => setCreating(true)}
188
+ >
189
+ <Icon name="plus" size={14} />
190
+ {t('sandbox.dashboard.new')}
191
+ </Button>
192
+ </div>
193
+ </PageHeader>
194
+ <div class="ui-kpi-grid">
195
+ <Kpi
196
+ label={t('sandbox.dashboard.inProgress')}
197
+ value={counts
198
+ ? number(
199
+ counts.draft + counts.working + counts.changes + counts.attention,
200
+ )
201
+ : '…'}
202
+ note={t('sandbox.dashboard.inProgressNote')}
203
+ />
204
+ <Kpi
205
+ label={t('sandbox.dashboard.status.approval')}
206
+ value={counts ? number(counts.approval) : '…'}
207
+ note={t('sandbox.dashboard.approvalNote')}
208
+ />
209
+ <Kpi
210
+ label={t('sandbox.dashboard.status.approved')}
211
+ value={counts
212
+ ? number(counts.approved + counts.review + counts.delivered)
213
+ : '…'}
214
+ note={t('sandbox.dashboard.acceptedNote', {
215
+ delivered: counts?.delivered ?? 0,
216
+ review: counts?.review ?? 0,
217
+ })}
218
+ />
219
+ <Kpi
220
+ label={t('sandbox.dashboard.status.rejected')}
221
+ value={counts ? number(counts.rejected) : '…'}
222
+ note={t('sandbox.dashboard.rejectedNote')}
223
+ />
224
+ </div>
225
+ <div class="ui-grid-2">
226
+ <Kpi
227
+ label={t('sandbox.dashboard.tokens')}
228
+ value={usage ? formatTokens(usage, activeLocale()) : '…'}
229
+ note={t('sandbox.dashboard.tokensNote', {
230
+ input: number(usage?.inputTokens ?? 0),
231
+ output: number(usage?.outputTokens ?? 0),
232
+ })}
233
+ />
234
+ <Kpi
235
+ label={t('sandbox.dashboard.cost')}
236
+ value={usage ? formatCost(usage, activeLocale()) : '…'}
237
+ note={t('sandbox.dashboard.costNote')}
238
+ />
239
+ </div>
240
+ @if (usage && (usage.unpricedTurns > 0 || usage.missingUsage > 0)) {
241
+ <Alert tone="info">{t('sandbox.dashboard.incomplete', {
242
+ cost: usage.unpricedTurns,
243
+ tokens: usage.missingUsage,
244
+ })}</Alert>
245
+ }
246
+ @if (dashboard && dashboard.legacySessions > 0) {
247
+ <Alert tone="info">{t('sandbox.dashboard.legacyNote', {
248
+ count: dashboard.legacySessions,
249
+ })}</Alert>
250
+ }
251
+ <TableCard
252
+ title={t('sandbox.dashboard.sessions')}
253
+ count={number(rows.length)}
254
+ columns={columns()}
255
+ rows={rows}
256
+ rowKey={(row) => row.session.id}
257
+ status={dashboard ? 'idle' : 'loading'}
258
+ loadingLabel={t('sandbox.dashboard.loading')}
259
+ search={<SearchField
260
+ value={query}
261
+ onInput={setQuery}
262
+ label={t('sandbox.dashboard.search')}
263
+ placeholder={t('sandbox.dashboard.search')}
264
+ />}
265
+ filters={<Filters
266
+ open={filtersOpen}
267
+ onToggle={() => setFiltersOpen(!filtersOpen)}
268
+ activeCount={(status ? 1 : 0) + (props.showArchived ? 1 : 0)}
269
+ label={t('sandbox.dashboard.filters')}
270
+ >
271
+ <FormField label={t('sandbox.dashboard.status')}>
272
+ <select
273
+ class="ui-select"
274
+ value={status}
275
+ onChange={(event) => setStatus(event.currentTarget.value)}
276
+ >
277
+ <option value="">{t('sandbox.dashboard.all')}</option>
278
+ @for (const item of statuses; key item) {
279
+ <option value={item}>{t(
280
+ 'sandbox.dashboard.status.' + item,
281
+ )}</option>
282
+ }
283
+ </select>
284
+ </FormField>
285
+ <label class="ui-checkbox">
286
+ <input
287
+ type="checkbox"
288
+ checked={props.showArchived}
289
+ onChange={(event) => props.onShowArchived(
290
+ event.currentTarget.checked,
291
+ )}
292
+ />
293
+ <span>{t('sandbox.sessions.archived.show')}</span>
294
+ </label>
295
+ </Filters>}
296
+ filtered={Boolean(query.trim() || status || props.showArchived)}
297
+ empty={{
298
+ icon: 'flask',
299
+ title: t('sandbox.dashboard.empty'),
300
+ hint: t('sandbox.dashboard.emptyHint'),
301
+ }}
302
+ emptyFiltered={{
303
+ icon: 'search',
304
+ title: t('sandbox.dashboard.noMatch'),
305
+ hint: t('sandbox.dashboard.noMatchHint'),
306
+ }}
307
+ actionsWidth="240px"
308
+ actionsLabel={t('sandbox.sessions.actions')}
309
+ actions={(row) => [
310
+ {
311
+ id: 'open',
312
+ label: t('sandbox.dashboard.open'),
313
+ icon: 'external',
314
+ onSelect: () => props.onOpen(row.session.id),
315
+ },
316
+ {
317
+ id: 'details',
318
+ label: t('sandbox.dashboard.details'),
319
+ icon: 'activity',
320
+ onSelect: () => setDetailId(row.session.id),
321
+ },
322
+ ]}
323
+ note={t('sandbox.dashboard.scopeNote', {
324
+ count: dashboard?.deletedSessions ?? 0,
325
+ })}
326
+ />
327
+ <Drawer
328
+ open={creating}
329
+ title={t('sandbox.dashboard.new')}
330
+ subtitle={t('sandbox.dashboard.newHint')}
331
+ width="lg"
332
+ onClose={() => {
333
+ if (!props.busy) setCreating(false);
334
+ }}
335
+ >
336
+ <NewSessionForm
337
+ drivers={props.drivers}
338
+ driver={props.driver}
339
+ busy={props.busy}
340
+ onDriver={props.onDriver}
341
+ onCreate={props.onCreate}
342
+ />
343
+ </Drawer>
344
+ <Drawer
345
+ open={detail !== null}
346
+ title={detail?.session.title ?? ''}
347
+ subtitle={t('sandbox.dashboard.detailHint')}
348
+ width="lg"
349
+ onClose={closeDetail}
350
+ >
351
+ <div class="ui-drawer__body">
352
+ @if (detail) {
353
+ <div class="ui-form">
354
+ <div class="ui-tag-cloud">
355
+ <Tag tone={tone(detail.status)} dot>{t(
356
+ 'sandbox.dashboard.status.' + detail.status,
357
+ )}</Tag>
358
+ </div>
359
+ <p class="ui-help">{detail.session.brief}</p>
360
+ <div class="ui-grid-2">
361
+ <Kpi
362
+ label={t('sandbox.dashboard.tokens')}
363
+ value={formatTokens(detail.usage, activeLocale())}
364
+ note={t('sandbox.dashboard.tokensHelp')}
365
+ />
366
+ <Kpi
367
+ label={t('sandbox.dashboard.cost')}
368
+ value={formatCost(detail.usage, activeLocale())}
369
+ note={t('sandbox.dashboard.costNote')}
370
+ />
371
+ </div>
372
+ <p class="ui-help">{t('sandbox.dashboard.moduleApprovals', {
373
+ approved: detail.approvedModules,
374
+ total: detail.session.modules.length,
375
+ })}</p>
376
+ <TableCard
377
+ title={t('sandbox.dashboard.phases')}
378
+ rows={detail.phases}
379
+ rowKey={(phase) => phase.role}
380
+ columns={[
381
+ {
382
+ key: 'phase',
383
+ header: t('sandbox.dashboard.phase'),
384
+ width: '50%',
385
+ cell: (phase) => t('sandbox.dashboard.role.' + phase.role) ===
386
+ 'sandbox.dashboard.role.' + phase.role
387
+ ? t('sandbox.dashboard.role.unknown')
388
+ : t('sandbox.dashboard.role.' + phase.role),
389
+ },
390
+ {
391
+ key: 'tokens',
392
+ header: t('sandbox.dashboard.table.tokens'),
393
+ width: '25%',
394
+ numeric: true,
395
+ cell: (phase) => formatTokens(phase.usage, activeLocale()),
396
+ },
397
+ {
398
+ key: 'cost',
399
+ header: t('sandbox.dashboard.table.cost'),
400
+ width: '25%',
401
+ numeric: true,
402
+ cell: (phase) => formatCost(phase.usage, activeLocale()),
403
+ },
404
+ ]}
405
+ empty={{
406
+ icon: 'activity',
407
+ title: t('sandbox.dashboard.noUsage'),
408
+ hint: t('sandbox.dashboard.noUsageHint'),
409
+ }}
410
+ />
411
+ <div class="ui-form__actions">
412
+ @if (detail.session.archivedAt) {
413
+ <Button
414
+ disabled={locked}
415
+ onClick={() => props.onRestore(detail.session.id)}
416
+ >{t('sandbox.common.restore')}</Button>
417
+ } @else {
418
+ <Button
419
+ disabled={locked}
420
+ onClick={() => props.onArchive(detail.session.id)}
421
+ >{t('sandbox.sessions.archive')}</Button>
422
+ }
423
+ @if (!detail.session.ejectedAt && !detail.session.rejectedAt) {
424
+ <Button
425
+ disabled={locked}
426
+ onClick={() => setConfirmation('reject')}
427
+ >{t('sandbox.dashboard.reject')}</Button>
428
+ }
429
+ </div>
430
+ <div class="ui-form__section ui-form__section--danger">
431
+ <div class="ui-form__section-head">
432
+ <b>{t('sandbox.common.delete')}</b>
433
+ <small>{t('sandbox.dashboard.deleteHint')}</small>
434
+ </div>
435
+ <Button
436
+ variant="danger"
437
+ disabled={locked}
438
+ onClick={() => setConfirmation('delete')}
439
+ >{t('sandbox.common.delete')}</Button>
440
+ </div>
441
+ </div>
442
+ }
443
+ </div>
444
+ </Drawer>
445
+ <ConfirmDialog
446
+ open={confirmation !== '' && detail !== null}
447
+ title={t(
448
+ confirmation === 'reject'
449
+ ? 'sandbox.dashboard.rejectTitle'
450
+ : 'sandbox.common.delete',
451
+ )}
452
+ confirmLabel={t(
453
+ confirmation === 'reject'
454
+ ? 'sandbox.dashboard.reject'
455
+ : 'sandbox.common.delete',
456
+ )}
457
+ cancelLabel={t('sandbox.common.cancel')}
458
+ busy={props.busy}
459
+ onCancel={() => setConfirmation('')}
460
+ onConfirm={() => {
461
+ if (!detail) return;
462
+ if (confirmation === 'reject') props.onReject(detail.session.id);
463
+ else props.onDelete(detail.session.id);
464
+ closeDetail();
465
+ }}
466
+ >{t(
467
+ confirmation === 'reject'
468
+ ? 'sandbox.dashboard.rejectHint'
469
+ : 'sandbox.dashboard.deleteHint',
470
+ )}</ConfirmDialog>
471
+ </div>
472
+ }
@@ -0,0 +1,109 @@
1
+ import { useEffect, useRef, useState } from 'octane';
2
+ import { Alert, Button, Icon } from '@flowdular/sdk/ui';
3
+ import { loadModuleSpec, saveModuleSpec } from './api.ts';
4
+ import { useTranslation } from './i18n.ts';
5
+
6
+ export interface SpecEditorProps {
7
+ readonly sessionId: string;
8
+ readonly module: string;
9
+ readonly onSaved: () => void;
10
+ }
11
+
12
+ // Keyed by session and module in WorkbenchModal. A document cannot be saved
13
+ // until this editor has loaded it; stale responses never populate another editor.
14
+ export function SpecEditor(props: SpecEditorProps) @{
15
+ const { t } = useTranslation();
16
+ const [text, setText] = useState('');
17
+ const [loaded, setLoaded] = useState(false);
18
+ const [saving, setSaving] = useState(false);
19
+ const [error, setError] = useState('');
20
+ const [saved, setSaved] = useState(false);
21
+ const [attempt, setAttempt] = useState(0);
22
+ const mounted = useRef(true);
23
+ useEffect(() => {
24
+ mounted.current = true;
25
+ let cancelled = false;
26
+ setLoaded(false);
27
+ setError('');
28
+ void loadModuleSpec(props.sessionId, props.module).then((document) => {
29
+ if (cancelled) return;
30
+ if (document.module !== props.module) throw new Error(t(
31
+ 'sandbox.workbench.spec.readFailed',
32
+ ));
33
+ setText(document.text);
34
+ setLoaded(true);
35
+ }).catch((failure: unknown) => {
36
+ if (!cancelled) setError(
37
+ failure instanceof Error
38
+ ? failure.message
39
+ : t('sandbox.workbench.spec.readFailed'),
40
+ );
41
+ });
42
+ return () => {
43
+ cancelled = true;
44
+ mounted.current = false;
45
+ };
46
+ }, [attempt]);
47
+ const save = async () => {
48
+ if (!loaded || saving || !text.trim()) return;
49
+ setSaving(true);
50
+ setError('');
51
+ setSaved(false);
52
+ try {
53
+ await saveModuleSpec(props.sessionId, props.module, text);
54
+ if (!mounted.current) return;
55
+ setSaved(true);
56
+ props.onSaved();
57
+ } catch (failure) {
58
+ if (mounted.current) setError(
59
+ failure instanceof Error
60
+ ? failure.message
61
+ : t('sandbox.workbench.spec.saveFailed'),
62
+ );
63
+ } finally {
64
+ if (mounted.current) setSaving(false);
65
+ }
66
+ };
67
+ <div class="spec-editor">
68
+ <div class="spec-editor__bar">
69
+ <span class="ui-mono">
70
+ {'modules/' + props.module + '/spec/module.yaml'}
71
+ </span>
72
+ <span class="sandbox__spacer"></span>
73
+ <Button
74
+ size="sm"
75
+ variant="primary"
76
+ disabled={!loaded || saving || !text.trim()}
77
+ onClick={() => void save()}
78
+ >
79
+ <Icon name="check" size={14} />
80
+ {saving ? t('sandbox.common.saving') : t('sandbox.common.save')}
81
+ </Button>
82
+ </div>
83
+ @if (error) {
84
+ <Alert>{error}</Alert>
85
+ } @else if (saved) {
86
+ <Alert tone="info">{t('sandbox.workbench.spec.saved')}</Alert>
87
+ }
88
+ @if (!loaded) {
89
+ @if (error) {
90
+ <Button onClick={() => setAttempt(attempt + 1)}>{t(
91
+ 'sandbox.dashboard.refresh',
92
+ )}</Button>
93
+ } @else {
94
+ <p class="ui-help">{t('sandbox.workbench.spec.loading')}</p>
95
+ }
96
+ }
97
+ <textarea
98
+ class="ui-textarea spec-editor__text"
99
+ aria-label={t('sandbox.workbench.specification')}
100
+ spellcheck={false}
101
+ disabled={!loaded || saving}
102
+ value={text}
103
+ onInput={(event) => {
104
+ setText(event.currentTarget.value);
105
+ setSaved(false);
106
+ }}
107
+ ></textarea>
108
+ </div>
109
+ }