Skip to content

Commit df05135

Browse files
Merge branch 'Th0rgal:master' into master
2 parents 768c466 + cf433bb commit df05135

11 files changed

Lines changed: 447 additions & 69 deletions

File tree

dashboard/bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dashboard/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,24 +45,25 @@
4545
"tailwind-merge": "^3.4.0",
4646
"xterm": "^5.3.0",
4747
"xterm-addon-fit": "^0.8.0",
48+
"yaml": "^2.9.0",
4849
"zod": "^4.2.0"
4950
},
5051
"devDependencies": {
5152
"@playwright/test": "^1.57.0",
53+
"@tailwindcss/postcss": "^4",
5254
"@testing-library/dom": "^10.4.0",
5355
"@testing-library/jest-dom": "^6.6.3",
5456
"@testing-library/react": "^16.3.0",
55-
"@vitejs/plugin-react": "^4.5.2",
56-
"@tailwindcss/postcss": "^4",
5757
"@types/node": "^20",
5858
"@types/react": "19.2.7",
5959
"@types/react-dom": "^19",
60+
"@vitejs/plugin-react": "^4.5.2",
6061
"eslint": "^9",
6162
"eslint-config-next": "16.0.10",
63+
"jsdom": "^26.1.0",
6264
"sharp": "^0.34.5",
6365
"tailwindcss": "^4",
6466
"typescript": "^5",
65-
"jsdom": "^26.1.0",
6667
"vitest": "^3.2.4"
6768
}
6869
}

dashboard/src/app/config/skills/page.tsx

