Skip to content

Commit ac04e39

Browse files
committed
refine(frontend): workspace audit — thinner right pane, validation on left
De-clutter run details: Data pipeline rows without Fivetran sublines, thin Release receipt, Skill block with replay-safe built-in list and live-only upload. Move Approve & release to the conversation ValidationCard; goal-as- title topbar with pane rails and overflow actions.
1 parent 3206789 commit ac04e39

14 files changed

Lines changed: 641 additions & 300 deletions

frontend/src/App.tsx

Lines changed: 85 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
1-
import { useCallback, useEffect, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
22
import { Icons } from "./components/Icons";
3+
import { OverflowMenu } from "./components/OverflowMenu";
34
import { ThemeCtx, type Theme } from "./components/theme";
45
import { LeftPane } from "./components/LeftPane";
56
import { CenterPane } from "./components/CenterPane";
67
import { RightPane } from "./components/RightPane";
78
import { RunsDrawer } from "./components/RunsDrawer";
89
import { type Activation, type DryLabView, type ExperimentRequest, emptyView } from "./lib/contract";
910
import { isLive, loadView } from "./lib/loadView";
10-
import { recordedRunLabel } from "./lib/spine";
1111
import { continueLive, hasLiveSession, postExperiment } from "./lib/sse";
1212
import { copyToClipboard, createShare } from "./lib/share";
1313
import { navigate } from "./lib/router";
1414
import { cleanGoal } from "./lib/goal";
1515

16-
// Capture month of the committed replay fixture (frontend/public/replay/replay.json) — see PLAN.md run log.
17-
// Honest: the default /app is a RECORDED run, not a live one. Bump this when the fixture is recaptured.
18-
const FIXTURE_DATE = "Jun 2026";
16+
function truncateTitle(text: string, max = 56): string {
17+
if (text.length <= max) return text;
18+
return text.slice(0, max - 1).trimEnd() + "…";
19+
}
1920

2021
function useTheme(): [Theme, (t: Theme | ((p: Theme) => Theme)) => void] {
2122
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem("drylab-theme") as Theme) || "light");
@@ -143,64 +144,67 @@ export default function App({ sharedId, runId }: { sharedId?: string; runId?: st
143144
}
144145
}, [view]);
145146

