Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 144 additions & 91 deletions src/components/organisms/Studio/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,16 @@ import {
stoppedQueuedSwitch,
switchEnded,
endedSwitch,
switchLine,
switchReply,
unreachableSwitch,
optionLabel,
withApprovedPlan,
questionPause,
withQuestion,
answeredPause,
type ApprovedPlan,
type Pause,
type PlanState,
} from '@/lib/studio/plan';
import { withAnswer } from '@/lib/studio/transcript';
Expand Down Expand Up @@ -280,12 +285,9 @@ export const useChat = (initial?: ChatSnapshot) => {
// The pending pause's addressing (interrupt_id) with, for elicitations,
// the normalized clarify that answerClarify() builds its reply and
// status note from, and that skipClarify() reads `skippable` from.
// For plans, the plan that switchChoice() checks a pick against.
const pendingInterruptRef = useRef<{
interruptId: string;
clarify?: Clarify;
plan?: PlanState;
} | null>(null);
// For plans, the plan that switchChoice() checks a pick against. A
// question a switch's replan asked carries the switch's waiting plan.
const pendingInterruptRef = useRef<Pause | null>(null);
// The latest plan this turn offered, or is waiting on a switch of. A
// fresh plan.proposed carries its baseline (what the turn's first plan
// held) and learns which choice its switch moved. A new turn clears it.
Expand Down Expand Up @@ -692,20 +694,19 @@ export const useChat = (initial?: ChatSnapshot) => {
// follows re-renders the card.
break;
case 'elicitation.request': {
// Also a question a switch's replan asks, which takes
// the waiting card's place and carries the switch.
const clarify = clarifyFromFrame(frame);

stall.stop();
grace.cancel();
pendingInterruptRef.current = {
interruptId: clarify.interruptId,
pendingInterruptRef.current = questionPause(
clarify,
};
setState((prev) => ({
...prev,
...NO_LINE,
thinking: false,
clarify,
}));
pendingInterruptRef.current,
);
setState((prev) =>
withQuestion({ ...prev, ...NO_LINE, thinking: false }, clarify),
);
break;
}
case 'suggestions': {
Expand Down Expand Up @@ -1026,11 +1027,98 @@ export const useChat = (initial?: ChatSnapshot) => {
setState((prev) => ({ ...prev, queued: prev.queued.filter((item) => item.id !== id) }));
}, []);

// A switch's stream, from the pick (switchChoice) or from the answer to
// a question its replan asked (resume), while `waiting` is the pending
// plan. A fresh plan.proposed, its `switch_failed` included, replaces
// the card through the stream; after Stop the frames are still read, so
// the plan the harness re-offers on cancel ends the switch. A stream that
// ends without a plan (it closed, broke, sent `done` or an error, or was
// stopped) ends the card read-only, once, closing the turn's Activity
// entry (endedSwitch) and leaving the reply alone. `refuse` gives the card
// for a refusal, which stays up when live and is the read-only end
// otherwise; `unreached` and `unsent` give the live card for a request
// that never reached the harness and one stopped before it started.
// Without them a refusal or an unreached request ends as an error, and a
// Stop before the request started as stopped.
const streamSwitch = useCallback(
(
turn: number,
waiting: PlanState,
start: (handlers: StreamHandlers, signal: AbortSignal) => Promise<void>,
{
refuse,
unreached,
unsent,
}: {
refuse?: (refusal: StreamRefusal) => PlanState;
unreached?: () => PlanState;
unsent?: () => PlanState;
} = {},
) => {
// The read-only card a refusal that may have used up the pause
// ends on.
let refused: PlanState | null = null;

stream(turn, start, {
onRefusal:
refuse &&
((refusal) => {
const card = refuse(refusal);

if (card.live) {
offerPlan(card);
} else {
refused = card;
}

return true;
}),
onUnreachable:
unreached &&
(() => {
offerPlan(unreached());

return true;
}),
onEnd: ({ error, stopped, sent }) => {
// A fresh plan, a refusal or a question replaced the
// waiting card.
if (pendingInterruptRef.current?.plan !== waiting) {
return;
}

if (!sent && unsent) {
offerPlan(unsent());

return;
}

const { plan: ended, activity } = endedSwitch(waiting, {
error,
stopped,
refused,
});

pendingInterruptRef.current = null;
turnPlanRef.current = null;
setState((prev) => {
const after = switchEnded(prev, waiting, ended);

return after === prev ? prev : endTurn(after, turn, activity);
});
},
});
},
[offerPlan, stream],
);

// Continue a paused turn on /interrupt — shared by clarify answers, the
// skip button and the plan actions. `answer` is what the user chose or
// wrote, which enters the transcript.
// A stale plan answer (an approve sent from an out-of-date card) is
// refused with the current card, which replaces it to be answered again.
// The answer to a question a switch's replan asked puts the waiting card
// back and resumes as the switch (streamSwitch).
const resume = useCallback(
(
reply: ClarifyReply | Pick<InterruptResponse, 'decision' | 'plan_revision'>,
Expand All @@ -1039,8 +1127,9 @@ export const useChat = (initial?: ChatSnapshot) => {
) => {
const conversation = sessionRef.current;
const pending = pendingInterruptRef.current;
const after = answeredPause(pending);

pendingInterruptRef.current = null;
pendingInterruptRef.current = after;

setState((prev) => ({
// The decision shows on the status line; the user's answer
Expand All @@ -1050,31 +1139,35 @@ export const useChat = (initial?: ChatSnapshot) => {
messages: answer && pending ? withAnswer(prev.messages, answer) : prev.messages,
thinking: true,
clarify: null,
plan: null,
plan: after?.plan ?? null,
}));

if (!conversation || !pending) {
return;
}

stream(
turnCount.current,
(handlers, signal) =>
runInterrupt(conversation, pending.interruptId, reply, handlers, signal),
{
onRefusal: (refusal) => {
const current = stalePlan(refusal, pending?.plan);
const start = (handlers: StreamHandlers, signal: AbortSignal) =>
runInterrupt(conversation, pending.interruptId, reply, handlers, signal);

if (current) {
offerPlan(current);
}
if (after?.plan) {
streamSwitch(turnCount.current, after.plan, start);

return current !== null;
},
return;
}

stream(turnCount.current, start, {
onRefusal: (refusal) => {
const current = stalePlan(refusal, pending?.plan);

if (current) {
offerPlan(current);
}

return current !== null;
},
);
});
},
[offerPlan, stream],
[offerPlan, stream, streamSwitch],
);

// Answer every question of the pending clarify at once, keyed by
Expand Down Expand Up @@ -1141,17 +1234,16 @@ export const useChat = (initial?: ChatSnapshot) => {
[resume],
);

// A scope decision's option, picked on the plan card. The card stays,
// its controls waiting, until the harness re-offers the plan with what
// changed, or with `switch_failed` saying why it did not apply. A
// refusal that leaves the pause alone keeps the card live and says why on
// the choice's row (refusedSwitch); a stale one (the plan moved on
// elsewhere) is answered with the current card, and any other ends the
// card read-only. So does a stream that ends without a plan (it closed,
// broke, sent `done` or an error, or was stopped), once, closing the
// turn's Activity entry (endedSwitch) and leaving the reply alone. A
// A scope choice's or planner decision's option, picked on the plan card.
// The card stays, its controls waiting, until the harness re-offers the
// plan with what changed, or with `switch_failed` saying why it did not
// apply. The page sets no deadline of its own, since a planner
// decision's replan takes tens of seconds. A refusal that leaves the
// pause alone keeps the card live and says why on the choice's row
// (refusedSwitch); a stale one (the plan moved on elsewhere) is answered
// with the current card, and any other ends the card read-only. A
// request that never reached the harness keeps the card live, with "Try
// again".
// again". The rest of the switch's end is streamSwitch's.
const switchChoice = useCallback(
(choiceId: string, optionId: string) => {
const conversation = sessionRef.current;
Expand All @@ -1165,9 +1257,6 @@ export const useChat = (initial?: ChatSnapshot) => {

const label = optionLabel(plan.decisions, choiceId, optionId);
const turn = turnCount.current;
// The read-only card a refusal that may have used up the pause
// ends on.
let refused: PlanState | null = null;

// The pause stays pending with the waiting plan. switchReply
// refuses a second pick while it waits, and the plan the harness
Expand All @@ -1177,72 +1266,36 @@ export const useChat = (initial?: ChatSnapshot) => {
pendingInterruptRef.current = { interruptId: pending.interruptId, plan: waiting };
turnPlanRef.current = waiting;
setState((prev) => ({
...withLine(prev, label ? `Switching to ${label}` : 'Switching'),
...withLine(prev, switchLine(choiceId, label)),
thinking: true,
plan: waiting,
}));

stream(
streamSwitch(
turn,
waiting,
(handlers, signal) =>
runInterrupt(conversation, pending.interruptId, reply, handlers, signal),
{
onRefusal: (refusal) => {
const card = refusedSwitch(waiting, refusal);

if (card.live) {
offerPlan(card);
} else {
refused = card;
}

refuse: (refusal) => {
// The card shows only a harness ToolError's detail.
if (!refusal.error && refusal.detail) {
console.warn('Studio: a switch was refused', refusal);
}

return true;
return refusedSwitch(waiting, refusal);
},
// The request never reached the harness, so the pause
// waits.
onUnreachable: () => {
offerPlan(unreachableSwitch(waiting));

return true;
},
onEnd: ({ error, stopped, sent }) => {
// A fresh plan or a refusal replaced the waiting card.
if (pendingInterruptRef.current?.plan !== waiting) {
return;
}

// Stopped while it waited behind the stream that
// offered the plan. Nothing reached the harness, so
// the pause waits.
if (!sent) {
offerPlan(stoppedQueuedSwitch(waiting));

return;
}

const { plan: ended, activity } = endedSwitch(waiting, {
error,
stopped,
refused,
});

pendingInterruptRef.current = null;
turnPlanRef.current = null;
setState((prev) => {
const after = switchEnded(prev, waiting, ended);

return after === prev ? prev : endTurn(after, turn, activity);
});
},
unreached: () => unreachableSwitch(waiting),
// Stopped while it waited behind the stream that offered
// the plan. Nothing reached the harness, so the pause
// waits.
unsent: () => stoppedQueuedSwitch(waiting),
},
);
},
[offerPlan, stream],
[streamSwitch],
);

// A clicked `apply_choice` chip runs a turn with the chip's choice and no
Expand Down
7 changes: 4 additions & 3 deletions src/components/organisms/Studio/workspace/PlanCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ type PlanCardProps = {

/**
* The research plan awaiting review, in the chat foot: the title and its
* summary, the scope choices ("Counted as", each switchable in place), the
* step that gives the answer, what to keep in mind about the answer, and
* "View full plan" to every step in a modal. Then the "Start building?" bar
* summary, the scope choices ("Counted as") and planner decisions ("Choices
* the plan made"), each switchable in place, the step that gives the
* answer, what to keep in mind about the answer, and "View full plan" to
* every step in a modal. Then the "Start building?" bar
* with Cancel and Approve. Changes travel through the chat input below
* (ChatPanel repurposes it as "Propose changes or ask a follow up…" while
* the plan is pending, wired to the amend decision).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
vertical-align: rem(1);
}

// --- "Counted as": the scope choices as a ruled ledger ----------------------
// --- The switchable choices as a ruled ledger, one head a group ------------

// A host sets --ledger-inset to align the rows with its own padding.
.ledger {
Expand All @@ -51,6 +51,11 @@
letter-spacing: -0.02em;
}

// A group's head after another group's rows.
.row + .head {
border-top: 1px solid var(--color-net-light);
}

.row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
Expand Down
Loading
Loading