Lines changed: 23 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import { useState, useRef, useEffect, useCallback } from 'react';
4+
import YAML from "yaml";
45
import {
56
getLibrarySkill,
67
getSkillReference,
@@ -131,46 +132,38 @@ function parseFrontmatter(content: string): { frontmatter: Frontmatter; body: st
131132
const yamlStr = content.substring(4, endIndex);
132133
const body = content.substring(endIndex + 4).trimStart();
133134

134-
// Simple YAML parsing for key: value pairs
135+
// Real YAML parsing: block scalars (`description: >`), quoted strings,
136+
// and escapes must round-trip. A previous hand-rolled line parser read a
137+
// block-scalar description as the literal string ">" and saving then
138+
// destroyed the real description.
135139
const frontmatter: Frontmatter = {};
136-
for (const line of yamlStr.split('\n')) {
137-
const match = line.match(/^(\w+):\s*(.*)$/);
138-
if (match) {
139-
frontmatter[match[1]] = match[2].replace(/^["']|["']$/g, '');
140+
try {
141+
const parsed = YAML.parse(yamlStr);
142+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
143+
for (const [k, v] of Object.entries(parsed)) {
144+
if (v === null || v === undefined) continue;
145+
frontmatter[k] = typeof v === 'string' ? v : YAML.stringify(v).trimEnd();
146+
}
140147
}
148+
} catch {
149+
// Unparseable frontmatter: drop the (rare, machine-written) bad block and
150+
// keep the post-delimiter body. Returning the whole file as body would
151+
// duplicate the --- block once any field is added and re-saved.
152+
return { frontmatter: {}, body };
141153
}
142154

143155
return { frontmatter, body };
144156
}
145157

146-
// YAML special characters that require quoting
147-
const YAML_SPECIAL_CHARS = [':', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', "'", '"', '%', '@', '`'];
148-
149-
/**
150-
* Format a YAML value, quoting if it contains special characters.
151-
* This prevents YAML parsing errors when descriptions contain colons (e.g., "Triggers: foo, bar").
152-
*/
153-
function formatYamlValue(value: string): string {
154-
// Check if value needs quoting
155-
const needsQuoting = YAML_SPECIAL_CHARS.some(char => value.includes(char));
156-
157-
if (needsQuoting) {
158-
// Escape backslashes and double quotes, then wrap in quotes
159-
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
160-
return `"${escaped}"`;
161-
}
162-
163-
return value;
164-
}
165-
166158
function buildContent(frontmatter: Frontmatter, body: string): string {
167159
const entries = Object.entries(frontmatter).filter(([, v]) => v !== undefined && v !== '');
168160
if (entries.length === 0) {
169161
return body;
170162
}
171163

172-
// Quote values that contain YAML special characters
173-
const yaml = entries.map(([k, v]) => `${k}: ${formatYamlValue(v!)}`).join('\n');
164+
// Serialize through the YAML library so quoting/escaping is always valid
165+
// and round-trips with parseFrontmatter.
166+
const yaml = YAML.stringify(Object.fromEntries(entries), { lineWidth: 0 }).trimEnd();
174167
return `---\n${yaml}\n---\n\n${body}`;
175168
}
176169

@@ -285,9 +278,6 @@ function FrontmatterEditor({ frontmatter, onChange, disabled }: FrontmatterEdito
285278
onChange({ ...frontmatter, [key]: value || undefined });
286279
};
287280

288-
// Check if description contains special YAML characters
289-
const descriptionHasSpecialChars = frontmatter.description &&
290-
YAML_SPECIAL_CHARS.some(char => frontmatter.description?.includes(char));
291281

292282
return (
293283
<div className="space-y-3 p-3 rounded-lg bg-white/[0.02] border border-white/[0.06]">
@@ -296,20 +286,14 @@ function FrontmatterEditor({ frontmatter, onChange, disabled }: FrontmatterEdito
296286
<div className="space-y-2">
297287
<div>
298288
<label className="block text-xs text-white/40 mb-1">Description *</label>
299-
<input
300-
type="text"
289+
<textarea
290+
rows={2}
301291
value={frontmatter.description || ''}
302292
onChange={(e) => updateField('description', e.target.value)}
303293
placeholder="Brief description of what this skill does"
304-
className="w-full px-3 py-1.5 text-xs rounded-lg bg-white/[0.04] border border-white/[0.08] text-white placeholder:text-white/30 focus:outline-none focus:border-indigo-500/50"
294+
className="w-full resize-y px-3 py-1.5 text-xs rounded-lg bg-white/[0.04] border border-white/[0.08] text-white placeholder:text-white/30 focus:outline-none focus:border-indigo-500/50"
305295
disabled={disabled}
306296
/>
307-
{descriptionHasSpecialChars && (
308-
<p className="text-xs text-blue-400/80 mt-1 flex items-center gap-1">
309-
<Check className="h-3 w-3" />
310-
Contains special characters. This will be auto-quoted for valid YAML
311-
</p>
312-
)}
313297
</div>
314298

315299
<div className="grid grid-cols-2 gap-2">

dashboard/src/app/control/components/MissionWorkbenchPanel.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { RelativeTime } from "@/components/ui/relative-time";
2424
import { getMissionShortName } from "@/lib/mission-display";
2525
import type { inferMissionRole } from "@/lib/mission-role";
2626
import type { Mission, MissionStatus } from "@/lib/api";
27+
import type { MissionStateSummary } from "../events-reducer";
2728
import { missionStatusDotClass, missionStatusLabel } from "./common";
2829

2930
export function MissionWorkbenchPanel({
@@ -33,6 +34,7 @@ export function MissionWorkbenchPanel({
3334
isRunning,
3435
childMissions,
3536
queueLen,
37+
missionState,
3638
onClose,
3739
onResume,
3840
onCancel,
@@ -51,6 +53,8 @@ export function MissionWorkbenchPanel({
5153
childMissions: Mission[];
5254
/** Pending message count, surfaced inline alongside status. */
5355
queueLen?: number;
56+
/** Agent task board + next-wakeup marker derived from chat items. */
57+
missionState?: MissionStateSummary;
5458
onClose: () => void;
5559
onResume: () => void;
5660
onCancel: (missionId: string) => void;
@@ -336,6 +340,65 @@ export function MissionWorkbenchPanel({
336340
)}
337341
</div>
338342

343+
{missionState?.upNext && (
344+
<div className="mt-3 border-t border-white/[0.06] pt-2.5">
345+
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-white/40">
346+
<Clock className="h-3 w-3" />
347+
Up next
348+
</div>
349+
<div className="text-[11px] leading-snug text-white/70">
350+
{missionState.upNext.reason.length > 160
351+
? missionState.upNext.reason.slice(0, 160) + "…"
352+
: missionState.upNext.reason}
353+
</div>
354+
<div className="mt-0.5 text-[10px] text-white/35">
355+
scheduled{" "}
356+
<RelativeTime date={new Date(missionState.upNext.timestamp)} />
357+
{missionState.upNext.delaySeconds != null &&
358+
` · fires ~${Math.round(missionState.upNext.delaySeconds / 60)}min later`}
359+
</div>
360+
</div>
361+
)}
362+
363+
{missionState?.plan && missionState.plan.items.length > 0 && (
364+
<div className="mt-3 border-t border-white/[0.06] pt-2.5">
365+
<div className="mb-1.5 flex items-center justify-between">
366+
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-white/40">
367+
<Flag className="h-3 w-3" />
368+
Plan
369+
</div>
370+
<span className="text-[10px] text-white/35">
371+
{missionState.plan.items.filter((t) => t.status === "completed").length}
372+
/{missionState.plan.items.length} done
373+
</span>
374+
</div>
375+
<ul className="space-y-1">
376+
{missionState.plan.items.map((task, i) => (
377+
<li
378+
key={i}
379+
className={cn(
380+
"flex items-start gap-1.5 text-[11px] leading-snug",
381+
task.status === "completed"
382+
? "text-white/30 line-through"
383+
: task.status === "in_progress"
384+
? "text-amber-200/90"
385+
: "text-white/60",
386+
)}
387+
>
388+
<span className="mt-px shrink-0">
389+
{task.status === "completed"
390+
? "✓"
391+
: task.status === "in_progress"
392+
? "●"
393+
: "○"}
394+
</span>
395+
<span>{task.content}</span>
396+
</li>
397+
))}
398+
</ul>
399+
</div>
400+
)}
401+
339402
{childMissions.length > 0 && (
340403
<div className="mt-3 border-t border-white/[0.06] pt-2.5">
341404
<div className="mb-1.5 flex items-center justify-between">

dashboard/src/app/control/control-client.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
} from "@/lib/stream-continuation";
3333
import {
3434
eventsToItemsImpl,
35+
extractMissionState,
3536
isRecord,
3637
parseCostMetadata,
3738
type ChatItem,
@@ -1180,7 +1181,7 @@ function QuestionToolItem({
11801181
data-chat-item-id={item.id}
11811182
className="flex justify-start gap-3"
11821183
>
1183-
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-indigo-500/20">
1184+
<div className="@max-[30rem]:hidden flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-indigo-500/20">
11841185
<Bot className="h-4 w-4 text-indigo-400" />
11851186
</div>
11861187
<div className="max-w-[90%] rounded-2xl rounded-tl-md bg-white/[0.03] border border-white/[0.06] px-4 py-3">
@@ -2660,7 +2661,7 @@ const ChatItemRow = memo(function ChatItemRow({
26602661
</span>
26612662
</div>
26622663
</div>
2663-
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/[0.08]">
2664+
<div className="@max-[30rem]:hidden flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/[0.08]">
26642665
<User className="h-4 w-4 text-white/60" />
26652666
</div>
26662667
</div>
@@ -2689,7 +2690,7 @@ const ChatItemRow = memo(function ChatItemRow({
26892690
highlighted && "ring-1 ring-amber-400/70 bg-amber-500/10",
26902691
)}
26912692
>
2692-
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-indigo-500/20">
2693+
<div className="@max-[30rem]:hidden flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-indigo-500/20">
26932694
<Bot className="h-4 w-4 text-indigo-400" />
26942695
</div>
26952696
<div className="max-w-[80%] rounded-2xl rounded-tl-md bg-white/[0.03] border border-white/[0.06] px-4 py-3">
@@ -2852,7 +2853,7 @@ const ChatItemRow = memo(function ChatItemRow({
28522853
data-chat-item-id={item.id}
28532854
className="flex justify-start gap-3"
28542855
>
2855-
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-indigo-500/20">
2856+
<div className="@max-[30rem]:hidden flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-indigo-500/20">
28562857
<Bot className="h-4 w-4 text-indigo-400" />
28572858
</div>
28582859
<div className="max-w-[80%] rounded-2xl rounded-tl-md bg-white/[0.03] border border-white/[0.06] px-4 py-3">
@@ -2899,7 +2900,7 @@ const ChatItemRow = memo(function ChatItemRow({
28992900
data-chat-item-id={item.id}
29002901
className="flex justify-start gap-3"
29012902
>
2902-
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-indigo-500/20">
2903+
<div className="@max-[30rem]:hidden flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-indigo-500/20">
29032904
<Bot className="h-4 w-4 text-indigo-400" />
29042905
</div>
29052906
<div className="max-w-[90%] rounded-2xl rounded-tl-md bg-white/[0.03] border border-white/[0.06] px-4 py-3">
@@ -3014,6 +3015,9 @@ export default function ControlClient() {
30143015
const showPerfOverlay = searchParams.get("debug") === "perf";
30153016

30163017
const [items, setItems] = useControlItemsStore();
3018+
// Agent task board + next-wakeup marker for the Workbench panel, derived
3019+
// from the same chat items the transcript renders (single source of truth).
3020+
const workbenchMissionState = useMemo(() => extractMissionState(items), [items]);
30173021
const itemsRef = useRef<ChatItem[]>([]);
30183022
const [input, setInput] = useState(() => loadControlDraftForMission(null));
30193023
const [canSubmitInput, setCanSubmitInput] = useState(false);
@@ -9837,7 +9841,7 @@ export default function ControlClient() {
98379841
</div>
98389842
</div>
98399843
) : (
9840-
<div className="mx-auto max-w-3xl space-y-6">
9844+
<div className="@container mx-auto max-w-3xl space-y-6">
98419845
<div
98429846
className="relative w-full"
98439847
style={{ height: `${chatVirtualizer.getTotalSize()}px` }}
@@ -10232,6 +10236,7 @@ export default function ControlClient() {
1023210236
isRunning={viewingMissionIsRunning}
1023310237
childMissions={childMissions}
1023410238
queueLen={viewingQueueLen}
10239+
missionState={workbenchMissionState}
1023510240
onClose={() => setShowWorkbenchPanel(false)}
1023610241
onResume={handleResumeMission}
1023710242
onCancel={handleCancelMission}

dashboard/src/app/control/events-reducer.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,37 @@ describe("eventsToItemsImpl lazy tool stubs", () => {
247247
]);
248248
});
249249
});
250+
251+
describe("extractMissionState", () => {
252+
it("derives plan and up-next from harness tool items", async () => {
253+
const { extractMissionState } = await import("./events-reducer");
254+
const items = eventsToItemsImpl([
255+
{
256+
...storedEvent(1, "tool_call", JSON.stringify({
257+
todos: [
258+
{ content: "merge PR", status: "completed" },
259+
{ content: "port Frames", status: "in_progress" },
260+
],
261+
})),
262+
tool_call_id: "t1",
263+
tool_name: "TodoWrite",
264+
},
265+
{
266+
...storedEvent(2, "tool_call", JSON.stringify({
267+
delaySeconds: 1500,
268+
reason: "review foundry shard 3 then merge",
269+
prompt: "Wakeup: ...",
270+
})),
271+
tool_call_id: "t2",
272+
tool_name: "ScheduleWakeup",
273+
},
274+
]);
275+
const state = extractMissionState(items);
276+
expect(state.plan?.items).toEqual([
277+
{ content: "merge PR", status: "completed" },
278+
{ content: "port Frames", status: "in_progress" },
279+
]);
280+
expect(state.upNext?.reason).toBe("review foundry shard 3 then merge");
281+
expect(state.upNext?.delaySeconds).toBe(1500);
282+
});
283+
});

0 commit comments

Comments
 (0)