mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] canvas: a mid-task-stopped chat wears a resume chip on its board pill instead of hiding the owed response inside the card (ENG-321)
This commit is contained in:
@@ -93,6 +93,7 @@ P_RELEASES: List[ReleaseNote] = [
|
||||
"An expired provider login heals itself mid-chat: the first failure rebuilds the connection and retries your message with zero clicks, and only a second failure asks you to reconnect. It used to take six manual steps every time a token aged out.",
|
||||
"A newly connected ChatGPT or Gemini subscription works immediately. The routing layer restarts itself the moment a connect completes, so new subscriptions no longer sit dead behind rate-limit errors until you restart the app.",
|
||||
"Finished apps opened from the dock no longer sit on \"Starting preview\" forever. Apps served straight from their built files have no server process by design, and the preview was waiting for one that would never exist.",
|
||||
"A chat that was cut off mid-answer now says so right on the board with an amber \"Stopped mid-task, click to resume\" chip, instead of looking idle until you open it and hunt for the resume button.",
|
||||
"Heavy sessions no longer vanish without a trace. When memory climbs past the safe line the app now sheds weight itself: preview thumbnails pause and refetchable caches drop, instead of growing until the operating system kills it mid-task.",
|
||||
],
|
||||
),
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
closeSession,
|
||||
fetchSession,
|
||||
renameSession,
|
||||
sendMessage as sendMessageThunk,
|
||||
} from '@/shared/state/agentsSlice';
|
||||
import { displayChatTitle, isLegacyAutoName } from '@/shared/state/sessionDisplay';
|
||||
import { Typewriter } from '@/app/components/feedback/Animated';
|
||||
@@ -747,6 +748,19 @@ const AgentCard: React.FC<Props> = ({
|
||||
}, [session.messages, session.status, session.last_message_preview]);
|
||||
const pillLabel = session.turn_label?.label || displayChatTitle(session);
|
||||
const pillRunning = session.status === 'running';
|
||||
// The boot restore marks a cut-off turn 'stopped' ONLY when the agent still owes a response
|
||||
// (SessionPersistence finalize); surfacing it here is what makes an interrupted chat visible
|
||||
// from the board instead of behind a Resume button inside the card (ENG-321).
|
||||
const pillInterrupted = session.status === 'stopped' && !session.workflow_run_id;
|
||||
const handleResumeInterrupted = React.useCallback(() => {
|
||||
dispatch(sendMessageThunk({
|
||||
sessionId: session.id,
|
||||
prompt: "Continue your previous response from exactly where it was cut off. Do not repeat anything you already wrote; pick up mid-sentence if you need to and keep going.",
|
||||
mode: session.mode,
|
||||
model: session.model,
|
||||
hidden: true,
|
||||
}));
|
||||
}, [dispatch, session.id, session.mode, session.model]);
|
||||
|
||||
// Cold-loaded collapsed cards carry no transcript (status frames are slim), so the pill can't pin
|
||||
// its widget/checklist artifact; hydrate ONCE per card actually on this dashboard, never in a loop.
|
||||
@@ -1074,6 +1088,8 @@ const AgentCard: React.FC<Props> = ({
|
||||
<AgentNarratorPill
|
||||
label={pillLabel}
|
||||
running={pillRunning}
|
||||
interrupted={pillInterrupted}
|
||||
onResumeInterrupted={handleResumeInterrupted}
|
||||
todos={todos}
|
||||
liveSteps={liveSteps}
|
||||
artifact={pillArtifact}
|
||||
|
||||
@@ -25,6 +25,9 @@ interface AgentNarratorPillProps {
|
||||
browserShot: string | null;
|
||||
/** The turn's plain-text answer, shown when the turn produced no richer artifact. */
|
||||
finalText?: string | null;
|
||||
/** App quit mid-turn and this agent still owes a response; click resumes it (ENG-321). */
|
||||
interrupted?: boolean;
|
||||
onResumeInterrupted?: () => void;
|
||||
selected: boolean;
|
||||
highlighted: boolean;
|
||||
}
|
||||
@@ -34,7 +37,7 @@ const GLASS_BLUR = GLASS_SURFACE_BLUR;
|
||||
const MAX_VISIBLE_TODOS = 4;
|
||||
|
||||
/** Collapsed agent as the desktop narrator pill; below it, the best artifact wins: live question > widget > browser shot > plan > live steps > Thinking. */
|
||||
function AgentNarratorPill({ label, running, todos, liveSteps, artifact, askPair, sessionId, browserShot, finalText, selected, highlighted }: AgentNarratorPillProps): React.ReactElement {
|
||||
function AgentNarratorPill({ label, running, todos, liveSteps, artifact, askPair, sessionId, browserShot, finalText, interrupted, onResumeInterrupted, selected, highlighted }: AgentNarratorPillProps): React.ReactElement {
|
||||
const visibleTodos = (todos || []).slice(0, MAX_VISIBLE_TODOS);
|
||||
const hiddenCount = (todos?.length || 0) - visibleTodos.length;
|
||||
// Live tool steps window to the most recent, since earlier ones are history, not plan.
|
||||
@@ -44,7 +47,7 @@ function AgentNarratorPill({ label, running, todos, liveSteps, artifact, askPair
|
||||
const ring = selected || highlighted ? { outline: '2px solid #3b82f6', outlineOffset: '2px' } : undefined;
|
||||
const liveAsk = askPair && sessionId ? askPair : null;
|
||||
// One key per ladder state so a state CHANGE remounts the artifact and replays the one-shot entrance; nothing loops.
|
||||
const artifactKey = liveAsk ? `ask-${liveAsk.id}` : shownArtifact ? 'widget' : browserShot ? 'shot' : visibleTodos.length > 0 ? 'todos' : visibleSteps.length > 0 ? 'steps' : running ? 'thinking' : finalText ? 'final' : 'none';
|
||||
const artifactKey = interrupted ? 'interrupted' : liveAsk ? `ask-${liveAsk.id}` : shownArtifact ? 'widget' : browserShot ? 'shot' : visibleTodos.length > 0 ? 'todos' : visibleSteps.length > 0 ? 'steps' : running ? 'thinking' : finalText ? 'final' : 'none';
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -111,7 +114,36 @@ function AgentNarratorPill({ label, running, todos, liveSteps, artifact, askPair
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{liveAsk ? (
|
||||
{interrupted ? (
|
||||
// Interrupted wins the ladder: a chat that owes a response must read that from the BOARD,
|
||||
// not only after opening the card (ENG-321). 'stopped' covers user-stop AND app-restart
|
||||
// cuts with no persisted discriminator, so the copy stays true for both.
|
||||
<Box
|
||||
key={artifactKey}
|
||||
className="osw-artifact"
|
||||
onClick={(e) => { e.stopPropagation(); onResumeInterrupted?.(); }}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 1.5,
|
||||
py: 0.875,
|
||||
borderRadius: '14px',
|
||||
cursor: 'pointer',
|
||||
background: 'rgba(217,119,6,0.16)',
|
||||
border: '1px solid rgba(245,158,11,0.45)',
|
||||
backdropFilter: GLASS_BLUR,
|
||||
WebkitBackdropFilter: GLASS_BLUR,
|
||||
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
|
||||
'&:hover': { background: 'rgba(217,119,6,0.26)' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: '#f59e0b' }} />
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: '#fbbf24' }}>
|
||||
Stopped mid-task, click to resume
|
||||
</Typography>
|
||||
</Box>
|
||||
) : liveAsk ? (
|
||||
<PillArtifactFrame key={artifactKey} name="question">
|
||||
{/* One glass surface holds the whole ask (options + Confirm + the type-your-own field); without it the widget's footer floated bare on the canvas. */}
|
||||
<Box sx={{ borderRadius: '16px', background: GLASS, backdropFilter: GLASS_BLUR, WebkitBackdropFilter: GLASS_BLUR, boxShadow: '0 8px 24px rgba(0,0,0,0.32)', px: 1.25, py: 1.25 }}>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// A chat cut off mid-turn was invisible from the board: the only recovery affordance was a Resume
|
||||
// pill inside the opened card, so Eric read a restored board as "agents aren't even running"
|
||||
// (ENG-321). These pin the board-level signal and its one-click resume.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const here = path.join(process.cwd(), 'src/app/pages/Dashboard/desktop');
|
||||
const pill = fs.readFileSync(path.join(here, 'AgentNarratorPill.tsx'), 'utf8');
|
||||
const card = fs.readFileSync(path.join(here, '../cards/AgentCard.tsx'), 'utf8');
|
||||
|
||||
test('interrupted wins the artifact ladder so nothing hides it', () => {
|
||||
const key = pill.slice(pill.indexOf('const artifactKey'), pill.indexOf(';', pill.indexOf('const artifactKey')));
|
||||
assert.ok(key.includes("interrupted ? 'interrupted'"), 'a browser shot or old answer must not out-rank the owed-response signal');
|
||||
assert.ok(pill.includes('{interrupted ? ('), 'the chip must render at the ladder top');
|
||||
});
|
||||
|
||||
test('the chip resumes with one click and does not select the card', () => {
|
||||
const chip = pill.slice(pill.indexOf('{interrupted ? ('), pill.indexOf(') : liveAsk ? ('));
|
||||
assert.ok(chip.includes('onResumeInterrupted?.()'), 'a signal without the action is half the fix');
|
||||
assert.ok(chip.includes('stopPropagation'), 'the click must not fall through to card-select');
|
||||
assert.ok(chip.includes('Stopped mid-task'), "copy must stay true for user-stop too; 'stopped' has no persisted cause");
|
||||
});
|
||||
|
||||
test('the card derives interrupted from stopped, workflow sidecars excluded', () => {
|
||||
assert.ok(card.includes("session.status === 'stopped' && !session.workflow_run_id"), 'run sidecars own pause/resume from the workflow card');
|
||||
assert.ok(card.includes('interrupted={pillInterrupted}'), 'wire-check: the flag must reach the pill');
|
||||
assert.ok(card.includes('onResumeInterrupted={handleResumeInterrupted}'));
|
||||
});
|
||||
|
||||
test('the resume dispatch is the same hidden continue the in-card button sends', () => {
|
||||
const start = card.indexOf('handleResumeInterrupted');
|
||||
const h = card.slice(start, card.indexOf('}, [dispatch, session.id', start));
|
||||
assert.ok(h.includes('hidden: true'), 'a visible synthetic prompt would litter the transcript');
|
||||
assert.ok(h.includes('pick up mid-sentence'), 'keep the proven continue prompt, not a new dialect');
|
||||
});
|
||||
Reference in New Issue
Block a user