mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 17:38:20 -10:00
UI round 7: plan card, Plan sidebar, goal step and queued messages built on agent6's backend contract
This commit is contained in:
1 parent
89d0965408
commit
e9a2ca44a7
17 files changed
+299
-125
No files matched your search
@@ -11,7 +11,7 @@ export function goalStatusText(goal: Goal): string {
|
||||
case 'paused': return tr('Paused');
|
||||
case 'blocked': return goal.blocked || tr('Needs you');
|
||||
case 'complete': return tr('Done');
|
||||
default: return goal.round > 0 && goal.maxRounds > 0 ? tr('step {0} of {1}', [goal.round, goal.maxRounds]) : tr('Working');
|
||||
default: return goal.step ? tr('step {0} of {1}', [goal.step.index, goal.step.total]) : goal.round > 0 && goal.maxRounds > 0 ? tr('step {0} of {1}', [goal.round, goal.maxRounds]) : tr('Working');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { tr } from '../lib/i18n';
|
||||
import { cancelQueued, planAction, queuedWhen, stepsDone, usePlan, useQueue, type Plan, type QueuedMessage } from '../lib/plan';
|
||||
import { pushToast } from '../lib/store';
|
||||
import { cancelQueued, planEdit, planReject, queuedWhen, stepsDone, usePlan, useQueue, type Plan, type QueuedMessage } from '../lib/plan';
|
||||
import { approvePlan, pushToast, startPlanGoal, useStore } from '../lib/store';
|
||||
import { plainFailureText } from '../lib/plain-errors';
|
||||
import { Btn, Icon, IconBtn } from './ui';
|
||||
|
||||
const STEP_WORDS = { done: 'done', running: 'working on it', failed: 'failed', skipped: 'skipped', pending: 'waiting' } as const;
|
||||
|
||||
/** The steps with their state: a number, a check once done, a soft glow on the one being worked on. Used in the chat and in the sidebar. */
|
||||
export function PlanSteps({ plan }: { plan: Plan }) {
|
||||
return <ol className="plan__steps">
|
||||
{plan.steps.map((s, i) => <li key={s.id} className="plan__step" data-state={s.state}>
|
||||
<span className="plan__no" aria-hidden="true">{s.state === 'done' ? <Icon name="check" size={12}/> : s.state === 'failed' ? <Icon name="x" size={12}/> : i + 1}</span>
|
||||
<span className="plan__text">{s.text}{s.note ? <small>{s.note}</small> : null}<span className="sr-only"> ({tr(s.state === 'done' ? 'done' : s.state === 'active' ? 'working on it' : s.state === 'failed' ? 'failed' : 'waiting')})</span></span>
|
||||
<span className="plan__text">{s.text}{s.state === 'done' && s.summary ? <small>{s.summary}</small> : s.detail ? <small>{s.detail}</small> : null}<span className="sr-only"> ({tr(STEP_WORDS[s.state])})</span></span>
|
||||
</li>)}
|
||||
</ol>;
|
||||
}
|
||||
|
||||
/** The /plan the assistant proposed, in the chat: Approve, Edit, Reject, or Start as goal. Nothing runs until the person decides. */
|
||||
export function PlanCard({ chatId }: { chatId: string | null | undefined }) {
|
||||
/** The words after "Plan": how many steps, or how far it has come. */
|
||||
function planCount(plan: Plan): string {
|
||||
if (plan.state === 'proposed' || plan.state === 'rejected') return tr('{0} steps', [plan.steps.length]);
|
||||
if (plan.state === 'paused') return tr('Paused · {0} of {1} done', [stepsDone(plan), plan.steps.length]);
|
||||
return tr('{0} of {1} done', [stepsDone(plan), plan.steps.length]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The /plan the assistant proposed, in the chat in place of its closing text: Approve, Edit, Reject, or Start as goal. Nothing runs until the
|
||||
* person decides. Once it runs, the same card shows each step's state; a paused plan offers Resume.
|
||||
*/
|
||||
export function PlanCard({ chatId, planId }: { chatId: string | null | undefined; planId: string }) {
|
||||
const plan = usePlan(chatId);
|
||||
const streaming = useStore(s => s.streaming);
|
||||
const [editing, setEditing] = useState(false), [draft, setDraft] = useState<string[]>([]), [busy, setBusy] = useState(false);
|
||||
useEffect(() => { setEditing(false); }, [chatId, plan?.id]);
|
||||
if (!chatId || !plan || plan.state === 'rejected') return null;
|
||||
if (!chatId || !plan || plan.id !== planId) return null;
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try { await fn(); }
|
||||
@@ -39,20 +52,23 @@ export function PlanCard({ chatId }: { chatId: string | null | undefined }) {
|
||||
<Btn size="sm" icon="plus" onClick={() => setDraft(d => [...d, ''])}>{tr("Add a step")}</Btn>
|
||||
</div>
|
||||
<div className="plan__acts">
|
||||
<Btn size="sm" variant="primary" disabled={busy || !draft.some(t => t.trim())} onClick={() => void run(async () => { await planAction(chatId, 'edit', draft.map(t => t.trim()).filter(Boolean)); setEditing(false); })}>{tr("Save plan")}</Btn>
|
||||
<Btn size="sm" variant="primary" disabled={busy || !draft.some(t => t.trim())} onClick={() => void run(async () => { await planEdit(chatId, plan, draft.map(t => t.trim()).filter(Boolean)); setEditing(false); })}>{tr("Save plan")}</Btn>
|
||||
<Btn size="sm" onClick={() => setEditing(false)}>{tr("Cancel")}</Btn>
|
||||
</div>
|
||||
</section>;
|
||||
return <section className="plan" data-state={plan.state} aria-label={tr("Plan")}>
|
||||
<div className="plan__head"><b>{tr("Plan")}</b><span>{plan.state === 'proposed' ? tr('{0} steps', [plan.steps.length]) : tr('{0} of {1} done', [stepsDone(plan), plan.steps.length])}</span></div>
|
||||
<div className="plan__head"><b>{tr("Plan")}</b><span>{planCount(plan)}</span></div>
|
||||
<PlanSteps plan={plan}/>
|
||||
{proposed ? <p className="plan__note">{tr("Nothing runs until you approve.")}</p> : null}
|
||||
{proposed ? <div className="plan__acts">
|
||||
<Btn size="sm" variant="primary" icon="check" disabled={busy} onClick={() => void run(() => planAction(chatId, 'approve'))}>{tr("Approve")}</Btn>
|
||||
<Btn size="sm" variant="primary" icon="check" disabled={busy || streaming} onClick={() => void run(() => approvePlan(chatId, plan.id))}>{tr("Approve")}</Btn>
|
||||
<Btn size="sm" icon="pencil" disabled={busy} onClick={() => { setDraft(plan.steps.map(s => s.text)); setEditing(true); }}>{tr("Edit")}</Btn>
|
||||
<Btn size="sm" disabled={busy} onClick={() => void run(() => planAction(chatId, 'reject'))}>{tr("Reject")}</Btn>
|
||||
<Btn size="sm" disabled={busy} onClick={() => void run(() => planReject(chatId, plan.id))}>{tr("Reject")}</Btn>
|
||||
<span className="plan__sp"/>
|
||||
<Btn size="sm" icon="bolt" disabled={busy} onClick={() => void run(() => planAction(chatId, 'start-goal'))}>{tr("Start as goal")}</Btn>
|
||||
<Btn size="sm" icon="bolt" disabled={busy || streaming} onClick={() => void run(() => startPlanGoal(plan.id))}>{tr("Start as goal")}</Btn>
|
||||
</div> : null}
|
||||
{plan.state === 'paused' ? <div className="plan__acts">
|
||||
<Btn size="sm" variant="primary" icon="play" disabled={busy || streaming} onClick={() => void run(() => startPlanGoal(plan.id))}>{tr("Resume")}</Btn>
|
||||
</div> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -62,8 +78,8 @@ export function PlanPanel({ chatId }: { chatId: string | null | undefined }) {
|
||||
const plan = usePlan(chatId);
|
||||
if (!plan) return <aside className="ts-panel plan-panel" id="plan-content" aria-label={tr("Plan")}><p className="ts-empty">{tr("There is no plan in this chat.")}</p></aside>;
|
||||
return <aside className="ts-panel plan-panel" id="plan-content" aria-label={tr("Plan")}>
|
||||
<div className="plan__head"><b>{tr("Plan")}</b><span>{tr('{0} of {1} done', [stepsDone(plan), plan.steps.length])}</span></div>
|
||||
{plan.goal ? <p className="plan__goal">{plan.goal}</p> : null}
|
||||
<div className="plan__head"><b>{tr("Plan")}</b><span>{planCount(plan)}</span></div>
|
||||
{plan.title ? <p className="plan__goal">{plan.title}</p> : null}
|
||||
<PlanSteps plan={plan}/>
|
||||
</aside>;
|
||||
}
|
||||
@@ -72,8 +88,8 @@ export function PlanPanel({ chatId }: { chatId: string | null | undefined }) {
|
||||
export function QueuedMessages({ chatId }: { chatId: string | null | undefined }) {
|
||||
const items = useQueue(chatId), plan = usePlan(chatId);
|
||||
if (!chatId || !items.length) return null;
|
||||
return <>{items.map((q: QueuedMessage) => {
|
||||
const when = queuedWhen(q, plan);
|
||||
return <>{items.map((q: QueuedMessage, i) => {
|
||||
const when = queuedWhen(q, i, plan);
|
||||
return <div className="queued" key={q.id}>
|
||||
<div className="queued__bubble">{q.text}</div>
|
||||
<div className="queued__line"><span>{tr(when.id, when.args)}</span>
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface Goal {
|
||||
round: number;
|
||||
maxRounds: number;
|
||||
blocked?: string;
|
||||
/** While a plan runs: which step the goal is on (notes-split-agent6.md). */
|
||||
step?: { index: number; total: number; text: string };
|
||||
}
|
||||
|
||||
const goals = new Map<string, Goal>();
|
||||
@@ -24,9 +26,17 @@ export function readGoal(raw: unknown): Goal | null {
|
||||
objective: g.objective, phase: phase as Goal['phase'],
|
||||
round: Number(g.round) || 0, maxRounds: Number(g.maxRounds) || 0,
|
||||
...(typeof g.blocked === 'string' && g.blocked ? { blocked: g.blocked } : {}),
|
||||
...readStep(g.step),
|
||||
};
|
||||
}
|
||||
|
||||
function readStep(raw: unknown): { step?: Goal['step'] } {
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
const s = raw as Record<string, unknown>;
|
||||
const index = Number(s.index), total = Number(s.total);
|
||||
return index > 0 && total >= index ? { step: { index, total, text: typeof s.text === 'string' ? s.text : '' } } : {};
|
||||
}
|
||||
|
||||
export function applyGoalFrame(chatId: string, raw: unknown) {
|
||||
const goal = readGoal(raw);
|
||||
const before = goals.get(chatId);
|
||||
@@ -60,5 +70,6 @@ export async function goalAction(chatId: string, action: 'pause' | 'edit' | 'cle
|
||||
export function goalProgress(goal: Goal): string {
|
||||
if (goal.phase === 'blocked') return goal.blocked ?? '';
|
||||
if (goal.phase === 'complete') return '';
|
||||
if (goal.step) return `${goal.step.index}/${goal.step.total}`;
|
||||
return goal.maxRounds > 0 && goal.round > 0 ? `${goal.round}/${goal.maxRounds}` : '';
|
||||
}
|
||||
+70
-26
@@ -1,19 +1,20 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { api } from './client';
|
||||
import { api, ApiError } from './client';
|
||||
|
||||
/**
|
||||
* A chat's /plan and its queued messages as the host reports them (contract in notes-split-agent6.md and notes-split-ui5.md). Both arrive as
|
||||
* whole snapshots, in the chat stream as {type:'plan', plan} and {type:'queue', items}, and as events {type:'plan'|'queue', chatId, ...}.
|
||||
* A snapshot replaces what was there; null or an empty list clears it. The host owns both; every button asks it and draws the answer.
|
||||
* A chat's /plan and its queued messages as the host reports them (notes-split-agent6.md, "Backend contract for the ui5 worker"). The plan
|
||||
* arrives whole every time it changes, in the chat stream as {type:'plan', event, plan} and as the SSE event 'plan' {chatId, event, plan}; the
|
||||
* queue as {type:'queued-message', queue} and the SSE event 'queue' {chatId, queue}. A snapshot replaces what was there; null or an empty
|
||||
* list clears it. The host owns both; every button asks it and draws the answer.
|
||||
*/
|
||||
export type PlanState = 'proposed' | 'approved' | 'running' | 'done' | 'rejected';
|
||||
export type StepState = 'pending' | 'active' | 'done' | 'failed';
|
||||
export interface PlanStep { id: string; text: string; note?: string; state: StepState }
|
||||
export interface Plan { id: string; goal: string; state: PlanState; steps: PlanStep[] }
|
||||
export interface QueuedMessage { id: string; text: string; after?: string }
|
||||
export type PlanState = 'proposed' | 'approved' | 'rejected' | 'running' | 'paused' | 'done' | 'stopped';
|
||||
export type StepState = 'pending' | 'running' | 'done' | 'failed' | 'skipped';
|
||||
export interface PlanStep { id: string; text: string; detail?: string; summary?: string; state: StepState }
|
||||
export interface Plan { id: string; title: string; source: 'plan' | 'goal'; state: PlanState; steps: PlanStep[] }
|
||||
export interface QueuedMessage { id: string; text: string; deliverAt: 'step' | 'end' }
|
||||
|
||||
const PLAN_STATES = ['proposed', 'approved', 'running', 'done', 'rejected'];
|
||||
const STEP_STATES = ['pending', 'active', 'done', 'failed'];
|
||||
const PLAN_STATES = ['proposed', 'approved', 'rejected', 'running', 'paused', 'done', 'stopped'];
|
||||
const STEP_STATES = ['pending', 'running', 'done', 'failed', 'skipped'];
|
||||
|
||||
export function readPlan(raw: unknown): Plan | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
@@ -25,13 +26,14 @@ export function readPlan(raw: unknown): Plan | null {
|
||||
const r = s as Record<string, unknown>;
|
||||
if (typeof r.text !== 'string' || !r.text.trim()) return;
|
||||
steps.push({
|
||||
id: typeof r.id === 'string' && r.id ? r.id : String(i + 1), text: r.text,
|
||||
id: typeof r.id === 'string' && r.id ? r.id : `s${i + 1}`, text: r.text,
|
||||
state: STEP_STATES.includes(String(r.state)) ? r.state as StepState : 'pending',
|
||||
...(typeof r.note === 'string' && r.note ? { note: r.note } : {}),
|
||||
...(typeof r.detail === 'string' && r.detail ? { detail: r.detail } : {}),
|
||||
...(typeof r.summary === 'string' && r.summary ? { summary: r.summary } : {}),
|
||||
});
|
||||
});
|
||||
if (!steps.length) return null;
|
||||
return { id: p.id, goal: typeof p.goal === 'string' ? p.goal : '', state: p.state as PlanState, steps };
|
||||
return { id: p.id, title: typeof p.title === 'string' ? p.title : '', source: p.source === 'goal' ? 'goal' : 'plan', state: p.state as PlanState, steps };
|
||||
}
|
||||
|
||||
export function readQueue(raw: unknown): QueuedMessage[] {
|
||||
@@ -40,7 +42,7 @@ export function readQueue(raw: unknown): QueuedMessage[] {
|
||||
if (!q || typeof q !== 'object') return [];
|
||||
const r = q as Record<string, unknown>;
|
||||
return typeof r.id === 'string' && r.id && typeof r.text === 'string' && r.text
|
||||
? [{ id: r.id, text: r.text, ...(typeof r.after === 'string' && r.after ? { after: r.after } : {}) }] : [];
|
||||
? [{ id: r.id, text: r.text, deliverAt: r.deliverAt === 'end' ? 'end' : 'step' }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,31 +84,73 @@ export function useQueue(chatId: string | null | undefined): QueuedMessage[] {
|
||||
}
|
||||
|
||||
export async function loadPlan(chatId: string) {
|
||||
try { applyPlanFrame(chatId, (await api.get<{ plan: unknown }>(`/api/plan?chatId=${encodeURIComponent(chatId)}`)).plan); }
|
||||
try { applyPlanFrame(chatId, (await api.get<{ plan: unknown }>(`/api/plan?sessionId=${encodeURIComponent(chatId)}`)).plan); }
|
||||
catch { /* an older host has no plan route: no plan */ }
|
||||
}
|
||||
|
||||
/** The waiting messages. Without `release` this only reads; the host never sends them from here. */
|
||||
export async function loadQueue(chatId: string) {
|
||||
try { applyQueueFrame(chatId, (await api.get<{ items: unknown }>(`/api/chat/queue?chatId=${encodeURIComponent(chatId)}`)).items); }
|
||||
try { applyQueueFrame(chatId, (await api.get<{ queue: unknown }>(`/api/chat/queue?sessionId=${encodeURIComponent(chatId)}`)).queue); }
|
||||
catch { /* an older host has no queue route: nothing queued */ }
|
||||
}
|
||||
|
||||
/** Approve, edit (with the new step texts), reject, or start the plan as a goal. The host answers with the plan as it now is. */
|
||||
export async function planAction(chatId: string, action: 'approve' | 'edit' | 'reject' | 'start-goal', steps?: string[]) {
|
||||
const result = await api.post<{ plan: unknown }>(`/api/plan/${action}`, { chatId, ...(steps ? { steps } : {}) });
|
||||
/** Save the edited steps. Each step keeps its grey detail line when its words did not change. */
|
||||
export async function planEdit(chatId: string, plan: Plan, texts: string[]) {
|
||||
const steps = texts.map((text, i) => { const was = plan.steps[i]; return was && was.text === text && was.detail ? { text, detail: was.detail } : text; });
|
||||
const result = await api.post<{ plan: unknown }>('/api/plan/edit', { sessionId: chatId, planId: plan.id, steps });
|
||||
applyPlanFrame(chatId, result.plan);
|
||||
}
|
||||
|
||||
export async function planReject(chatId: string, planId: string) {
|
||||
const result = await api.post<{ plan: unknown }>('/api/plan/reject', { sessionId: chatId, planId });
|
||||
applyPlanFrame(chatId, result.plan);
|
||||
}
|
||||
|
||||
/** Approve: the host answers with the plan and the message that carries it out; the caller sends that message as an ordinary turn. */
|
||||
export async function planApprove(chatId: string, planId: string): Promise<{ prompt: string }> {
|
||||
const result = await api.post<{ plan: unknown; prompt: string }>('/api/plan/approve', { sessionId: chatId, planId });
|
||||
applyPlanFrame(chatId, result.plan);
|
||||
return { prompt: String(result.prompt ?? '') };
|
||||
}
|
||||
|
||||
/** Put a message behind the running goal. Returns false when no goal is running (409), so the caller can send it the normal way. */
|
||||
export async function queueMessage(chatId: string, message: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await api.post<{ queue: unknown }>('/api/chat/queue', { sessionId: chatId, message });
|
||||
applyQueueFrame(chatId, result.queue);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Take a queued message back before it is sent. */
|
||||
export async function cancelQueued(chatId: string, id: string) {
|
||||
const result = await api.post<{ items: unknown }>('/api/chat/queue/cancel', { chatId, id });
|
||||
applyQueueFrame(chatId, result.items);
|
||||
const result = await api.post<{ queue: unknown }>('/api/chat/queue/cancel', { sessionId: chatId, id });
|
||||
applyQueueFrame(chatId, result.queue);
|
||||
}
|
||||
|
||||
/** The words under a queued message: when it will be sent. `after` is the step id it waits for, if the host names one. */
|
||||
export function queuedWhen(item: QueuedMessage, plan: Plan | null): { id: string; args?: unknown[] } {
|
||||
const step = item.after && plan ? plan.steps.findIndex(s => s.id === item.after) : -1;
|
||||
return step >= 0 ? { id: 'Queued — sends when step {0} finishes', args: [step + 1] } : { id: 'Queued — sends when the assistant is done' };
|
||||
/** The step the queued message waits for: the one running now, or the next that has not started. 1-based, 0 if there is none. */
|
||||
export function currentStep(plan: Plan | null): number {
|
||||
if (!plan) return 0;
|
||||
const running = plan.steps.findIndex(s => s.state === 'running');
|
||||
if (running >= 0) return running + 1;
|
||||
const next = plan.steps.findIndex(s => s.state === 'pending');
|
||||
return next >= 0 ? next + 1 : 0;
|
||||
}
|
||||
|
||||
/** The words under a queued message: when it will be sent. The first one goes out after the current step, the next ones after that. */
|
||||
export function queuedWhen(item: QueuedMessage, index: number, plan: Plan | null): { id: string; args?: unknown[] } {
|
||||
if (item.deliverAt === 'end') return { id: 'Queued — sends when the goal finishes' };
|
||||
if (index > 0) return { id: 'Queued — sends after that' };
|
||||
const step = currentStep(plan);
|
||||
return step > 0 ? { id: 'Queued — sends when step {0} finishes', args: [step] } : { id: 'Queued — sends when the goal finishes' };
|
||||
}
|
||||
|
||||
export const stepsDone = (plan: Plan) => plan.steps.filter(s => s.state === 'done').length;
|
||||
|
||||
/** The stored plan of a chat without asking the host (the one that asks is usePlan, used once per chat). */
|
||||
export function usePlanQuiet(chatId: string | null | undefined): Plan | null {
|
||||
return useSyncExternalStore(subscribe, () => planOf(chatId), () => null);
|
||||
}
|
||||
+57
-15
@@ -4,7 +4,7 @@ import { updateAgent } from './agents';
|
||||
import { LOOP_MAX_RUNS, LOOP_USAGE, loopEvery, parseLoop, type LoopCommand } from './loop';
|
||||
import { applyStatusFrame, clearStatus } from './live-status';
|
||||
import { applyGoalFrame } from './goal';
|
||||
import { applyPlanFrame, applyQueueFrame } from './plan';
|
||||
import { applyPlanFrame, applyQueueFrame, planApprove, queueMessage } from './plan';
|
||||
import { TIME_SKIP_DURATION, timeSkipPrompt, timeSkipTabId, withTimeSkipResult, type TimeSkipFile, type TimeSkipJob, type TimeSkipRun, type TimeSkipVersion } from './time-skip';
|
||||
import { PREVIEW_EXAMPLES, type PreviewArtifact } from './artifacts';
|
||||
import { lifecyclePolicy } from './lifecycle';
|
||||
@@ -641,7 +641,7 @@ function applyEvent(e: StreamEvent) {
|
||||
if (typeof data.chatId === 'string') applyPlanFrame(data.chatId, data.plan);
|
||||
break;
|
||||
case 'queue':
|
||||
if (typeof data.chatId === 'string') applyQueueFrame(data.chatId, data.items);
|
||||
if (typeof data.chatId === 'string') applyQueueFrame(data.chatId, data.queue);
|
||||
break;
|
||||
case 'timeskip':
|
||||
applyTimeSkipEvent(data);
|
||||
@@ -1482,12 +1482,32 @@ async function runLoopCommand(command: LoopCommand) {
|
||||
/** Carry on a paused goal: the host runs its rounds again. No message is sent and no bubble is added. */
|
||||
export async function resumeGoal() { await sendMessage('', { goalAction: 'resume' }); }
|
||||
|
||||
export async function sendMessage(text: string, options: { fromLoop?: boolean; goalAction?: 'resume' } = {}) {
|
||||
if(state.streaming)return;
|
||||
/** Approve a proposed plan and carry it out: the host answers with the message that does the work, which goes out as an ordinary turn. */
|
||||
export async function approvePlan(sessionId: string, planId: string) {
|
||||
const { prompt } = await planApprove(sessionId, planId);
|
||||
// A chat in Plan mode only reads; carrying the plan out needs a mode that may act.
|
||||
if (state.sessions.find(s => s.id === sessionId)?.preferences?.mode === 'plan') setChatPreferences(sessionId, { mode: 'manual' });
|
||||
if (prompt) await sendMessage(prompt, { shown: tr('Approved the plan.') });
|
||||
}
|
||||
|
||||
/** "Start as goal" (and Resume on a paused plan): the host streams the run like any chat turn. */
|
||||
export async function startPlanGoal(planId: string) { await sendMessage('', { planId }); }
|
||||
|
||||
export async function sendMessage(text: string, options: { fromLoop?: boolean; goalAction?: 'resume'; planId?: string; shown?: string } = {}) {
|
||||
if (state.streaming) {
|
||||
// Typed while the assistant works: behind a running goal it waits in the queue; otherwise there is nothing to do but wait.
|
||||
const waiting = state.activeSession, line = text.trim();
|
||||
if (waiting && line && !options.planId && !options.goalAction) {
|
||||
try { if (!(await queueMessage(waiting, line))) pushToast('info', 'The assistant is still answering', 'Send your message when it has finished.'); }
|
||||
catch (e) { pushToast('error', 'The message was not queued', plainFailureText(e instanceof Error ? e.message : String(e)).message); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
const resume = options.goalAction === 'resume';
|
||||
const loopCommand = options.fromLoop || resume ? null : parseLoop(text.trim());
|
||||
const planStart = options.planId;
|
||||
const loopCommand = options.fromLoop || resume || planStart ? null : parseLoop(text.trim());
|
||||
if (loopCommand) { await runLoopCommand(loopCommand); return; }
|
||||
const demoPrompt = resume ? null : timeSkipPrompt(text.trim());
|
||||
const demoPrompt = resume || planStart ? null : timeSkipPrompt(text.trim());
|
||||
if (demoPrompt !== null) {
|
||||
if (demoPrompt && (state.remoteModel || nativeState()?.instances.some(i => i.status === 'ready'))) void startTimeSkipReal(demoPrompt); else startTimeSkipDemo(demoPrompt);
|
||||
return;
|
||||
@@ -1496,13 +1516,13 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
if(pendingChatPhotos(state.activeSession).length&&!canSendPictures){pushToast('error','Choose an image-reading model','Switch to a local model with Read pictures enabled, or remove the attachments.');return;}
|
||||
const pictures=takeChatPhotos(state.activeSession);
|
||||
const body=text.trim()||(pictures.length?'Describe this picture.':'');
|
||||
if(!body&&!resume)return;
|
||||
if(!body&&!resume&&!planStart)return;
|
||||
|
||||
let sessionId = state.activeSession;
|
||||
if (!sessionId) sessionId = newSession().id;
|
||||
|
||||
const userMsg: ChatMessage = { id: uid('m'), role: 'user', content: body, at: new Date().toISOString(),...(pictures.length?{photos:pictures}:{}) };
|
||||
const assistantId = uid('m');
|
||||
const userMsg: ChatMessage = { id: uid('m'), role: 'user', content: options.shown ?? body, at: new Date().toISOString(),...(pictures.length?{photos:pictures}:{}) };
|
||||
let assistantId = uid('m');
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: assistantId,
|
||||
role: 'assistant',
|
||||
@@ -1533,9 +1553,9 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
patchSession(sessionId, (s) => ({
|
||||
...s,
|
||||
defaultTitle: false,
|
||||
title: s.messages.length === 0 && !resume ? (body.length>48?body.slice(0,47).trimEnd()+'…':body) : s.title,
|
||||
title: s.messages.length === 0 && !resume && !planStart ? (body.length>48?body.slice(0,47).trimEnd()+'…':body) : s.title,
|
||||
updatedAt: new Date().toISOString(),
|
||||
messages: [...s.messages, ...(resume ? [] : [userMsg]), assistantMsg],
|
||||
messages: [...s.messages, ...(resume || planStart ? [] : [userMsg]), assistantMsg],
|
||||
}));
|
||||
|
||||
const turnAbort = new AbortController();
|
||||
@@ -1555,10 +1575,11 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
const imageSlots=photoHistorySlots(prior,pictures.length);
|
||||
try {
|
||||
await streamPost(
|
||||
'/api/chat/stream',
|
||||
planStart ? '/api/plan/start-goal' : '/api/chat/stream',
|
||||
{
|
||||
sessionId,
|
||||
...(resume ? { goalAction: 'resume' } : { message: body }),
|
||||
...(planStart ? { planId: planStart } : {}),
|
||||
...(resume ? { goalAction: 'resume' } : planStart ? {} : { message: options.shown ? body : body }),
|
||||
history:await Promise.all(prior.map(async(m,index)=>{const included=canSendPictures?(m.photos??[]).filter((_,p)=>imageSlots.has(`${index}:${p}`)):[];const omitted=(m.photos??[]).filter(p=>!included.includes(p));const text=m.content+(omitted.length?`\n[Earlier pictures: ${omitted.map(p=>p.name).join(', ')}. Their pixels are outside this model's image context; ask to reattach them if needed.]`:'');return {role:m.role,content:included.length?[{type:'text',text},...await Promise.all(included.map(async photo=>({type:'image_url',image_url:{url:(await photoData(photo)).url}})))]:text};})),
|
||||
model: state.library?.loaded ?? state.library?.current ?? null,
|
||||
remoteModel: state.remoteModel,
|
||||
@@ -1587,8 +1608,24 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
applyGoalFrame(sessionId, frame.goal);
|
||||
} else if (type === 'plan') {
|
||||
applyPlanFrame(sessionId, frame.plan);
|
||||
} else if (type === 'queue') {
|
||||
applyQueueFrame(sessionId, frame.items);
|
||||
// A proposed plan is shown as the plan card in place of this turn's closing text (that text is the plan in prose).
|
||||
const planned = frame.plan as { id?: unknown } | null;
|
||||
if (frame.event === 'proposed' && planned && typeof planned.id === 'string') { const pid = planned.id; patchAssistant(m => ({ ...m, planId: pid })); }
|
||||
} else if (type === 'queued-message') {
|
||||
applyQueueFrame(sessionId, frame.queue);
|
||||
const sent = frame.message as { text?: unknown } | undefined;
|
||||
if (frame.state === 'delivered' && sent && typeof sent.text === 'string') {
|
||||
// The host has just sent it into the run: it becomes an ordinary message here, and the answer to it starts a new reply.
|
||||
const before = assistantId; assistantId = uid('m');
|
||||
const next: ChatMessage = { id: assistantId, role: 'assistant', content: '', at: new Date().toISOString(), streaming: true };
|
||||
patchSession(sessionId, sess => ({ ...sess, messages: [...sess.messages.map(m => m.id === before ? { ...m, streaming: false } : m), { id: uid('m'), role: 'user', content: sent.text as string, at: new Date().toISOString() }, next] }));
|
||||
}
|
||||
} else if (type === 'queued-release') {
|
||||
// The goal ended with messages left: they go out now, in order, as ordinary messages once this turn has finished.
|
||||
const left = Array.isArray(frame.messages) ? frame.messages.flatMap(q => (q && typeof (q as { text?: unknown }).text === 'string' ? [String((q as { text: string }).text)] : [])) : [];
|
||||
if (left.length) releasedMessages.set(sessionId, [...(releasedMessages.get(sessionId) ?? []), ...left]);
|
||||
} else if (type === 'plan-step') {
|
||||
/* the whole plan follows in a plan frame; the live status already says which step is running */
|
||||
} else if (type === 'reasoning') {
|
||||
patchAssistant((m) => ({ ...m, reasoning: (m.reasoning ?? '') + String(frame.text ?? ''), reasoningStartedAt: m.reasoningStartedAt ?? Date.now() }));
|
||||
} else if (type === 'notice') {
|
||||
@@ -1660,9 +1697,14 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
} finally {
|
||||
clearStatus(sessionId);
|
||||
markTurn(sessionId, null);
|
||||
const next = releasedMessages.get(sessionId)?.shift();
|
||||
if (next !== undefined) { if (!releasedMessages.get(sessionId)?.length) releasedMessages.delete(sessionId); setTimeout(() => void sendMessage(next), 0); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Messages the host handed back when a goal ended with some still waiting; sent one after another. */
|
||||
const releasedMessages = new Map<string, string[]>();
|
||||
|
||||
/* -------------------------------------------------------------- tool grants */
|
||||
|
||||
const noticed = new Set<string>();
|
||||
|
||||
@@ -318,6 +318,8 @@ export interface ChatMessage {
|
||||
/** Reply came from a saved API connection, rather than the local engine. */
|
||||
remoteModel?: boolean;
|
||||
timeSkipId?: string;
|
||||
/** The plan this reply proposed: shown as the plan card in place of the reply text. */
|
||||
planId?: string;
|
||||
/** A line from the app itself (for example from /loop), never sent to the model. */
|
||||
local?: boolean;
|
||||
ttftMs?: number;
|
||||
|
||||
@@ -3083,9 +3083,13 @@
|
||||
"s3087": "In der Warteschlange — wird gesendet, wenn Schritt {0} fertig ist",
|
||||
"s3088": "In der Warteschlange — wird gesendet, wenn der Assistent fertig ist",
|
||||
"s3089": "Diese Nachricht abbrechen",
|
||||
"s3090": "Die Nachricht wurde nicht abgebrochen",
|
||||
"s3091": "in Arbeit",
|
||||
"s3092": "fehlgeschlagen",
|
||||
"s3093": "wartet",
|
||||
"s3094": "Der Assistent schreibt zuerst einen Plan; nichts läuft, bevor Sie zustimmen"
|
||||
"s3090": "Fortsetzen",
|
||||
"s3091": "übersprungen",
|
||||
"s3092": "Pausiert · {0} von {1} erledigt",
|
||||
"s3093": "Plan genehmigt.",
|
||||
"s3094": "Der Assistent antwortet noch",
|
||||
"s3095": "Senden Sie Ihre Nachricht, wenn er fertig ist.",
|
||||
"s3096": "Die Nachricht wurde nicht eingereiht",
|
||||
"s3097": "In der Warteschlange — wird danach gesendet",
|
||||
"s3098": "In der Warteschlange — wird gesendet, wenn das Ziel erreicht ist"
|
||||
}
|
||||
@@ -3083,9 +3083,13 @@
|
||||
"s3087": "Queued — sends when step {0} finishes",
|
||||
"s3088": "Queued — sends when the assistant is done",
|
||||
"s3089": "Cancel this message",
|
||||
"s3090": "The message was not cancelled",
|
||||
"s3091": "working on it",
|
||||
"s3092": "failed",
|
||||
"s3093": "waiting",
|
||||
"s3094": "Have the assistant write a plan first; nothing runs until you approve it"
|
||||
"s3090": "Resume",
|
||||
"s3091": "skipped",
|
||||
"s3092": "Paused · {0} of {1} done",
|
||||
"s3093": "Approved the plan.",
|
||||
"s3094": "The assistant is still answering",
|
||||
"s3095": "Send your message when it has finished.",
|
||||
"s3096": "The message was not queued",
|
||||
"s3097": "Queued — sends after that",
|
||||
"s3098": "Queued — sends when the goal finishes"
|
||||
}
|
||||
@@ -3083,9 +3083,13 @@
|
||||
"s3087": "En cola — se envía cuando termine el paso {0}",
|
||||
"s3088": "En cola — se envía cuando el asistente termine",
|
||||
"s3089": "Cancelar este mensaje",
|
||||
"s3090": "No se canceló el mensaje",
|
||||
"s3091": "en curso",
|
||||
"s3092": "falló",
|
||||
"s3093": "en espera",
|
||||
"s3094": "El asistente escribe primero un plan; no se ejecuta nada hasta que lo apruebes"
|
||||
"s3090": "Reanudar",
|
||||
"s3091": "omitido",
|
||||
"s3092": "En pausa · {0} de {1} hechos",
|
||||
"s3093": "Plan aprobado.",
|
||||
"s3094": "El asistente aún está respondiendo",
|
||||
"s3095": "Envía tu mensaje cuando haya terminado.",
|
||||
"s3096": "No se pudo poner el mensaje en cola",
|
||||
"s3097": "En cola — se envía después",
|
||||
"s3098": "En cola — se envía cuando termine el objetivo"
|
||||
}
|
||||
@@ -3083,9 +3083,13 @@
|
||||
"s3087": "待機中 — ステップ {0} の完了後に送信",
|
||||
"s3088": "待機中 — アシスタントの作業完了後に送信",
|
||||
"s3089": "このメッセージを取り消す",
|
||||
"s3090": "メッセージは取り消されませんでした",
|
||||
"s3091": "作業中",
|
||||
"s3092": "失敗",
|
||||
"s3093": "待機中",
|
||||
"s3094": "まずアシスタントにプランを書かせます。承認するまで何も実行されません"
|
||||
"s3090": "再開",
|
||||
"s3091": "スキップ",
|
||||
"s3092": "一時停止中 · {1} 件中 {0} 件完了",
|
||||
"s3093": "プランを承認しました。",
|
||||
"s3094": "アシスタントはまだ回答中です",
|
||||
"s3095": "完了してからメッセージを送ってください。",
|
||||
"s3096": "メッセージを待機に入れられませんでした",
|
||||
"s3097": "待機中 — その後に送信",
|
||||
"s3098": "待機中 — ゴールの完了後に送信"
|
||||
}
|
||||
@@ -3083,9 +3083,13 @@
|
||||
"s3087": "대기 중 — {0}단계가 끝나면 전송됩니다",
|
||||
"s3088": "대기 중 — 어시스턴트가 끝나면 전송됩니다",
|
||||
"s3089": "이 메시지 취소",
|
||||
"s3090": "메시지가 취소되지 않았습니다",
|
||||
"s3091": "진행 중",
|
||||
"s3092": "실패",
|
||||
"s3093": "대기 중",
|
||||
"s3094": "어시스턴트가 먼저 계획을 작성합니다. 승인하기 전에는 아무것도 실행되지 않습니다"
|
||||
"s3090": "재개",
|
||||
"s3091": "건너뜀",
|
||||
"s3092": "일시 중지 · {1}개 중 {0}개 완료",
|
||||
"s3093": "계획을 승인했습니다.",
|
||||
"s3094": "어시스턴트가 아직 답하는 중입니다",
|
||||
"s3095": "끝난 뒤에 메시지를 보내 주세요.",
|
||||
"s3096": "메시지를 대기열에 넣지 못했습니다",
|
||||
"s3097": "대기 중 — 그 다음에 전송됩니다",
|
||||
"s3098": "대기 중 — 목표가 끝나면 전송됩니다"
|
||||
}
|
||||
@@ -3083,9 +3083,13 @@
|
||||
"s3087": "Na fila — enviada quando o passo {0} terminar",
|
||||
"s3088": "Na fila — enviada quando o assistente terminar",
|
||||
"s3089": "Cancelar esta mensagem",
|
||||
"s3090": "A mensagem não foi cancelada",
|
||||
"s3091": "em andamento",
|
||||
"s3092": "falhou",
|
||||
"s3093": "aguardando",
|
||||
"s3094": "O assistente escreve um plano primeiro; nada é executado até você aprovar"
|
||||
"s3090": "Retomar",
|
||||
"s3091": "ignorado",
|
||||
"s3092": "Pausado · {0} de {1} concluídos",
|
||||
"s3093": "Plano aprovado.",
|
||||
"s3094": "O assistente ainda está respondendo",
|
||||
"s3095": "Envie sua mensagem quando ele terminar.",
|
||||
"s3096": "A mensagem não foi colocada na fila",
|
||||
"s3097": "Na fila — enviada depois disso",
|
||||
"s3098": "Na fila — enviada quando a meta terminar"
|
||||
}
|
||||
@@ -3083,9 +3083,13 @@
|
||||
"s3087": "В очереди — отправится после шага {0}",
|
||||
"s3088": "В очереди — отправится, когда ассистент закончит",
|
||||
"s3089": "Отменить это сообщение",
|
||||
"s3090": "Сообщение не было отменено",
|
||||
"s3091": "в работе",
|
||||
"s3092": "не удалось",
|
||||
"s3093": "ожидает",
|
||||
"s3094": "Сначала ассистент пишет план; ничего не запустится, пока вы не одобрите"
|
||||
"s3090": "Продолжить",
|
||||
"s3091": "пропущено",
|
||||
"s3092": "Пауза · готово {0} из {1}",
|
||||
"s3093": "План одобрен.",
|
||||
"s3094": "Ассистент ещё отвечает",
|
||||
"s3095": "Отправьте сообщение, когда он закончит.",
|
||||
"s3096": "Сообщение не удалось поставить в очередь",
|
||||
"s3097": "В очереди — отправится после этого",
|
||||
"s3098": "В очереди — отправится, когда цель будет достигнута"
|
||||
}
|
||||
@@ -3083,9 +3083,13 @@
|
||||
"s3087": "已排队 — 第 {0} 步完成后发送",
|
||||
"s3088": "已排队 — 助手完成后发送",
|
||||
"s3089": "取消此消息",
|
||||
"s3090": "消息未被取消",
|
||||
"s3091": "进行中",
|
||||
"s3092": "失败",
|
||||
"s3093": "等待中",
|
||||
"s3094": "先让助手写计划;在你批准之前不会运行任何内容"
|
||||
"s3090": "继续",
|
||||
"s3091": "已跳过",
|
||||
"s3092": "已暂停 · 已完成 {0}/{1}",
|
||||
"s3093": "已批准计划。",
|
||||
"s3094": "助手仍在回答",
|
||||
"s3095": "等它完成后再发送你的消息。",
|
||||
"s3096": "消息未能排队",
|
||||
"s3097": "已排队 — 在此之后发送",
|
||||
"s3098": "已排队 — 目标完成后发送"
|
||||
}
|
||||
@@ -21,7 +21,8 @@ import { ReasoningBlock } from '../components/ReasoningBlock';
|
||||
import { StatusLine } from '../components/StatusLine';
|
||||
import { GoalBar } from '../components/GoalBar';
|
||||
import { PlanCard, PlanPanel, QueuedMessages } from '../components/PlanCard';
|
||||
import { usePlan } from '../lib/plan';
|
||||
import { queueMessage, usePlan, usePlanQuiet } from '../lib/plan';
|
||||
import { plainFailureText } from '../lib/plain-errors';
|
||||
import { useLiveStatus } from '../lib/live-status';
|
||||
import { askQuestion, askSub, askIcon } from '../lib/ask';
|
||||
import { artifactFromUrl, type PreviewArtifact } from '../lib/artifacts';
|
||||
@@ -332,8 +333,16 @@ export function ChatScreen() {
|
||||
}, [setupVisible, readyForChat, session?.id, messageCount]);
|
||||
function submit() {
|
||||
const text = draft.trim();
|
||||
if ((!text && !queuedPhotos.length) || streaming)
|
||||
if (!text && !queuedPhotos.length)
|
||||
return;
|
||||
if (streaming) {
|
||||
// Typed while a goal runs: it waits in the queue, shown under the conversation. With no goal running there is nothing to queue behind.
|
||||
if (!text || !session)
|
||||
return;
|
||||
updateDraft('');
|
||||
void queueMessage(session.id, text).then(ok => { if (!ok) { updateDraft(text); pushToast('info', tr('The assistant is still answering'), tr('Send your message when it has finished.')); } }, e => { updateDraft(text); pushToast('error', tr('The message was not queued'), plainFailureText(e instanceof Error ? e.message : String(e)).message); });
|
||||
return;
|
||||
}
|
||||
const timeSkip = timeSkipPrompt(text);
|
||||
if (timeSkip === '') {
|
||||
pushToast('info', 'Add a prompt after /time-skip');
|
||||
@@ -388,7 +397,7 @@ export function ChatScreen() {
|
||||
<div className="chat__inner">
|
||||
{tr(session.messages.length === 0 ? (session.statsAvailable === false ? <p className="small muted">{tr("Editing from an earlier message. Update the prompt below.")}</p> :
|
||||
<UsageDashboard loaded={!!loaded}/>) : (session.messages.map((m, i) => (<Message key={m.id} message={m} isLast={i === session.messages.length - 1} onEditPrompt={editPrompt} onPreview={openPreview} onOpenTimeSkip={openTimeSkip} timeSkipDone={!!timeSkipRuns.find(r => r.id === m.timeSkipId)?.completedAt} timeSkipReal={!!timeSkipRuns.find(r => r.id === m.timeSkipId)?.job} timeSkipRun={timeSkipRuns.find(r => r.id === m.timeSkipId)} onOpenLink={openLink}/>))))}
|
||||
<PlanCard chatId={session.id}/><QueuedMessages chatId={session.id}/>
|
||||
<QueuedMessages chatId={session.id}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="jumplatest-row">{tr(awayFromBottom ? (<button type="button" className="jumplatest" data-finished={justFinished || undefined} aria-label={tr("Scroll to the latest message")} title={tr("Scroll to the latest message")} onClick={() => scrollToLatest()}>
|
||||
@@ -558,6 +567,9 @@ function Message({ message: m, isLast, onEditPrompt, onPreview, onOpenLink, onOp
|
||||
const streaming = useStore(s => s.streaming);
|
||||
const activeSession = useStore(s => s.activeSession);
|
||||
const liveStatus = useLiveStatus(m.role === 'assistant' && m.streaming ? activeSession : null);
|
||||
// A reply that proposed a plan shows the plan card in place of its closing text (which is the plan in prose).
|
||||
const storedPlan = usePlanQuiet(m.planId ? activeSession : null);
|
||||
const planCard = !!m.planId && storedPlan?.id === m.planId;
|
||||
const [sentTime, setSentTime] = useState(() => relTime(m.at));
|
||||
if (m.role === 'user') {
|
||||
return (<div className="st-msg st-msg--user" onMouseEnter={() => setSentTime(relTime(m.at))} onFocus={() => setSentTime(relTime(m.at))}>
|
||||
@@ -583,8 +595,9 @@ function Message({ message: m, isLast, onEditPrompt, onPreview, onOpenLink, onOp
|
||||
{tr((m.tools ?? []).map((t) => (<ToolCard key={t.id} messageId={m.id} tool={t}/>)))}
|
||||
|
||||
{tr(!(m.timeSkipId && timeSkipReal && !timeSkipDone) && (m.content || m.error || !m.streaming || !liveStatus) ? <div className="st-bubble">
|
||||
{tr(planCard ? <PlanCard chatId={activeSession} planId={m.planId!}/> : null)}
|
||||
{tr(m.error ? (<ErrorNotice message={m.error} next={tr("Try sending the message again.")}/>) : null)}
|
||||
{tr(m.content ? (<Markdown text={m.timeSkipId && !timeSkipReal ? timeSkipDone ? tr('The result is ready. Switch between Original and Updated, or compare them side by side. This example uses sample outputs and simulated checks.') : tr('Revising the saved draft. Follow the result in the sidebar. This example uses sample outputs and simulated checks.') : m.content} onOpenLink={onOpenLink} onPreviewHtml={content => onPreview({ id: m.id + '-html', kind: 'website', title: 'HTML preview', content })}/>) : !m.error && m.streaming && !liveStatus ? (<span className="typing" aria-label={tr("Generating")}>
|
||||
{tr(m.content && !planCard ? (<Markdown text={m.timeSkipId && !timeSkipReal ? timeSkipDone ? tr('The result is ready. Switch between Original and Updated, or compare them side by side. This example uses sample outputs and simulated checks.') : tr('Revising the saved draft. Follow the result in the sidebar. This example uses sample outputs and simulated checks.') : m.content} onOpenLink={onOpenLink} onPreviewHtml={content => onPreview({ id: m.id + '-html', kind: 'website', title: 'HTML preview', content })}/>) : !m.error && m.streaming && !liveStatus ? (<span className="typing" aria-label={tr("Generating")}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
|
||||
@@ -7066,7 +7066,7 @@ textarea {
|
||||
.main__back:hover { color: var(--st-ink); }
|
||||
.task-header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.task-state { font-size: var(--st-fs-sm); font-weight: 700; color: var(--st-ink-muted); margin-left: 10px; }
|
||||
.task-state[data-state="active"], .task-state[data-state="running"] { color: var(--st-accent-text); }
|
||||
.task-state[data-state="running"], .task-state[data-state="running"] { color: var(--st-accent-text); }
|
||||
.task-detail { display: flex; flex-direction: column; gap: 16px; }
|
||||
.task-detail .label { display: block; font-size: var(--st-fs-xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--st-ink-muted); margin: 0 0 6px; }
|
||||
.task-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0; padding: 18px 4px; }
|
||||
@@ -7352,11 +7352,11 @@ textarea {
|
||||
.plan__step[data-state="done"] { color: var(--st-ink-muted); }
|
||||
.plan__step[data-state="done"] .plan__no { background: var(--st-accent); border-color: transparent; color: var(--st-accent-ink); }
|
||||
.plan__step[data-state="failed"] .plan__no { border-color: var(--st-danger, #d92d20); color: var(--st-danger, #d92d20); }
|
||||
.plan__step[data-state="active"] { font-weight: 700; }
|
||||
.plan__step[data-state="active"] .plan__no { border-color: var(--st-accent); color: var(--st-accent-text); animation: plan-glow 1.8s ease-out infinite; }
|
||||
.plan__step[data-state="running"] { font-weight: 700; }
|
||||
.plan__step[data-state="running"] .plan__no { border-color: var(--st-accent); color: var(--st-accent-text); animation: plan-glow 1.8s ease-out infinite; }
|
||||
.plan__text small { display: block; font-weight: 400; color: var(--st-ink-muted); font-size: var(--st-fs-sm); margin-top: 2px; }
|
||||
@keyframes plan-glow { 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, .4); } 70%, 100% { box-shadow: 0 0 0 8px transparent; } }
|
||||
@media (prefers-reduced-motion: reduce) { .plan__step[data-state="active"] .plan__no { animation: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { .plan__step[data-state="running"] .plan__no { animation: none; } }
|
||||
.plan__note { margin: 0; padding: 0 16px 12px; color: var(--st-ink-muted); font-size: var(--st-fs-sm); }
|
||||
.plan__acts { display: flex; gap: 8px; align-items: center; padding: 10px 12px 12px 16px; border-top: 1px solid var(--st-line-soft); }
|
||||
.plan__sp { flex: 1; }
|
||||
@@ -7372,3 +7372,5 @@ textarea {
|
||||
|
||||
.st-card--flush:has(> .privacy-notes) { border: 0; background: transparent; box-shadow: none; }
|
||||
.privacy-notes { padding: 2px 4px; }
|
||||
.plan__step[data-state="skipped"] { color: var(--st-ink-muted); text-decoration: line-through; }
|
||||
.plan__step[data-state="skipped"] .plan__text small { text-decoration: none; }
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Owner round 7: /plan card, plan steps in the sidebar, queued messages. Snapshots from the host replace what was there. */
|
||||
/** Owner round 7: /plan card, plan steps in the sidebar, queued messages, against agent6's contract (notes-split-agent6.md). */
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
@@ -14,45 +14,57 @@ async function load(file) {
|
||||
globalThis.window ??= globalThis; globalThis.localStorage ??= { getItem: () => null, setItem: () => {} };
|
||||
globalThis.navigator ??= { language: 'en' };
|
||||
const P = await load('plan.ts');
|
||||
const G = await load('goal.ts');
|
||||
|
||||
const plan = (state, steps) => ({ id: 'p1', goal: 'Pagoda', state, steps });
|
||||
const plan = (state, steps, extra = {}) => ({ id: 'p1', chatId: 'c', title: 'Pagoda', request: 'x', source: 'plan', state, goalStarted: false, steps, ...extra });
|
||||
const step = (id, text, state = 'pending', extra = {}) => ({ id, text, detail: null, state, summary: null, ...extra });
|
||||
|
||||
test('a plan snapshot is read, ordered, with step states; bad parts are dropped', () => {
|
||||
const p = P.readPlan(plan('running', [{ id: 'a', text: 'Look', state: 'done' }, { id: 'b', text: 'Write', state: 'active', note: 'one file' }, { text: 'Check' }, { text: ' ' }, 7]));
|
||||
test('a plan from the host is read with step states, detail and summary; bad parts are dropped', () => {
|
||||
const p = P.readPlan(plan('running', [step('s1', 'Look', 'done', { summary: 'Found two files.' }), step('s2', 'Write', 'running', { detail: 'one file' }), step('s3', 'Check'), step('s4', ' '), 7]));
|
||||
assert.equal(p.steps.length, 3);
|
||||
assert.deepEqual(p.steps.map(s => s.state), ['done', 'active', 'pending']);
|
||||
assert.equal(p.steps[2].id, '3');
|
||||
assert.equal(P.stepsDone(p), 1);
|
||||
assert.equal(P.readPlan(plan('weird', [{ text: 'x' }])), null);
|
||||
assert.deepEqual(p.steps.map(s => s.state), ['done', 'running', 'pending']);
|
||||
assert.equal(p.steps[0].summary, 'Found two files.'); assert.equal(p.steps[1].detail, 'one file');
|
||||
assert.equal(P.stepsDone(p), 1); assert.equal(P.currentStep(p), 2);
|
||||
assert.equal(P.readPlan(plan('weird', [step('s1', 'x')])), null);
|
||||
assert.equal(P.readPlan(plan('proposed', [])), null);
|
||||
assert.equal(P.readPlan(null), null);
|
||||
assert.equal(P.readPlan(plan('paused', [step('s1', 'x', 'skipped')])).steps[0].state, 'skipped');
|
||||
});
|
||||
|
||||
test('frames replace the plan; null clears it; the same snapshot changes nothing', () => {
|
||||
P.applyPlanFrame('c', plan('proposed', [{ text: 'A' }, { text: 'B' }]));
|
||||
test('plan frames replace the plan; null clears it; the same plan changes nothing', () => {
|
||||
P.applyPlanFrame('c', plan('proposed', [step('s1', 'A'), step('s2', 'B')]));
|
||||
assert.equal(P.planOf('c').state, 'proposed');
|
||||
P.applyPlanFrame('c', plan('running', [{ id: '1', text: 'A', state: 'active' }, { text: 'B' }]));
|
||||
assert.equal(P.planOf('c').steps[0].state, 'active');
|
||||
P.applyPlanFrame('c', plan('running', [step('s1', 'A', 'running'), step('s2', 'B')]));
|
||||
assert.equal(P.planOf('c').steps[0].state, 'running');
|
||||
assert.equal(P.planOf('other'), null);
|
||||
P.applyPlanFrame('c', null);
|
||||
assert.equal(P.planOf('c'), null);
|
||||
});
|
||||
|
||||
test('queued messages: a snapshot, the words say which step they wait for, cancel empties', () => {
|
||||
P.applyQueueFrame('c', [{ id: 'q1', text: 'Add a moon', after: '2' }, { id: 'q2', text: 'Warmer glow' }, { id: '', text: 'x' }, { id: 'q3' }]);
|
||||
test('queued messages: the first waits for the current step, the next ones "after that", a goal without points waits for the end', () => {
|
||||
P.applyQueueFrame('c', [{ id: 'q1', text: 'Add a moon', deliverAt: 'step' }, { id: 'q2', text: 'Warmer glow', deliverAt: 'step' }, { id: '', text: 'x' }, { id: 'q3' }]);
|
||||
const items = P.queueOf('c');
|
||||
assert.deepEqual(items.map(q => q.id), ['q1', 'q2']);
|
||||
const p = P.readPlan(plan('running', [{ id: '1', text: 'A', state: 'done' }, { id: '2', text: 'B', state: 'active' }]));
|
||||
assert.deepEqual(P.queuedWhen(items[0], p), { id: 'Queued — sends when step {0} finishes', args: [2] });
|
||||
assert.deepEqual(P.queuedWhen(items[1], p), { id: 'Queued — sends when the assistant is done' });
|
||||
const p = P.readPlan(plan('running', [step('s1', 'A', 'done'), step('s2', 'B', 'running')]));
|
||||
assert.deepEqual(P.queuedWhen(items[0], 0, p), { id: 'Queued — sends when step {0} finishes', args: [2] });
|
||||
assert.deepEqual(P.queuedWhen(items[1], 1, p), { id: 'Queued — sends after that' });
|
||||
assert.deepEqual(P.queuedWhen({ id: 'e', text: 'x', deliverAt: 'end' }, 0, null), { id: 'Queued — sends when the goal finishes' });
|
||||
P.applyQueueFrame('c', []);
|
||||
assert.deepEqual(P.queueOf('c'), []);
|
||||
});
|
||||
|
||||
test('the goal bar reads the step the host reports', () => {
|
||||
const g = G.readGoal({ objective: 'Build a small pagoda page', phase: 'active', round: 9, maxRounds: 50, step: { index: 2, total: 3, text: 'Write it' } });
|
||||
assert.deepEqual(g.step, { index: 2, total: 3, text: 'Write it' });
|
||||
assert.equal(G.goalProgress(g), '2/3');
|
||||
assert.equal(G.readGoal({ objective: 'x', phase: 'active', round: 1, maxRounds: 5 }).step, undefined);
|
||||
});
|
||||
|
||||
test('the chat and the sidebar draw the plan from the same store, and the stream feeds it', async () => {
|
||||
const [card, chat, store, client] = await Promise.all(['components/PlanCard.tsx', 'screens/Chat.tsx', 'lib/store.ts', 'lib/client.ts'].map(f => readFile(src(f), 'utf8')));
|
||||
assert.match(card, /export function PlanCard/); assert.match(card, /export function PlanPanel/); assert.match(card, /export function QueuedMessages/);
|
||||
assert.match(card, /Approve/); assert.match(card, /Start as goal/); assert.match(card, /planAction\(chatId, 'reject'\)/);
|
||||
assert.match(chat, /<PlanPanel chatId/); assert.match(chat, /plan:\$\{session\.id\}/);
|
||||
assert.match(store, /type === 'plan'/); assert.match(store, /type === 'queue'/); assert.match(client, /'plan', 'queue'/);
|
||||
assert.match(card, /approvePlan\(chatId, plan\.id\)/); assert.match(card, /startPlanGoal\(plan\.id\)/); assert.match(card, /planReject\(chatId, plan\.id\)/);
|
||||
assert.match(chat, /<PlanPanel chatId/); assert.match(chat, /plan:\$\{session\.id\}/); assert.match(chat, /planCard \? <PlanCard/);
|
||||
assert.match(store, /type === 'plan'/); assert.match(store, /type === 'queued-message'/); assert.match(store, /type === 'queued-release'/); assert.match(store, /\/api\/plan\/start-goal/);
|
||||
assert.match(client, /'plan', 'queue'/);
|
||||
});
|
||||
Reference in new issue
Block a user