147+
const runTitle = useMemo(() => {
148+
const goal = cleanGoal(view.research_goal);
149+
if (goal) return truncateTitle(goal);
150+
return "Dry Lab";
151+
}, [view.research_goal]);
152+
153+
const modeLabel = isPastRun ? "Past run" : sharedId ? "Shared" : isLive() ? "Live" : "Recorded";
154+
155+
const overflowItems = useMemo(() => {
156+
const items: { label: string; onClick: () => void; disabled?: boolean }[] = [];
157+
if (!sharedId) {
158+
items.push({
159+
label: shareState === "copied" ? "Link copied" : shareState === "busy" ? "Sharing…" : "Copy share link",
160+
onClick: onShare,
161+
disabled: shareState === "busy",
162+
});
163+
}
164+
items.push({ label: "Print report", onClick: () => window.print() });
165+
return items;
166+
}, [sharedId, shareState, onShare]);
167+
146168
return (
147169
<ThemeCtx.Provider value={theme}>
148170
<div className="app">
149171
<header className="topbar">
150-
<button className="brand brand-home" onClick={() => navigate("/")} title="Dry Lab home">
151-
<span className="brand-mark"><Icons.dna /></span>
152-
<div className="brand-text">
153-
<span className="brand-name">Dry Lab</span>
154-
<span className="brand-project mono">
155-
{isPastRun
156-
? "Past run · read-only (from the runs index)"
157-
: sharedId
158-
? "Shared run · read-only"
159-
: isLive()
160-
? "Live run · /run_sse"
161-
: `Recorded run · ${recordedRunLabel(
162-
new URLSearchParams(location.search).get("fixture") || "replay",
163-
view.research_goal,
164-
)} · ${FIXTURE_DATE} — watch it replay`}
165-
</span>
166-
</div>
167-
</button>
168-
{/* Layout toggles (VS Code / Linear grammar): collapse either side pane to focus the center. The
169-
buttons stay in the bar whatever the state, so a hidden pane is always one click from returning. */}
170-
<div className="topbar-toggles">
171-
<button className={"btn btn-ghost btn-icon" + (leftOpen ? " on" : "")} onClick={toggleLeft} aria-pressed={leftOpen} title={leftOpen ? "Hide the conversation" : "Show the conversation"}>
172-
<Icons.panelLeft cls="icon-sm" />
172+
<div className="topbar-left">
173+
<button
174+
className={"btn btn-ghost btn-icon" + (runsOpen ? " on" : "")}
175+
onClick={() => setRunsOpen((o) => !o)}
176+
aria-pressed={runsOpen}
177+
title="History"
178+
aria-label="Run history"
179+
>
180+
<Icons.list cls="icon-sm" />
173181
</button>
174-
<button className={"btn btn-ghost btn-icon" + (rightOpen ? " on" : "")} onClick={toggleRight} aria-pressed={rightOpen} title={rightOpen ? "Hide the run details" : "Show the run details"}>
175-
<Icons.panelRight cls="icon-sm" />
182+
<button className="brand brand-home" onClick={() => navigate("/")} title="Dry Lab home">
183+
<span className="brand-mark"><Icons.dna /></span>
184+
<div className="brand-text">
185+
<span className="brand-title serif">{runTitle}</span>
186+
<span className="mode-chip mono">{modeLabel}</span>
187+
</div>
176188
</button>
177189
</div>
178-
<div className="topbar-right">
179-
{readOnly && <span className="view-only-badge mono"><Icons.shield cls="icon-sm" />View only</span>}
180-
<button className={"btn btn-ghost" + (runsOpen ? " on" : "")} onClick={() => setRunsOpen(true)} title="Run history">
181-
<Icons.list cls="icon-sm" />Runs
182-
</button>
183-
<button className="btn btn-ghost" onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))} title="Toggle theme">
190+
<div className="topbar-actions">
191+
<button className="btn btn-ghost btn-icon" onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))} title="Toggle theme">
184192
{theme === "light" ? <Icons.moon cls="icon-sm" /> : <Icons.sun cls="icon-sm" />}
185193
</button>
186-
{/* A reconstructed past run can be re-run live (a NEW run with the same goal) — never a faked resume. */}
194+
<OverflowMenu items={overflowItems} />
187195
{isPastRun && view.research_goal && (
188196
<button className="btn btn-accent" onClick={() => navigate(`/app?live=1&goal=${encodeURIComponent(cleanGoal(view.research_goal))}`)} title="Run this goal again, live">
189-
<Icons.flask cls="icon-sm" />Re-run live
197+
<Icons.flask cls="icon-sm" />Re-run
190198
</button>
191199
)}
192-
{/* Share is available for live/replay/past-run views, but NOT for an already-shared /r/:id snapshot. */}
193-
{!sharedId && (
194-
<button className="btn btn-ghost mobile-hide" onClick={onShare} disabled={shareState === "busy"} title="Create a read-only share link">
195-
{shareState === "copied" ? <Icons.check cls="icon-sm" /> : <Icons.share cls="icon-sm" />}
196-
{shareState === "copied" ? "Link copied" : shareState === "busy" ? "Sharing…" : "Share"}
197-
</button>
198-
)}
199-
<button className="btn btn-ghost mobile-hide" onClick={() => window.print()}><Icons.download cls="icon-sm" />Export</button>
200-
{/* This default workspace is a recorded run — make "Run it live" the first-class accent CTA. */}
201200
{!readOnly && !isLive() && (
202201
<button className="btn btn-accent" onClick={() => navigate("/app?live=1")} title="Start a live run">
203-
<Icons.flask cls="icon-sm" />Run it live
202+
<Icons.flask cls="icon-sm" />Run live
203+
</button>
204+
)}
205+
{!readOnly && isLive() && (
206+
<button className="btn btn-accent" onClick={() => navigate("/app?live=1")} title="Start a new run">
207+
<Icons.flask cls="icon-sm" />New run
204208
</button>
205209
)}
206210
</div>
@@ -212,10 +216,27 @@ export default function App({ sharedId, runId }: { sharedId?: string; runId?: st
212216
<div className="load-state">Loading run…</div>
213217
) : (
214218
<div className="workspace">
219+
{!leftOpen && (
220+
<div className="pane-rail pane-rail-left">
221+
<button className="btn btn-ghost btn-icon" onClick={toggleLeft} title="Show conversation" aria-label="Show conversation">
222+
<Icons.panelLeft cls="icon-sm" />
223+
</button>
224+
</div>
225+
)}
215226
{leftOpen && (
216227
<>
217228
<div className="pane-host pane-host-left fade-in" style={{ width: leftW }}>
218-
<LeftPane view={view} live={isLive() && !readOnly} onApprovePlan={onApprovePlan} onEditPlan={onEditPlan} onSend={onSend} onOpenFile={openFile} />
229+
<LeftPane
230+
view={view}
231+
live={isLive() && !readOnly}
232+
onApprovePlan={onApprovePlan}
233+
onEditPlan={onEditPlan}
234+
onApproveExperiment={onApproveExperiment}
235+
onSend={onSend}
236+
onOpenFile={openFile}
237+
paneOpen={leftOpen}
238+
onTogglePane={toggleLeft}
239+
/>
219240
</div>
220241
<div className="divider" onMouseDown={(e) => dragLeft(e, "left")}><span /></div>
221242
</>
@@ -225,10 +246,24 @@ export default function App({ sharedId, runId }: { sharedId?: string; runId?: st
225246
<>
226247
<div className="divider" onMouseDown={(e) => dragRight(e, "right")}><span /></div>
227248
<div className="pane-host pane-host-right fade-in" style={{ width: rightW }}>
228-
<RightPane view={view} onApproveExperiment={onApproveExperiment} onOpenFile={openFile} canUpload={!readOnly} />
249+
<RightPane
250+
view={view}
251+
onOpenFile={openFile}
252+
canUpload={!readOnly}
253+
live={isLive() && !readOnly}
254+
paneOpen={rightOpen}
255+
onTogglePane={toggleRight}
256+
/>
229257
</div>
230258
</>
231259
)}
260+
{!rightOpen && (
261+
<div className="pane-rail pane-rail-right">
262+
<button className="btn btn-ghost btn-icon" onClick={toggleRight} title="Show run details" aria-label="Show run details">
263+
<Icons.panelRight cls="icon-sm" />
264+
</button>
265+
</div>
266+
)}
232267
</div>
233268
)}
234269

frontend/src/components/BringSkill.tsx

Lines changed: 85 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
// PHASE B — "Bring a skill": upload a SKILL.md and run it in the SAME governed runtime as the vetted skills.
2-
// Honesty (researched, calm-not-alarmist — the VS Code Workspace-Trust / GitHub-unverified register): built-in
3-
// skills are VETTED; an uploaded skill is marked UNVERIFIED (amber, not red) and runs in the same sandboxed,
4-
// network-less, credential-less kernel — the @Critic reliability review still applies. A user skill can never
5-
// take a vetted skill's name, so it can't masquerade as trusted.
1+
// Skill block — which procedure drove THIS run (SKILL.md), plus live-only BYO upload.
62
import { useCallback, useEffect, useRef, useState } from "react";
73
import { Icons } from "./Icons";
4+
import { type DryLabView } from "../lib/contract";
5+
import { skillDirective } from "../lib/goal";
86
import { listSkills, uploadSkill, type SkillEntry, type SkillList } from "../lib/skills";
7+
import { activeSkillForGoal, VETTED_SKILL_NAMES } from "../lib/vettedSkills";
98

109
function SkillRow({ s }: { s: SkillEntry }) {
1110
return (
@@ -15,24 +14,30 @@ function SkillRow({ s }: { s: SkillEntry }) {
1514
{s.tier === "vetted" ? "Built-in" : "Unverified"}
1615
</span>
1716
<span className="skill-name mono">{s.name}</span>
18-
{s.domain && <span className="skill-domain">{s.domain}</span>}
1917
</li>
2018
);
2119
}
2220

23-
// canUpload: true on any writable workspace (the default recorded workspace OR a live run), false on read-only
24-
// shared / past-run views. An upload binds to the tab-stable live session id; "Run it live" reuses that session,
25-
// so a skill brought from the recorded workspace is available to the run the user then starts.
26-
export function BringSkill({ canUpload }: { canUpload: boolean }) {
21+
export function BringSkill({ view, canUpload, live = false }: { view: DryLabView; canUpload: boolean; live?: boolean }) {
22+
const [open, setOpen] = useState(true);
2723
const [list, setList] = useState<SkillList | null>(null);
2824
const [busy, setBusy] = useState(false);
2925
const [errors, setErrors] = useState<string[] | null>(null);
3026
const [accepted, setAccepted] = useState<string | null>(null);
3127
const fileRef = useRef<HTMLInputElement | null>(null);
3228

29+
const active =
30+
skillDirective(view.research_goal) ||
31+
activeSkillForGoal(view.research_goal) ||
32+
(view.skill_tier === "user" ? "user skill" : null);
33+
const activeTier = view.skill_tier === "user" ? "Unverified" : "Built-in";
34+
3335
const refresh = useCallback(() => {
34-
listSkills().then(setList).catch(() => setList((p) => p ?? { vetted: [], user: [] }));
35-
}, []);
36+
if (!canUpload || !live) return;
37+
listSkills()
38+
.then(setList)
39+
.catch(() => setList(null));
40+
}, [canUpload, live]);
3641
useEffect(() => { refresh(); }, [refresh]);
3742

3843
const onFile = useCallback(async (file: File) => {
@@ -54,80 +59,90 @@ export function BringSkill({ canUpload }: { canUpload: boolean }) {
5459
}, [refresh]);
5560

5661
const userSkills = list?.user || [];
57-
const vetted = list?.vetted || [];
62+
const vettedFromApi = list?.vetted?.length ? list.vetted : null;
63+
const vettedNames = vettedFromApi?.map((s) => s.name) ?? [...VETTED_SKILL_NAMES];
64+
const meta = active || "—";
5865

5966
return (
6067
<section className="rblock skills-block">
61-
<div className="rblock-head static">
62-
<Icons.spark cls="icon-sm" />
63-
<span className="rblock-title">Skills</span>
64-
<span className="rblock-meta mono">{vetted.length + userSkills.length}</span>
65-
</div>
66-
<div className="rblock-pad">
67-
<p className="skills-note">
68-
Built-in skills are Dry Lab's reviewed set. You can bring your own <span className="mono">SKILL.md</span>
69-
author-supplied code, not reviewed by us. It runs in the <strong>same sandboxed, network-less</strong> kernel
70-
as the built-ins and is marked
71-
<span className="skill-tier user inline"><Icons.shield cls="icon-sm" />Unverified</span>;
72-
the reliability review still applies.
73-
</p>
68+
<button className="rblock-head" onClick={() => setOpen(!open)}>
69+
{open ? <Icons.chevD cls="icon-sm" /> : <Icons.chevR cls="icon-sm" />}
70+
<span className="rblock-title">Skill</span>
71+
<span className="rblock-meta mono">{meta}</span>
72+
</button>
73+
{open && (
74+
<div className="rblock-pad fade-in">
75+
{active ? (
76+
<div className="skill-active-row">
77+
<span className={"skill-tier " + (view.skill_tier === "user" ? "user" : "vetted")}>
78+
{view.skill_tier === "user" ? <Icons.shield cls="icon-sm" /> : <Icons.check cls="icon-sm" />}
79+
{activeTier}
80+
</span>
81+
<span className="skill-name mono">{active}</span>
82+
</div>
83+
) : (
84+
<p className="skills-hint mono">Skill routes at @Investigator from your goal.</p>
85+
)}
7486

75-
{canUpload && (
76-
<div className="skills-upload">
77-
<input
78-
ref={fileRef}
79-
type="file"
80-
accept=".md,.zip,text/markdown"
81-
className="skills-file"
82-
id="byo-skill-file"
83-
disabled={busy}
84-
onChange={(e) => { const f = e.target.files?.[0]; if (f) void onFile(f); }}
85-
/>
86-
<label htmlFor="byo-skill-file" className={"btn btn-accent skills-upload-btn" + (busy ? " is-busy" : "")}>
87-
{busy ? <span className="composer-spin" /> : <Icons.plus cls="icon-sm" />}
88-
{busy ? "Validating…" : "Bring a skill (SKILL.md / .zip)"}
89-
</label>
90-
</div>
91-
)}
87+
{canUpload && live && (
88+
<div className="skills-upload">
89+
<input
90+
ref={fileRef}
91+
type="file"
92+
accept=".md,.zip,text/markdown"
93+
className="skills-file"
94+
id="byo-skill-file"
95+
disabled={busy}
96+
onChange={(e) => { const f = e.target.files?.[0]; if (f) void onFile(f); }}
97+
/>
98+
<label htmlFor="byo-skill-file" className={"btn btn-accent skills-upload-btn" + (busy ? " is-busy" : "")}>
99+
{busy ? <span className="composer-spin" /> : <Icons.plus cls="icon-sm" />}
100+
{busy ? "Validating…" : "Bring a skill"}
101+
</label>
102+
</div>
103+
)}
92104

93-
{accepted && (
94-
<div className="skills-accepted">
95-
<Icons.check cls="icon-sm" />
96-
Added <span className="mono">{accepted}</span>available to this run as an unverified skill.
97-
</div>
98-
)}
99-
{errors && (
100-
<div className="skills-rejected">
101-
<div className="skills-rejected-head"><Icons.x cls="icon-sm" />Rejected — not stored</div>
102-
<ul>{errors.map((e, i) => <li key={i} className="mono">{e}</li>)}</ul>
103-
</div>
104-
)}
105+
{accepted && (
106+
<div className="skills-accepted">
107+
<Icons.check cls="icon-sm" />
108+
Added <span className="mono">{accepted}</span>unverified, this session only.
109+
</div>
110+
)}
111+
{errors && (
112+
<div className="skills-rejected">
113+
<div className="skills-rejected-head"><Icons.x cls="icon-sm" />Rejected</div>
114+
<ul>{errors.map((e, i) => <li key={i} className="mono">{e}</li>)}</ul>
115+
</div>
116+
)}
105117

106-
{userSkills.length > 0 && (
107-
<>
108-
<div className="skills-group-label">Your skills</div>
118+
{userSkills.length > 0 && (
109119
<ul className="skill-list">{userSkills.map((s) => <SkillRow key={"u:" + s.name} s={s} />)}</ul>
110-
</>
111-
)}
112-
<details className="skills-vetted-details">
113-
<summary>Built-in skills ({vetted.length})</summary>
114-
<ul className="skill-list">{vetted.map((s) => <SkillRow key={"v:" + s.name} s={s} />)}</ul>
115-
</details>
116-
</div>
120+
)}
121+
122+
<details className="skills-vetted-details">
123+
<summary className="mono">Built-in skills ({vettedNames.length})</summary>
124+
<ul className="skill-list">
125+
{vettedNames.map((name) => (
126+
<li className="skill-row" key={name}>
127+
<span className="skill-tier vetted"><Icons.check cls="icon-sm" />Built-in</span>
128+
<span className="skill-name mono">{name}</span>
129+
</li>
130+
))}
131+
</ul>
132+
</details>
133+
</div>
134+
)}
117135
</section>
118136
);
119137
}
120138

121-
// The run-level banner: shown when an UNVERIFIED user skill actually drove the analysis (view.skill_tier).
122-
// One calm line — informative, not alarmist (matches the upload note above).
123139
export function UnverifiedRunBanner() {
124140
return (
125141
<div className="unverified-banner">
126142
<span className="unverified-banner-ic"><Icons.shield cls="icon-sm" /></span>
127143
<span>
128-
This run used an <strong>unverified skill</strong> — author-supplied code, not part of Dry Lab's reviewed
129-
set. It ran in the same sandboxed, network-less environment as the built-in skills; the results below are
130-
shown in full with provenance recorded, and the @Critic reliability review still applied.
144+
This run used an <strong>unverified skill</strong> — author-supplied procedure, not Dry Lab's reviewed set.
145+
Same sandbox; @Critic still applied.
131146
</span>
132147
</div>
133148
);

0 commit comments

Comments
 (0)