@flowdular/sandbox 0.2.9 → 0.3.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.
- package/README.md +25 -0
- package/internal/coding-agent/src/index.ts +6 -1
- package/internal/coding-agent/src/roles/defaults.ts +5 -1
- package/internal/coding-agent/src/roles/skills.ts +42 -6
- package/internal/coding-agent/src/workspace.ts +4 -0
- package/package.json +2 -2
- package/src/App.tsrx +46 -0
- package/src/client/ChatPane.tsrx +73 -12
- package/src/client/PendingQuestionsCard.tsrx +193 -0
- package/src/client/api.ts +55 -23
- package/src/client/locales/en.json +11 -0
- package/src/client/locales/pl.json +11 -0
- package/src/client/state.ts +21 -0
- package/src/server/preview-runtime.ts +58 -6
- package/src/server/preview-worker-manager.ts +1 -0
- package/src/server/preview-worker.ts +6 -3
- package/src/server/questions.ts +356 -0
- package/src/server/reference.ts +27 -1
- package/src/server/routes.ts +70 -0
- package/src/server/sessions.ts +7 -0
- package/src/server/turns.ts +44 -2
package/README.md
CHANGED
|
@@ -459,6 +459,31 @@ anyone else works, so a broken change never travels down the chain. The fix
|
|
|
459
459
|
prompt carries the gate command and the first 4000 characters of its output;
|
|
460
460
|
the transcript keeps the whole output (head and tail of a long one).
|
|
461
461
|
|
|
462
|
+
### Active questions
|
|
463
|
+
|
|
464
|
+
A specialist that needs a business decision closes its reply with one fenced
|
|
465
|
+
block tagged `questions` holding
|
|
466
|
+
`{"questions":[{"id":"Q-1","question":"...","options":["..."],"recommended":"...","allowFreeText":true}]}`.
|
|
467
|
+
The sandbox parses and bounds it (at most 12 questions with unique `Q-n` ids, a
|
|
468
|
+
question of 1 to 400 characters, at most 8 options of 1 to 120 characters, a
|
|
469
|
+
recommendation that is one of them) and stores it on the session as
|
|
470
|
+
`pendingQuestions`; a block it cannot read is a turn warning, not a failed turn.
|
|
471
|
+
The transcript lists the questions instead of showing JSON, and the session view
|
|
472
|
+
offers a form with the recommendation preselected.
|
|
473
|
+
|
|
474
|
+
```
|
|
475
|
+
POST /sandbox/api/sessions/:id/answers
|
|
476
|
+
{ "answers": [{ "id": "Q-1", "answer": "Only the owner" }], "message": "optional" }
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Behind the same boundary as every other mutation. It clears the questions and
|
|
480
|
+
starts the next turn in the role that asked, in the module it asked about, with
|
|
481
|
+
`Decisions:` and one `- Q-1: <question> -> <answer>` line per decision leading
|
|
482
|
+
the operator's optional message. Refusals: `409 NO_PENDING_QUESTIONS`, `409
|
|
483
|
+
SESSION_ARCHIVED`, `409 SESSION_DELIVERED`, and `400 INVALID_INPUT` for an
|
|
484
|
+
unanswered question, an unknown id, an over-long answer, or an answer that is
|
|
485
|
+
not one of the offered options when no free text was allowed.
|
|
486
|
+
|
|
462
487
|
## Turn lifetime
|
|
463
488
|
|
|
464
489
|
A turn runs to completion on the server whatever happens to the browser: the
|
|
@@ -28,7 +28,12 @@ export {
|
|
|
28
28
|
type InstructionContext,
|
|
29
29
|
} from './roles/contract.ts';
|
|
30
30
|
export { parseHandoff, type HandoffDeclaration } from './roles/handoff.ts';
|
|
31
|
-
export {
|
|
31
|
+
export {
|
|
32
|
+
ALWAYS_ELIGIBLE_SKILLS,
|
|
33
|
+
ROLE_SKILLS,
|
|
34
|
+
selectTaskSkill,
|
|
35
|
+
type TaskSkillContext,
|
|
36
|
+
} from './roles/skills.ts';
|
|
32
37
|
export { DEFAULT_AGENT_ROLES } from './roles/defaults.ts';
|
|
33
38
|
export type { AgentRoleDefinition } from './roles/defaults.ts';
|
|
34
39
|
export {
|
|
@@ -85,7 +85,11 @@ Leave client files to the frontend engineer and tools/business-agent definitions
|
|
|
85
85
|
allowedPaths: ['spec/**', 'translations/**'],
|
|
86
86
|
gates: ['spec-schema'],
|
|
87
87
|
handoff: ['backend-engineer', 'ux-designer'],
|
|
88
|
-
instruction: `You own specification decisions and locale terminology, never implementation. Use only the Task skill selected under Session. Consult reference/packages/contracts/schemas/module-spec.schema.json and reference/example-module/spec/module.yaml when writing the spec.
|
|
88
|
+
instruction: `You own specification decisions and locale terminology, never implementation. Use only the Task skill selected under Session. Consult reference/platform-capabilities.md, reference/packages/contracts/schemas/module-spec.schema.json and reference/example-module/spec/module.yaml when writing the spec.
|
|
89
|
+
|
|
90
|
+
Write schemaVersion 2: entities with typed fields and states, screens, actions, widgets, settings, agentTools, plus outOfScope and decisions. Fill decisions for every choice, including the platform defaults you proposed. The capability card is closed: anything it lists as missing goes to outOfScope with the business decision, never into a scenario. v1 specs stay valid.
|
|
91
|
+
|
|
92
|
+
When a decision is missing, end the reply with exactly one fenced block tagged questions holding {"questions":[{"id":"Q-1","question":"...","options":["..."],"recommended":"...","allowFreeText":true}]} and nothing after it. The operator answers in a form and the replies arrive next turn as a Decisions section.
|
|
89
93
|
|
|
90
94
|
For an edit, compare against base/modules/<dir>/spec/module.yaml and make the smallest delta covering the brief. Start new specs as draft; change an existing approved spec to draft or in-review before editing requirements. Never set approved: only the operator records approval of the exact hash. Later edits invalidate it.
|
|
91
95
|
|
|
@@ -3,13 +3,28 @@ export interface TaskSkillContext {
|
|
|
3
3
|
readonly sessionKind: 'new-module' | 'edit-module';
|
|
4
4
|
readonly blueprint: string;
|
|
5
5
|
readonly task: string;
|
|
6
|
+
/* The text an explicit $skill may come from. Defaults to the request text.
|
|
7
|
+
A turn whose request also carries words a specialist wrote narrows it to
|
|
8
|
+
the operator's own, so a question can never choose the skill that answers
|
|
9
|
+
it; empty means this turn has no operator text to scan. The keyword
|
|
10
|
+
defaults keep reading `task`, which is the whole request either way. */
|
|
11
|
+
readonly explicitSkillSource?: string;
|
|
6
12
|
readonly available: readonly string[];
|
|
13
|
+
/* Whether the operator has approved the exact current specification of the
|
|
14
|
+
module this turn works on. Absent counts as not approved, so a new module
|
|
15
|
+
starts with the interview instead of the implementation skill. */
|
|
16
|
+
readonly specApproved?: boolean;
|
|
7
17
|
}
|
|
8
18
|
|
|
9
19
|
/* A single deterministic selection. Discovery is not injected into the prompt.
|
|
10
20
|
Only skills this specialist can use are eligible for an explicit $skill. */
|
|
11
|
-
const ROLE_SKILLS: Readonly<Record<string, readonly string[]>> = {
|
|
12
|
-
'business-manager': [
|
|
21
|
+
export const ROLE_SKILLS: Readonly<Record<string, readonly string[]>> = {
|
|
22
|
+
'business-manager': [
|
|
23
|
+
'spec-interview',
|
|
24
|
+
'module-new',
|
|
25
|
+
'module-update',
|
|
26
|
+
'translations-i18n',
|
|
27
|
+
],
|
|
13
28
|
'backend-engineer': [
|
|
14
29
|
'module-new',
|
|
15
30
|
'module-update',
|
|
@@ -18,17 +33,19 @@ const ROLE_SKILLS: Readonly<Record<string, readonly string[]>> = {
|
|
|
18
33
|
'bug-hunt',
|
|
19
34
|
'test-hardening',
|
|
20
35
|
'auth-security-review',
|
|
36
|
+
'cli-extension',
|
|
21
37
|
'perf-audit',
|
|
22
38
|
],
|
|
23
39
|
'frontend-engineer': [
|
|
24
40
|
'ux-design',
|
|
25
41
|
'module-update',
|
|
42
|
+
'translations-i18n',
|
|
26
43
|
'variables',
|
|
27
44
|
'workflow-development',
|
|
28
45
|
'bug-hunt',
|
|
29
46
|
'test-hardening',
|
|
30
47
|
],
|
|
31
|
-
'ux-designer': ['ux-design'],
|
|
48
|
+
'ux-designer': ['ux-design', 'translations-i18n'],
|
|
32
49
|
'agentic-engineer': [
|
|
33
50
|
'agent-tool-design',
|
|
34
51
|
'business-agent-design',
|
|
@@ -38,13 +55,23 @@ const ROLE_SKILLS: Readonly<Record<string, readonly string[]>> = {
|
|
|
38
55
|
],
|
|
39
56
|
};
|
|
40
57
|
|
|
58
|
+
/* Review is read-only and is available to custom registered specialists too, so
|
|
59
|
+
it is eligible for every role without appearing in any role's list. */
|
|
60
|
+
export const ALWAYS_ELIGIBLE_SKILLS: readonly string[] = ['auto-review'];
|
|
61
|
+
|
|
41
62
|
export function selectTaskSkill(context: TaskSkillContext): string | null {
|
|
42
|
-
|
|
43
|
-
|
|
63
|
+
const eligible = [
|
|
64
|
+
...(ROLE_SKILLS[context.role] ?? []),
|
|
65
|
+
...ALWAYS_ELIGIBLE_SKILLS,
|
|
66
|
+
];
|
|
44
67
|
const available = new Set(context.available);
|
|
45
68
|
const installed = (skill: string) =>
|
|
46
69
|
eligible.includes(skill) && available.has(skill);
|
|
47
|
-
const explicit = [
|
|
70
|
+
const explicit = [
|
|
71
|
+
...(context.explicitSkillSource ?? context.task).matchAll(
|
|
72
|
+
/\$([a-z][a-z0-9-]*)\b/g,
|
|
73
|
+
),
|
|
74
|
+
]
|
|
48
75
|
.map((match) => match[1]!)
|
|
49
76
|
.find(installed);
|
|
50
77
|
if (explicit) return explicit;
|
|
@@ -56,6 +83,15 @@ export function selectTaskSkill(context: TaskSkillContext): string | null {
|
|
|
56
83
|
? 'bug-hunt'
|
|
57
84
|
: null;
|
|
58
85
|
if (focused && installed(focused)) return focused;
|
|
86
|
+
/* A new module starts as an interview, not as an implementation: until the
|
|
87
|
+
operator approves the exact spec there is nothing to implement from. */
|
|
88
|
+
if (
|
|
89
|
+
context.role === 'business-manager' &&
|
|
90
|
+
context.sessionKind === 'new-module' &&
|
|
91
|
+
context.specApproved !== true &&
|
|
92
|
+
installed('spec-interview')
|
|
93
|
+
)
|
|
94
|
+
return 'spec-interview';
|
|
59
95
|
const moduleSkill =
|
|
60
96
|
context.sessionKind === 'new-module' ? 'module-new' : 'module-update';
|
|
61
97
|
const defaultSkill =
|
|
@@ -229,6 +229,10 @@ export function spawnLineStream(options: SpawnJsonOptions): ProcessLineStream {
|
|
|
229
229
|
});
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
/* A spawn failure rejects before the caller has drained the lines and
|
|
233
|
+
awaited this promise; without a handler of its own that rejection is
|
|
234
|
+
unhandled and takes the process down. */
|
|
235
|
+
finished.catch(() => undefined);
|
|
232
236
|
return {
|
|
233
237
|
lines: createInterface({ input: child.stdout, crlfDelay: Infinity }),
|
|
234
238
|
finished,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowdular/sandbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The Flowdular sandbox: chat a change, build it behind the gates, preview it in the real application, deliver it as code.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/flowdular/flowdular/tree/main/packages/sandbox#readme",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"octane": "0.1.51",
|
|
58
58
|
"segment-state": "0.2.1",
|
|
59
59
|
"vite": "8.2.2",
|
|
60
|
-
"@flowdular/sdk": "0.
|
|
60
|
+
"@flowdular/sdk": "0.3.0"
|
|
61
61
|
},
|
|
62
62
|
"imports": {
|
|
63
63
|
"#coding-agent": "./internal/coding-agent/src/index.ts"
|
package/src/App.tsrx
CHANGED
|
@@ -31,9 +31,11 @@ import {
|
|
|
31
31
|
stopTurn,
|
|
32
32
|
streamEject,
|
|
33
33
|
streamTurn,
|
|
34
|
+
submitAnswers,
|
|
34
35
|
uploadAttachment,
|
|
35
36
|
type EjectPlanView,
|
|
36
37
|
type EjectTargetId,
|
|
38
|
+
type SubmittedAnswer,
|
|
37
39
|
type TurnHandlers,
|
|
38
40
|
} from './client/api.ts';
|
|
39
41
|
import { ChatPane } from './client/ChatPane.tsrx';
|
|
@@ -54,11 +56,13 @@ import {
|
|
|
54
56
|
type GitHubSettingsInput,
|
|
55
57
|
} from './client/GitHubSettingsModal.tsrx';
|
|
56
58
|
import {
|
|
59
|
+
answersErrorFor,
|
|
57
60
|
createSandboxClientState,
|
|
58
61
|
mergeEntries,
|
|
59
62
|
mergeSteps,
|
|
60
63
|
signalReady,
|
|
61
64
|
sessionIdFromUrl,
|
|
65
|
+
type AnswersRefusal,
|
|
62
66
|
type PreviewSelection,
|
|
63
67
|
} from './client/state.ts';
|
|
64
68
|
import {
|
|
@@ -121,6 +125,11 @@ export function App(props?: RenderRouteProps) @{
|
|
|
121
125
|
const [ejecting, setEjecting] = useValue(sandbox.state.ejecting);
|
|
122
126
|
const [ejectBuild, setEjectBuild] = useValue(sandbox.state.ejectBuild);
|
|
123
127
|
const [modelSettingsOpen, setModelSettingsOpen] = useState(false);
|
|
128
|
+
/* A refused answer belongs beside the form that was refused, not in the
|
|
129
|
+
session-wide banner and not beside another session's form. */
|
|
130
|
+
const [answersRefusal, setAnswersRefusal] = useState<AnswersRefusal | null>(
|
|
131
|
+
null,
|
|
132
|
+
);
|
|
124
133
|
const [menuOpen, setMenuOpen] = useValue(sandbox.state.menuOpen);
|
|
125
134
|
const [githubSettingsOpen, setGitHubSettingsOpen] = useValue(
|
|
126
135
|
sandbox.state.githubSettingsOpen,
|
|
@@ -380,6 +389,35 @@ export function App(props?: RenderRouteProps) @{
|
|
|
380
389
|
);
|
|
381
390
|
};
|
|
382
391
|
|
|
392
|
+
/* Answering the open questions is a turn the server composes: it prepends the
|
|
393
|
+
decisions to the optional note and resumes the specialist that asked. */
|
|
394
|
+
const answerQuestions = (
|
|
395
|
+
answers: readonly SubmittedAnswer[],
|
|
396
|
+
note: string,
|
|
397
|
+
) => {
|
|
398
|
+
const session = currentSession();
|
|
399
|
+
if (!session || sandbox.store.get(sandbox.state.running)) return;
|
|
400
|
+
const sequence = session.pendingQuestions?.sequence ?? -1;
|
|
401
|
+
setAnswersRefusal(null);
|
|
402
|
+
detach();
|
|
403
|
+
setRunning(true);
|
|
404
|
+
setError('');
|
|
405
|
+
const handlers = handlersFor(session.id);
|
|
406
|
+
turn.controller = submitAnswers(session.id, answers, note, {
|
|
407
|
+
...handlers,
|
|
408
|
+
onFailed: (failure) => {
|
|
409
|
+
if (sandbox.store.get(sandbox.state.activeSessionId) !== session.id) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
setAnswersRefusal({
|
|
413
|
+
session: session.id,
|
|
414
|
+
sequence,
|
|
415
|
+
message: failure,
|
|
416
|
+
});
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
};
|
|
420
|
+
|
|
383
421
|
const continueWith = (handoff: HandoffPlan) => {
|
|
384
422
|
const session = currentSession();
|
|
385
423
|
if (!session || running) return;
|
|
@@ -863,6 +901,7 @@ export function App(props?: RenderRouteProps) @{
|
|
|
863
901
|
<ChatPane
|
|
864
902
|
entries={entries}
|
|
865
903
|
delivered={Boolean(view.session.ejectedAt)}
|
|
904
|
+
archived={Boolean(view.session.archivedAt)}
|
|
866
905
|
roles={state.roles}
|
|
867
906
|
drivers={state.drivers}
|
|
868
907
|
modules={view.session.modules}
|
|
@@ -875,6 +914,12 @@ export function App(props?: RenderRouteProps) @{
|
|
|
875
914
|
busy={busy}
|
|
876
915
|
selection={selection}
|
|
877
916
|
autoContinue={view.session.autoContinue}
|
|
917
|
+
pendingQuestions={view.session.pendingQuestions ?? null}
|
|
918
|
+
answersError={answersErrorFor(
|
|
919
|
+
answersRefusal,
|
|
920
|
+
view.session.id,
|
|
921
|
+
view.session.pendingQuestions ?? null,
|
|
922
|
+
)}
|
|
878
923
|
pendingBrief={startingPoint(view.session, entries)}
|
|
879
924
|
onStart={() => send(startingPoint(view.session, entries))}
|
|
880
925
|
onContinue={continueWith}
|
|
@@ -893,6 +938,7 @@ export function App(props?: RenderRouteProps) @{
|
|
|
893
938
|
onDriver={setDriver}
|
|
894
939
|
onMessage={setDraft}
|
|
895
940
|
onSend={(fresh) => send(undefined, fresh)}
|
|
941
|
+
onAnswers={answerQuestions}
|
|
896
942
|
onStop={stop}
|
|
897
943
|
onClearSelection={() => setSelection(null)}
|
|
898
944
|
/>
|
package/src/client/ChatPane.tsrx
CHANGED
|
@@ -16,7 +16,14 @@ import {
|
|
|
16
16
|
type DriverSummary,
|
|
17
17
|
type ModuleSpecReview,
|
|
18
18
|
type RoleSummary,
|
|
19
|
+
type SubmittedAnswer,
|
|
19
20
|
} from './api.ts';
|
|
21
|
+
import {
|
|
22
|
+
readQuestions,
|
|
23
|
+
type PendingQuestion,
|
|
24
|
+
type PendingQuestions,
|
|
25
|
+
} from '../server/questions.ts';
|
|
26
|
+
import { PendingQuestionsCard } from './PendingQuestionsCard.tsrx';
|
|
20
27
|
import { ToolEvent } from './ToolEvent.tsrx';
|
|
21
28
|
import { transcriptRows, type TranscriptRow } from './transcript.ts';
|
|
22
29
|
import { TurnActivity } from './TurnActivity.tsrx';
|
|
@@ -34,6 +41,9 @@ import { roleLabel, handoffLabel } from './session-labels.ts';
|
|
|
34
41
|
export interface ChatPaneProps {
|
|
35
42
|
readonly entries: readonly ChatEntry[];
|
|
36
43
|
readonly delivered: boolean;
|
|
44
|
+
/* Archived and delivered both close the session to work, so a pending
|
|
45
|
+
decision form is read-only under either. */
|
|
46
|
+
readonly archived: boolean;
|
|
37
47
|
readonly roles: readonly RoleSummary[];
|
|
38
48
|
readonly drivers: readonly DriverSummary[];
|
|
39
49
|
readonly modules: readonly SessionModule[];
|
|
@@ -48,6 +58,9 @@ export interface ChatPaneProps {
|
|
|
48
58
|
readonly busy?: boolean;
|
|
49
59
|
readonly selection: PreviewSelection | null;
|
|
50
60
|
readonly autoContinue: boolean;
|
|
61
|
+
/* The decisions the specialist that asked is still waiting for. */
|
|
62
|
+
readonly pendingQuestions: PendingQuestions | null;
|
|
63
|
+
readonly answersError: string;
|
|
51
64
|
/* Set when the session has never run a turn, so the brief can still start it. */
|
|
52
65
|
readonly pendingBrief: string;
|
|
53
66
|
readonly onStart: () => void;
|
|
@@ -61,6 +74,7 @@ export interface ChatPaneProps {
|
|
|
61
74
|
readonly onDriver: (driver: string) => void;
|
|
62
75
|
readonly onMessage: (message: string) => void;
|
|
63
76
|
readonly onSend: (freshContext?: boolean) => void;
|
|
77
|
+
readonly onAnswers: (answers: readonly SubmittedAnswer[], message: string) => void;
|
|
64
78
|
readonly onStop: () => void;
|
|
65
79
|
readonly onClearSelection: () => void;
|
|
66
80
|
}
|
|
@@ -103,10 +117,11 @@ function isError(entry: ChatEntry): boolean {
|
|
|
103
117
|
|
|
104
118
|
const HANDOFF_LINE = /^handoff\s*:/i;
|
|
105
119
|
|
|
106
|
-
/* The handoff line
|
|
107
|
-
already
|
|
108
|
-
|
|
109
|
-
|
|
120
|
+
/* The handoff line and the questions block are protocol, not conversation: the
|
|
121
|
+
cards below the message already say who continues and what is being asked,
|
|
122
|
+
so the transcript shows the words only. */
|
|
123
|
+
function spoken(entry: ChatEntry, text: string): string {
|
|
124
|
+
const lines = text.split('\n');
|
|
110
125
|
while (lines.length > 0 &&
|
|
111
126
|
lines[lines.length - 1]!.trim() === '') lines.pop();
|
|
112
127
|
const last = lines[lines.length - 1]?.replace(/[`*_]/g, '').trim() ?? '';
|
|
@@ -120,6 +135,11 @@ interface StreamBlock {
|
|
|
120
135
|
readonly key: number;
|
|
121
136
|
readonly entry: ChatEntry | null;
|
|
122
137
|
readonly events: readonly TranscriptRow[] | null;
|
|
138
|
+
/* The message as words, with the protocol lines taken out. */
|
|
139
|
+
readonly speech: string;
|
|
140
|
+
/* The decisions this message asked for, read once so the transcript can
|
|
141
|
+
list them instead of showing the operator raw JSON. */
|
|
142
|
+
readonly questions: readonly PendingQuestion[];
|
|
123
143
|
}
|
|
124
144
|
|
|
125
145
|
/* One stream in the order it happened: a message, then the tool activity that
|
|
@@ -150,13 +170,26 @@ function streamBlocks(entries: readonly ChatEntry[]): readonly StreamBlock[] {
|
|
|
150
170
|
}
|
|
151
171
|
blocks.push({ key: entry.sequence, entry, events: null });
|
|
152
172
|
}
|
|
153
|
-
return blocks.map(
|
|
154
|
-
|
|
173
|
+
return blocks.map((block) => {
|
|
174
|
+
const reading =
|
|
175
|
+
block.entry?.kind === 'agent'
|
|
176
|
+
? readQuestions(block.entry.text ?? '')
|
|
177
|
+
: null;
|
|
178
|
+
return {
|
|
155
179
|
key: block.key,
|
|
156
180
|
entry: block.entry,
|
|
157
181
|
events: block.events,
|
|
158
|
-
|
|
159
|
-
|
|
182
|
+
speech: block.entry
|
|
183
|
+
? spoken(
|
|
184
|
+
block.entry,
|
|
185
|
+
reading?.kind === 'valid'
|
|
186
|
+
? reading.remainder
|
|
187
|
+
: block.entry.text ?? '',
|
|
188
|
+
)
|
|
189
|
+
: '',
|
|
190
|
+
questions: reading?.kind === 'valid' ? reading.questions : [],
|
|
191
|
+
};
|
|
192
|
+
});
|
|
160
193
|
}
|
|
161
194
|
|
|
162
195
|
function blockKey(block: StreamBlock): number {
|
|
@@ -484,8 +517,19 @@ export function ChatPane(props: ChatPaneProps) @{
|
|
|
484
517
|
<p class="chat__text">{block.entry!.text}</p>
|
|
485
518
|
</details>
|
|
486
519
|
}
|
|
487
|
-
@if (block.entry!.sequence === open
|
|
488
|
-
@if (block.entry!.handoff!.kind === '
|
|
520
|
+
@if (block.entry!.sequence === open) {
|
|
521
|
+
@if (block.entry!.handoff!.kind === 'question') {
|
|
522
|
+
<PendingQuestionsCard
|
|
523
|
+
pending={props.pendingQuestions}
|
|
524
|
+
loading={props.running}
|
|
525
|
+
denied={props.delivered || props.archived}
|
|
526
|
+
busy={props.busy === true}
|
|
527
|
+
error={props.answersError}
|
|
528
|
+
onSubmit={props.onAnswers}
|
|
529
|
+
/>
|
|
530
|
+
} @else if (
|
|
531
|
+
block.entry!.handoff!.kind === 'approval' && !props.running
|
|
532
|
+
) {
|
|
489
533
|
<ApprovalHandoff
|
|
490
534
|
handoff={block.entry!.handoff!}
|
|
491
535
|
reviews={props.specs}
|
|
@@ -494,7 +538,9 @@ export function ChatPane(props: ChatPaneProps) @{
|
|
|
494
538
|
onRequestChanges={props.onRequestChanges}
|
|
495
539
|
onEdit={props.onEditSpec}
|
|
496
540
|
/>
|
|
497
|
-
} @else if (
|
|
541
|
+
} @else if (
|
|
542
|
+
block.entry!.handoff!.kind === 'continue' && !props.running
|
|
543
|
+
) {
|
|
498
544
|
<div class="ui-form__actions">
|
|
499
545
|
<Button
|
|
500
546
|
size="sm"
|
|
@@ -530,7 +576,22 @@ export function ChatPane(props: ChatPaneProps) @{
|
|
|
530
576
|
</span>
|
|
531
577
|
{moduleLabel(block.entry!)}
|
|
532
578
|
</header>
|
|
533
|
-
<p class="chat__text">{
|
|
579
|
+
<p class="chat__text">{block.speech}</p>
|
|
580
|
+
@if (block.questions.length > 0) {
|
|
581
|
+
<ul>
|
|
582
|
+
@for (const question of block.questions; key question.id) {
|
|
583
|
+
<li>
|
|
584
|
+
<span class="ui-mono">{question.id}</span>
|
|
585
|
+
{' ' + question.question}
|
|
586
|
+
@if (question.options.length > 0) {
|
|
587
|
+
<span class="ui-help">{question.options.join(
|
|
588
|
+
' · ',
|
|
589
|
+
)}</span>
|
|
590
|
+
}
|
|
591
|
+
</li>
|
|
592
|
+
}
|
|
593
|
+
</ul>
|
|
594
|
+
}
|
|
534
595
|
{restoreRow(block.key)}
|
|
535
596
|
</article>
|
|
536
597
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useState } from 'octane';
|
|
2
|
+
import { Alert, Button, Icon, Tag } from '@flowdular/sdk/ui';
|
|
3
|
+
import {
|
|
4
|
+
MAX_ANSWER_LENGTH,
|
|
5
|
+
type PendingQuestion,
|
|
6
|
+
type PendingQuestions,
|
|
7
|
+
} from '../server/questions.ts';
|
|
8
|
+
import type { SubmittedAnswer } from './api.ts';
|
|
9
|
+
import { useTranslation } from './i18n.ts';
|
|
10
|
+
|
|
11
|
+
export interface PendingQuestionsCardProps {
|
|
12
|
+
readonly pending: PendingQuestions | null;
|
|
13
|
+
/* A turn is still in flight, so nothing here is answerable yet. */
|
|
14
|
+
readonly loading: boolean;
|
|
15
|
+
/* The session no longer accepts work: archived, or already delivered. */
|
|
16
|
+
readonly denied: boolean;
|
|
17
|
+
readonly busy: boolean;
|
|
18
|
+
/* Why the last submission was refused. It renders above the state branch,
|
|
19
|
+
because a refusal that already cleared the record leaves no questions to
|
|
20
|
+
render it beside. */
|
|
21
|
+
readonly error: string;
|
|
22
|
+
readonly onSubmit: (answers: readonly SubmittedAnswer[], message: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* One operator's unsent decisions, stamped with the turn that asked. A newer
|
|
26
|
+
question set makes the draft stale without an effect or a reset pass. */
|
|
27
|
+
interface AnswerDraft {
|
|
28
|
+
readonly sequence: number;
|
|
29
|
+
readonly mode: Readonly<Record<string, 'option' | 'free'>>;
|
|
30
|
+
readonly choice: Readonly<Record<string, string>>;
|
|
31
|
+
readonly typed: Readonly<Record<string, string>>;
|
|
32
|
+
readonly note: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const EMPTY: AnswerDraft = {
|
|
36
|
+
sequence: -1,
|
|
37
|
+
mode: {},
|
|
38
|
+
choice: {},
|
|
39
|
+
typed: {},
|
|
40
|
+
note: '',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/* The decisions a specialist is waiting for: one radio group per question, the
|
|
44
|
+
recommendation preselected, and a free-text answer where the specialist
|
|
45
|
+
allowed one. Submitting starts the next turn in the role that asked. */
|
|
46
|
+
export function PendingQuestionsCard(props: PendingQuestionsCardProps) @{
|
|
47
|
+
const { t } = useTranslation();
|
|
48
|
+
const questions = props.pending?.questions ?? [];
|
|
49
|
+
const sequence = props.pending?.sequence ?? -1;
|
|
50
|
+
const [stored, setStored] = useState<AnswerDraft>(EMPTY);
|
|
51
|
+
const draft =
|
|
52
|
+
stored.sequence === sequence ? stored : EMPTY;
|
|
53
|
+
const patch = (change: Partial<AnswerDraft>) => setStored({
|
|
54
|
+
...draft,
|
|
55
|
+
...change,
|
|
56
|
+
sequence,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const modeOf = (question: PendingQuestion): 'option' | 'free' | '' =>
|
|
60
|
+
draft.mode[question.id] ??
|
|
61
|
+
(question.recommended
|
|
62
|
+
? 'option'
|
|
63
|
+
: question.options.length === 0
|
|
64
|
+
? 'free'
|
|
65
|
+
: '');
|
|
66
|
+
const choiceOf = (question: PendingQuestion) =>
|
|
67
|
+
draft.choice[question.id] ?? question.recommended ?? '';
|
|
68
|
+
const answerOf = (question: PendingQuestion) => {
|
|
69
|
+
const mode = modeOf(question);
|
|
70
|
+
if (mode === 'free') return (draft.typed[question.id] ?? '').trim();
|
|
71
|
+
return mode === 'option' ? choiceOf(question) : '';
|
|
72
|
+
};
|
|
73
|
+
const pick = (question: PendingQuestion, option: string) => patch({
|
|
74
|
+
mode: { ...draft.mode, [question.id]: 'option' },
|
|
75
|
+
choice: { ...draft.choice, [question.id]: option },
|
|
76
|
+
});
|
|
77
|
+
const complete = questions.every((question) => answerOf(question).length > 0);
|
|
78
|
+
|
|
79
|
+
<section class="ui-form">
|
|
80
|
+
@if (props.error) {
|
|
81
|
+
<Alert tone="danger">{props.error}</Alert>
|
|
82
|
+
}
|
|
83
|
+
@if (props.loading) {
|
|
84
|
+
<p class="ui-note">
|
|
85
|
+
<Icon name="refresh" size={14} />
|
|
86
|
+
{t('sandbox.questions.loading')}
|
|
87
|
+
</p>
|
|
88
|
+
} @else if (props.denied) {
|
|
89
|
+
<Alert tone="warning">{t('sandbox.questions.denied')}</Alert>
|
|
90
|
+
} @else if (questions.length === 0) {
|
|
91
|
+
<p class="ui-note">
|
|
92
|
+
<Icon name="help" size={14} />
|
|
93
|
+
{t('sandbox.questions.empty')}
|
|
94
|
+
</p>
|
|
95
|
+
} @else {
|
|
96
|
+
<>
|
|
97
|
+
<div class="ui-form__section">
|
|
98
|
+
<div class="ui-form__section-head">
|
|
99
|
+
<b>{t('sandbox.questions.title')}</b>
|
|
100
|
+
<small>{t('sandbox.questions.help')}</small>
|
|
101
|
+
</div>
|
|
102
|
+
@for (const question of questions; key question.id) {
|
|
103
|
+
<div class="ui-field">
|
|
104
|
+
<span class="ui-label">
|
|
105
|
+
<span class="ui-mono">{question.id}</span>
|
|
106
|
+
{' ' + question.question}
|
|
107
|
+
</span>
|
|
108
|
+
@for (const option of question.options; key option) {
|
|
109
|
+
<label class="ui-checkbox">
|
|
110
|
+
<input
|
|
111
|
+
type="radio"
|
|
112
|
+
name={'answer-' + sequence + '-' + question.id}
|
|
113
|
+
value={option}
|
|
114
|
+
checked={modeOf(question) === 'option' &&
|
|
115
|
+
choiceOf(question) === option}
|
|
116
|
+
disabled={props.busy}
|
|
117
|
+
onChange={() => pick(question, option)}
|
|
118
|
+
/>
|
|
119
|
+
<span>{option}</span>
|
|
120
|
+
@if (option === question.recommended) {
|
|
121
|
+
<Tag tone="info">{t('sandbox.questions.recommended')}</Tag>
|
|
122
|
+
}
|
|
123
|
+
</label>
|
|
124
|
+
}
|
|
125
|
+
@if (question.allowFreeText) {
|
|
126
|
+
<>
|
|
127
|
+
<label class="ui-checkbox">
|
|
128
|
+
<input
|
|
129
|
+
type="radio"
|
|
130
|
+
name={'answer-' + sequence + '-' + question.id}
|
|
131
|
+
value=""
|
|
132
|
+
checked={modeOf(question) === 'free'}
|
|
133
|
+
disabled={props.busy}
|
|
134
|
+
onChange={() => patch({
|
|
135
|
+
mode: { ...draft.mode, [question.id]: 'free' },
|
|
136
|
+
})}
|
|
137
|
+
/>
|
|
138
|
+
<span>{t('sandbox.questions.freeText')}</span>
|
|
139
|
+
</label>
|
|
140
|
+
<input
|
|
141
|
+
class="ui-input"
|
|
142
|
+
type="text"
|
|
143
|
+
maxlength={MAX_ANSWER_LENGTH}
|
|
144
|
+
aria-label={t('sandbox.questions.freeText')}
|
|
145
|
+
placeholder={t('sandbox.questions.freeTextPlaceholder')}
|
|
146
|
+
value={draft.typed[question.id] ?? ''}
|
|
147
|
+
disabled={props.busy || modeOf(question) !== 'free'}
|
|
148
|
+
onInput={(event) => patch({
|
|
149
|
+
typed: {
|
|
150
|
+
...draft.typed,
|
|
151
|
+
[question.id]: event.currentTarget.value,
|
|
152
|
+
},
|
|
153
|
+
})}
|
|
154
|
+
/>
|
|
155
|
+
</>
|
|
156
|
+
}
|
|
157
|
+
</div>
|
|
158
|
+
}
|
|
159
|
+
</div>
|
|
160
|
+
<div class="ui-field">
|
|
161
|
+
<span class="ui-label">{t('sandbox.questions.note')}</span>
|
|
162
|
+
<textarea
|
|
163
|
+
class="ui-textarea"
|
|
164
|
+
rows={2}
|
|
165
|
+
placeholder={t('sandbox.questions.notePlaceholder')}
|
|
166
|
+
value={draft.note}
|
|
167
|
+
disabled={props.busy}
|
|
168
|
+
onInput={(event) => patch({ note: event.currentTarget.value })}
|
|
169
|
+
></textarea>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="ui-form__actions">
|
|
172
|
+
<Button
|
|
173
|
+
size="sm"
|
|
174
|
+
variant="primary"
|
|
175
|
+
disabled={props.busy || !complete}
|
|
176
|
+
onClick={() => props.onSubmit(
|
|
177
|
+
questions.map(
|
|
178
|
+
(question) => ({
|
|
179
|
+
id: question.id,
|
|
180
|
+
answer: answerOf(question),
|
|
181
|
+
}),
|
|
182
|
+
),
|
|
183
|
+
draft.note.trim(),
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
<Icon name="check" size={14} />
|
|
187
|
+
{t('sandbox.questions.submit')}
|
|
188
|
+
</Button>
|
|
189
|
+
</div>
|
|
190
|
+
</>
|
|
191
|
+
}
|
|
192
|
+
</section>
|
|
193
|
+
}
|