@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,219 @@
1
+ import { useState } from 'octane';
2
+ import { Alert, Button, Drawer, FormField } from '@flowdular/sdk/ui';
3
+ import { githubSettingsInput } from './github-settings.ts';
4
+ import type { SafeSandboxConfiguration } from '../server/config.ts';
5
+ import { useTranslation } from './i18n.ts';
6
+
7
+ export interface GitHubSettingsInput {
8
+ readonly githubEnabled: boolean;
9
+ readonly githubOverridesProject: boolean;
10
+ readonly githubRemote: string;
11
+ readonly githubRepository: string;
12
+ readonly githubBaseBranch: string;
13
+ readonly githubBranchPrefix: string;
14
+ readonly githubMode: 'auto' | 'direct' | 'fork';
15
+ readonly githubForkOwner: string;
16
+ readonly githubReviewers: readonly string[];
17
+ readonly githubToken?: string;
18
+ readonly githubClearToken?: boolean;
19
+ }
20
+
21
+ export interface GitHubSettingsModalProps {
22
+ readonly configuration: SafeSandboxConfiguration;
23
+ readonly busy: boolean;
24
+ readonly error: string;
25
+ readonly onSave: (input: GitHubSettingsInput) => void;
26
+ readonly onClose: () => void;
27
+ }
28
+
29
+ export function GitHubSettingsModal(props: GitHubSettingsModalProps) @{
30
+ const { t } = useTranslation();
31
+ const github = props.configuration.github;
32
+ const [enabled, setEnabled] = useState(github.enabled);
33
+ const [custom, setCustom] = useState(github.overridesProject);
34
+ const submit = (event: SubmitEvent) => {
35
+ event.preventDefault();
36
+ props.onSave(
37
+ githubSettingsInput(
38
+ new FormData(event.currentTarget as HTMLFormElement),
39
+ github,
40
+ ),
41
+ );
42
+ };
43
+
44
+ <Drawer
45
+ open={true}
46
+ title={t('sandbox.github.title')}
47
+ subtitle={t('sandbox.github.description')}
48
+ width="lg"
49
+ onClose={() => {
50
+ if (!props.busy) props.onClose();
51
+ }}
52
+ >
53
+ <form class="ui-drawer__form" onSubmit={submit}>
54
+ <div class="ui-drawer__body">
55
+ <div class="ui-form">
56
+ <label class="ui-checkbox">
57
+ <input
58
+ type="checkbox"
59
+ name="githubEnabled"
60
+ checked={enabled}
61
+ onChange={(event) => setEnabled(event.currentTarget.checked)}
62
+ />
63
+ <span>{t('sandbox.github.offer')}</span>
64
+ </label>
65
+ <Alert tone="info">{t('sandbox.github.safety')}</Alert>
66
+ <label class="ui-checkbox">
67
+ <input
68
+ type="checkbox"
69
+ name="githubOverridesProject"
70
+ checked={custom}
71
+ onChange={(event) => setCustom(event.currentTarget.checked)}
72
+ />
73
+ <span>{t('sandbox.github.overrideProject')}</span>
74
+ </label>
75
+ @if (!custom) {
76
+ <p class="ui-help">{t('sandbox.github.projectHelp')}</p>
77
+ } @else {
78
+ <div class="ui-form__section">
79
+ <FormField
80
+ label={t('sandbox.github.repository.label')}
81
+ help={t('sandbox.github.repository.help')}
82
+ >
83
+ <input
84
+ class="ui-input ui-mono"
85
+ name="githubRepository"
86
+ defaultValue={github.repository ?? ''}
87
+ placeholder={t('sandbox.github.repository.placeholder')}
88
+ maxlength={200}
89
+ />
90
+ </FormField>
91
+ <FormField
92
+ label={t('sandbox.github.mode.label')}
93
+ help={t('sandbox.github.mode.help')}
94
+ >
95
+ <select
96
+ class="ui-select"
97
+ name="githubMode"
98
+ defaultValue={github.mode}
99
+ >
100
+ <option value="auto">{t('sandbox.github.mode.auto')}</option>
101
+ <option value="direct">{t(
102
+ 'sandbox.github.mode.direct',
103
+ )}</option>
104
+ <option value="fork">{t('sandbox.github.mode.fork')}</option>
105
+ </select>
106
+ </FormField>
107
+ <FormField
108
+ label={t('sandbox.github.reviewers.label')}
109
+ help={t('sandbox.github.reviewers.help')}
110
+ >
111
+ <input
112
+ class="ui-input ui-mono"
113
+ name="githubReviewers"
114
+ defaultValue={github.reviewers.join(', ')}
115
+ maxlength={1000}
116
+ />
117
+ </FormField>
118
+ <details>
119
+ <summary class="ui-label">{t(
120
+ 'sandbox.github.advanced',
121
+ )}</summary>
122
+ <div class="ui-form">
123
+ <div class="ui-form__row">
124
+ <FormField
125
+ label={t('sandbox.github.remote.label')}
126
+ help={t('sandbox.github.remote.help')}
127
+ >
128
+ <input
129
+ class="ui-input ui-mono"
130
+ name="githubRemote"
131
+ defaultValue={github.remote}
132
+ maxlength={120}
133
+ />
134
+ </FormField>
135
+ <FormField
136
+ label={t('sandbox.github.base.label')}
137
+ help={t('sandbox.github.base.help')}
138
+ >
139
+ <input
140
+ class="ui-input ui-mono"
141
+ name="githubBaseBranch"
142
+ defaultValue={github.baseBranch}
143
+ maxlength={120}
144
+ />
145
+ </FormField>
146
+ </div>
147
+ <div class="ui-form__row">
148
+ <FormField label={t('sandbox.github.prefix.label')}>
149
+ <input
150
+ class="ui-input ui-mono"
151
+ name="githubBranchPrefix"
152
+ defaultValue={github.branchPrefix}
153
+ maxlength={120}
154
+ />
155
+ </FormField>
156
+ <FormField
157
+ label={t('sandbox.github.forkOwner.label')}
158
+ help={t('sandbox.github.forkOwner.help')}
159
+ >
160
+ <input
161
+ class="ui-input ui-mono"
162
+ name="githubForkOwner"
163
+ defaultValue={github.forkOwner ?? ''}
164
+ maxlength={120}
165
+ />
166
+ </FormField>
167
+ </div>
168
+ </div>
169
+ </details>
170
+ </div>
171
+ }
172
+ <div class="ui-form__section">
173
+ <div class="ui-form__section-head">
174
+ <b>{t('sandbox.github.access')}</b>
175
+ <small>{t('sandbox.github.accessHelp')}</small>
176
+ </div>
177
+ <FormField
178
+ label={t('sandbox.github.token.label')}
179
+ help={github.tokenFingerprint
180
+ ? t('sandbox.github.token.stored', {
181
+ fingerprint: github.tokenFingerprint,
182
+ })
183
+ : t('sandbox.github.token.help')}
184
+ >
185
+ <input
186
+ class="ui-input ui-mono"
187
+ name="githubToken"
188
+ type="password"
189
+ autocomplete="new-password"
190
+ placeholder={t('sandbox.github.token.placeholder')}
191
+ maxlength={16384}
192
+ />
193
+ </FormField>
194
+ @if (github.tokenFingerprint) {
195
+ <label class="ui-checkbox">
196
+ <input type="checkbox" name="githubClearToken" />
197
+ <span>{t('sandbox.github.token.remove')}</span>
198
+ </label>
199
+ }
200
+ </div>
201
+ @if (props.error) {
202
+ <Alert>{props.error}</Alert>
203
+ }
204
+ </div>
205
+ </div>
206
+ <div class="ui-drawer__foot">
207
+ <small>{t('sandbox.github.saveNote')}</small>
208
+ <div class="ui-form__actions">
209
+ <Button onClick={props.onClose} disabled={props.busy}>{t(
210
+ 'sandbox.common.cancel',
211
+ )}</Button>
212
+ <Button type="submit" variant="primary" disabled={props.busy}>
213
+ {props.busy ? t('sandbox.common.saving') : t('sandbox.github.save')}
214
+ </Button>
215
+ </div>
216
+ </div>
217
+ </form>
218
+ </Drawer>
219
+ }
@@ -0,0 +1,214 @@
1
+ import { useEffect, useRef, useState } from 'octane';
2
+ import { Button, Icon } from '@flowdular/sdk/ui';
3
+ import type { DriverSummary } from './api.ts';
4
+ import {
5
+ ATTACH_ACCEPT,
6
+ attachmentKind,
7
+ attachmentRefusal,
8
+ formatBytes,
9
+ } from './attachments.ts';
10
+ import { useTranslation } from './i18n.ts';
11
+
12
+ export interface NewSessionFormProps {
13
+ readonly drivers: readonly DriverSummary[];
14
+ readonly driver: string;
15
+ readonly busy: boolean;
16
+ readonly onDriver: (driver: string) => void;
17
+ readonly onCreate: (brief: string, driver: string, attachments: readonly File[]) => void;
18
+ }
19
+ /* A file the operator attached before the session exists. It is uploaded to
20
+ the new session id right after the session is created. */
21
+ interface PickedFile {
22
+ readonly key: string;
23
+ readonly file: File;
24
+ readonly kind: 'image' | 'file';
25
+ /* Object URL for an image thumbnail, empty otherwise. Revoked on removal
26
+ and when the screen goes away. */
27
+ readonly url: string;
28
+ }
29
+
30
+ export function NewSessionForm(props: NewSessionFormProps) @{
31
+ const { t } = useTranslation();
32
+ const offered = props.drivers.filter((driver) => driver.offered);
33
+ const selected =
34
+ offered.find((driver) => driver.id === props.driver) ?? offered[0] ?? null;
35
+ const attachInput = useRef<HTMLInputElement | null>(null);
36
+ const opened = useRef<string[]>([]);
37
+ const [picked, setPicked] = useState<readonly PickedFile[]>([]);
38
+ const [attachError, setAttachError] = useState('');
39
+
40
+ /* Thumbnails hold object URLs; nothing else releases them, so the screen
41
+ does it when it goes away. */
42
+ useEffect(
43
+ () => () => {
44
+ for (const url of opened.current) URL.revokeObjectURL(url);
45
+ },
46
+ [],
47
+ );
48
+
49
+ const addFiles = (chosen: readonly File[]) => {
50
+ const next = [...picked];
51
+ let refusal = '';
52
+ for (const file of chosen) {
53
+ const reason = attachmentRefusal(file, next.length);
54
+ if (reason) {
55
+ refusal = reason;
56
+ continue;
57
+ }
58
+ const kind = attachmentKind(file);
59
+ const url =
60
+ kind === 'image' ? URL.createObjectURL(file) : '';
61
+ if (url) opened.current.push(url);
62
+ next.push({
63
+ key: `${file.name}-${Date.now()}-${Math.random().toString(36).slice(
64
+ 2,
65
+ 8,
66
+ )}`,
67
+ file,
68
+ kind,
69
+ url,
70
+ });
71
+ }
72
+ setPicked(next);
73
+ setAttachError(refusal);
74
+ };
75
+
76
+ const removeFile = (key: string) => {
77
+ const found = picked.find((item) => item.key === key);
78
+ if (found?.url) URL.revokeObjectURL(found.url);
79
+ setPicked(picked.filter((item) => item.key !== key));
80
+ };
81
+
82
+ const onPaste = (event: ClipboardEvent) => {
83
+ const files = event.clipboardData?.files;
84
+ if (!files || files.length === 0) return;
85
+ const images = Array.from(files).filter(
86
+ (file) => file.type.startsWith('image/'),
87
+ );
88
+ if (images.length === 0) return;
89
+ event.preventDefault();
90
+ addFiles(images);
91
+ };
92
+
93
+ const onPick = (event: Event & { currentTarget: HTMLInputElement }) => {
94
+ const chosen = event.currentTarget.files;
95
+ if (chosen && chosen.length > 0) addFiles(Array.from(chosen));
96
+ event.currentTarget.value = '';
97
+ };
98
+
99
+ const submit = (event: SubmitEvent) => {
100
+ event.preventDefault();
101
+ const form = new FormData(event.currentTarget as HTMLFormElement);
102
+ const brief = form.get('brief');
103
+ if (typeof brief === 'string' && brief.trim().length > 7 && selected) {
104
+ props.onCreate(
105
+ brief.trim(),
106
+ selected.id,
107
+ picked.map((item) => item.file),
108
+ );
109
+ }
110
+ };
111
+
112
+ <form class="ui-drawer__form" onSubmit={submit}>
113
+ <div class="ui-drawer__body">
114
+ <div class="ui-form">
115
+ @if (picked.length > 0 || attachError) {
116
+ <div class="chat__attachments">
117
+ @for (const item of picked; key item.key) {
118
+ <div class="chat__attachment">
119
+ @if (item.kind === 'image') {
120
+ <img
121
+ class="chat__attachment-thumb"
122
+ src={item.url}
123
+ alt={item.file.name}
124
+ />
125
+ } @else {
126
+ <span
127
+ class="chat__attachment-thumb chat__attachment-thumb--file"
128
+ >
129
+ <Icon name="file-text" size={18} />
130
+ </span>
131
+ }
132
+ <span class="chat__attachment-info">
133
+ <span class="chat__attachment-name">{item.file.name}</span>
134
+ <span class="chat__attachment-size">{formatBytes(
135
+ item.file.size,
136
+ )}</span>
137
+ </span>
138
+ <button
139
+ class="chat__attachment-remove"
140
+ type="button"
141
+ aria-label={t('sandbox.attachments.remove', {
142
+ name: item.file.name,
143
+ })}
144
+ onClick={() => removeFile(item.key)}
145
+ >
146
+ <Icon name="x" size={12} />
147
+ </button>
148
+ </div>
149
+ }
150
+ @if (attachError) {
151
+ <span class="chat__attachment-error">{attachError}</span>
152
+ }
153
+ </div>
154
+ }
155
+ <textarea
156
+ class="ui-textarea"
157
+ aria-label={t('sandbox.sessions.brief.placeholder')}
158
+ minlength={8}
159
+ maxlength={20000}
160
+ name="brief"
161
+ rows={4}
162
+ placeholder={t('sandbox.sessions.brief.placeholder')}
163
+ required
164
+ onPaste={onPaste}
165
+ ></textarea>
166
+ </div>
167
+ </div>
168
+ <div class="ui-drawer__foot">
169
+ <button
170
+ class="chat__attach"
171
+ type="button"
172
+ aria-label={t('sandbox.attachments.action')}
173
+ title={t('sandbox.attachments.help')}
174
+ onClick={() => attachInput.current?.click()}
175
+ >
176
+ <Icon name="plus" size={16} />
177
+ </button>
178
+ <input
179
+ ref={attachInput}
180
+ class="chat__attach-input"
181
+ type="file"
182
+ multiple
183
+ accept={ATTACH_ACCEPT}
184
+ onChange={onPick}
185
+ />
186
+ <select
187
+ class="chat__picker"
188
+ aria-label={t('sandbox.sessions.driver.label')}
189
+ value={selected?.id ?? ''}
190
+ disabled={offered.length === 0}
191
+ onChange={(event) => props.onDriver(event.currentTarget.value)}
192
+ >
193
+ @for (const driver of offered; key driver.id) {
194
+ <option value={driver.id}>{driver.label}</option>
195
+ }
196
+ </select>
197
+ <span class="sandbox__spacer"></span>
198
+ <Button
199
+ size="sm"
200
+ variant="primary"
201
+ type="submit"
202
+ disabled={props.busy || offered.length === 0}
203
+ >
204
+ <Icon name="play" size={14} />
205
+ {props.busy
206
+ ? t('sandbox.sessions.planning')
207
+ : t('sandbox.sessions.start')}
208
+ </Button>
209
+ </div>
210
+ <p class="ui-note">
211
+ {selected ? selected.description : t('sandbox.sessions.driver.none')}
212
+ </p>
213
+ </form>
214
+ }
@@ -0,0 +1,151 @@
1
+ import { useState } from 'octane';
2
+ import { Button, Icon, Tag } from '@flowdular/sdk/ui';
3
+ import type { SandboxSession } from '../server/sessions.ts';
4
+ import type { WorkspaceModuleSummary } from './api.ts';
5
+ import { useTranslation } from './i18n.ts';
6
+
7
+ export interface SessionBarProps {
8
+ readonly session: SandboxSession;
9
+ readonly changes: number;
10
+ readonly busy: boolean;
11
+ /* The module the panes show; empty means every module of the session. */
12
+ readonly activeModule: string;
13
+ /* Every module of the workspace, so the session can take another one in. */
14
+ readonly workspaceModules: readonly WorkspaceModuleSummary[];
15
+ readonly onModule: (directory: string) => void;
16
+ readonly onAddModule: (moduleId: string) => void;
17
+ readonly onBack: () => void;
18
+ readonly onGates: () => void;
19
+ readonly onChanges: () => void;
20
+ readonly onPreview: () => void;
21
+ readonly onEject: () => void;
22
+ }
23
+
24
+ function stateTone(
25
+ state: SandboxSession['state'],
26
+ ): 'success' | 'warning' | 'danger' | 'info' | 'neutral' {
27
+ if (state === 'accepted') return 'success';
28
+ if (state === 'failed' || state === 'blocked') return 'danger';
29
+ if (state === 'awaiting-approval' || state === 'validating') return 'warning';
30
+ if (state === 'draft') return 'neutral';
31
+ return 'info';
32
+ }
33
+
34
+ /* Everything here belongs to one module session. The application bar above
35
+ stays global, so a session action can never look like a workspace action. */
36
+ export function SessionBar(props: SessionBarProps) @{
37
+ const { t } = useTranslation();
38
+ const delivered = Boolean(props.session.ejectedAt);
39
+ const modules = props.session.modules;
40
+ const held = new Set(modules.map((module) => module.id));
41
+ const candidates = props.workspaceModules.filter(
42
+ (module) => !held.has(module.id),
43
+ );
44
+ const [adding, setAdding] = useState(false);
45
+ const [chosen, setChosen] = useState('');
46
+
47
+ <div class="session-bar">
48
+ <button class="session-bar__back" type="button" onClick={props.onBack}>
49
+ <Icon name="chevron-down" size={14} />
50
+ {t('sandbox.session.back')}
51
+ </button>
52
+ <span class="session-bar__title">
53
+ <b>{props.session.title}</b>
54
+ <small class="ui-mono">
55
+ {modules.length === 1
56
+ ? props.session.moduleId
57
+ : t('sandbox.session.modules', { count: modules.length })}
58
+ </small>
59
+ </span>
60
+ <Tag tone={stateTone(props.session.state)} dot>
61
+ {delivered
62
+ ? t('sandbox.session.state.delivered')
63
+ : t('sandbox.session.state.' + props.session.state)}
64
+ </Tag>
65
+ @if (modules.length > 1) {
66
+ <select
67
+ class="chat__picker"
68
+ aria-label={t('sandbox.session.module.visible')}
69
+ value={props.activeModule}
70
+ onChange={(event) => props.onModule(event.currentTarget.value)}
71
+ >
72
+ <option value="">{t('sandbox.session.module.all')}</option>
73
+ @for (const module of modules; key module.id) {
74
+ <option value={module.directory}>{module.id}</option>
75
+ }
76
+ </select>
77
+ }
78
+ @if (adding) {
79
+ <>
80
+ <select
81
+ class="chat__picker"
82
+ aria-label={t('sandbox.session.module.addLabel')}
83
+ value={chosen}
84
+ onChange={(event) => setChosen(event.currentTarget.value)}
85
+ >
86
+ <option value="">{t('sandbox.session.module.choose')}</option>
87
+ @for (const module of candidates; key module.id) {
88
+ <option value={module.id}>{module.id}</option>
89
+ }
90
+ </select>
91
+ <Button
92
+ size="sm"
93
+ variant="primary"
94
+ disabled={props.busy || !chosen}
95
+ onClick={() => {
96
+ props.onAddModule(chosen);
97
+ setChosen('');
98
+ setAdding(false);
99
+ }}
100
+ >{t('sandbox.common.add')}</Button>
101
+ <Button size="sm" variant="ghost" onClick={() => setAdding(false)}>{t(
102
+ 'sandbox.common.cancel',
103
+ )}</Button>
104
+ </>
105
+ } @else if (!delivered && candidates.length > 0) {
106
+ <button
107
+ class="session-bar__add"
108
+ type="button"
109
+ disabled={props.busy}
110
+ onClick={() => setAdding(true)}
111
+ >
112
+ <Icon name="plus" size={12} />
113
+ {t('sandbox.session.module.add')}
114
+ </button>
115
+ }
116
+ <span class="sandbox__spacer"></span>
117
+ <Button size="sm" variant="ghost" onClick={props.onPreview}>
118
+ <Icon name="play" size={14} />
119
+ {t('sandbox.work.preview')}
120
+ </Button>
121
+ <Button
122
+ size="sm"
123
+ variant="ghost"
124
+ onClick={props.onGates}
125
+ disabled={props.busy}
126
+ >
127
+ <Icon name="check" size={14} />
128
+ {t('sandbox.session.gates')}
129
+ </Button>
130
+ <Button
131
+ size="sm"
132
+ variant="ghost"
133
+ onClick={props.onChanges}
134
+ disabled={props.changes === 0}
135
+ >
136
+ <Icon name="code" size={14} />
137
+ {t('sandbox.session.changes', { count: props.changes })}
138
+ </Button>
139
+ <Button
140
+ size="sm"
141
+ variant={delivered ? 'ghost' : 'secondary'}
142
+ onClick={props.onEject}
143
+ disabled={props.busy || props.changes === 0}
144
+ >
145
+ <Icon name={delivered ? 'check' : 'download'} size={14} />
146
+ {delivered
147
+ ? t('sandbox.session.deliverAgain')
148
+ : t('sandbox.session.eject')}
149
+ </Button>
150
+ </div>
151
+ }