Merge branch 'main' into issue/ope-51-ask_user-upgrades

This commit is contained in:
Rohit P
2026-08-01 09:45:03 -07:00
75 changed files with 7547 additions and 191 deletions
+55 -11
View File
@@ -165,6 +165,9 @@ export function App() {
// {full model id → context window in tokens} from the curated matrix (verified only);
// drives the composer usage chip's context-fill meter.
const [modelContextWindows, setModelContextWindows] = useState<Record<string, number>>({});
// Settings: show the composer's context-window fill bar. OFF by default (owner ask),
// so an older backend without the field also shows the session total.
const [contextBar, setContextBar] = useState(false);
// Per-session token usage (OPE-42): rebuilt from the transcript on session load,
// accumulated live from assistant_message events, reset with the transcript.
const [usage, setUsage] = useState<SessionUsage>(emptyUsage());
@@ -172,6 +175,10 @@ export function App() {
const [mode, setMode] = useState("interactive");
const [connected, setConnected] = useState(false);
const [running, setRunning] = useState(false);
// Transient "Compacting context…" indicator (OPE-27): set by the `compacting` event,
// cleared by whatever the engine emits next — the summarizer call is otherwise a
// multi-second silent stall mid-turn.
const [compacting, setCompacting] = useState(false);
const [items, setItems] = useState<Item[]>([]);
const [streaming, setStreamingState] = useState("");
// Ref mirror of `streaming`: the WS handler closure is built once per socket and can't read
@@ -204,10 +211,12 @@ export function App() {
const [scheduledOpenId, setScheduledOpenId] = useState<string | null>(null);
const [gateCreate, setGateCreate] = useState(false);
// Which Settings section the full-page Settings surface opens on (§ Settings-as-page).
const [settingsTab, setSettingsTab] = useState<"appearance" | "models" | "voice" | "personas">(
"appearance",
);
const openSettings = (tab: "appearance" | "models" | "voice" | "personas" = "appearance") => {
const [settingsTab, setSettingsTab] = useState<
"appearance" | "models" | "skills" | "voice" | "personas"
>("appearance");
const openSettings = (
tab: "appearance" | "models" | "skills" | "voice" | "personas" = "appearance",
) => {
setSettingsTab(tab);
setSurface("settings");
};
@@ -502,6 +511,7 @@ export function App() {
setModels(s.models || []);
setModelLabels(s.model_labels || {});
setModelContextWindows(s.model_context_windows || {});
setContextBar(s.context_bar === true);
setModelReady(s.model_ready);
if (s.surfaces) setSurfaces(s.surfaces);
})
@@ -576,6 +586,9 @@ export function App() {
},
]);
};
// Any engine event after `compacting` means the summarizer finished (compacted /
// silent no-op / failure prompt) — the transient must never outlive it.
if (ev.type !== "compacting") setCompacting(false);
switch (ev.type) {
case "ready":
setConnected(true);
@@ -602,11 +615,14 @@ export function App() {
: [...p, { kind: "connector", source: src }];
});
} else if (typeof d.input === "string" && d.input) {
// `display` (force-run) is the user's literal "/name …" line; the framed
// `input` is model-facing. Surface/dedupe on what the user actually sees.
const shown = (typeof d.display === "string" && d.display) || (d.input as string);
setItems((p) => {
const last = p[p.length - 1];
return last && last.kind === "user" && last.text === d.input
return last && last.kind === "user" && last.text === shown
? p
: [...p, { kind: "user", text: d.input as string, ts: Date.now() / 1000 }];
: [...p, { kind: "user", text: shown, ts: Date.now() / 1000 }];
});
}
break;
@@ -711,6 +727,14 @@ export function App() {
if (d.model) setModel(d.model);
setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Model switched" }]);
break;
case "compacting":
setCompacting(true);
break;
case "compacted":
// Auto-compaction marker (OPE-27): outbound-only — the transcript stays intact,
// this divider just shows where the model's memory was summarized.
setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Context compacted" }]);
break;
case "interrupted":
flushPartialStream();
setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Interrupted." }]);
@@ -844,10 +868,13 @@ export function App() {
return () => clearInterval(t);
}, [surface, sessionId, browserRefreshKey, markUnattended]);
const send = (text: string, attachments?: Attachment[]) => {
setItems((p) => [...p, { kind: "user", text, attachments, ts: Date.now() / 1000 }]);
const send = (text: string, attachments?: Attachment[], skill?: string) => {
// Force-run shows exactly what the user typed: "/name rest". Must match the server's
// `display` sidecar formula so the turn_start dedupe recognizes the local echo.
const shown = skill ? `/${skill}${text ? ` ${text}` : ""}` : text;
setItems((p) => [...p, { kind: "user", text: shown, attachments, ts: Date.now() / 1000 }]);
// The visible model rides along with the message (single source of truth per turn).
sessionRef.current?.userMessage(text, attachments, model);
sessionRef.current?.userMessage(text, attachments, model, skill);
followLatest(); // sending always re-engages stream-following, wherever the user had scrolled
};
// Resolving a LIVE prompt also resolves its parked Inbox mirror server-side, but the polled
@@ -1332,6 +1359,17 @@ export function App() {
key={settingsTab}
initialTab={settingsTab}
onOpenPersona={(id) => openPersona(id, "settings")}
onCreateSkill={(description) => {
// The Skills doorway (SKILLS-SPEC §5.2): creation is a conversation. Fresh
// session, description in the composer — the user reads and hits send. With
// no description, the prefill invites them to finish the sentence there.
startNewSession();
prefillComposer(
description
? `Build a new skill for me: ${description}`
: "Build a new skill for me: (describe what the skill should do)",
);
}}
/>
) : surface === "audit" ? (
<AuditView />
@@ -1516,7 +1554,11 @@ export function App() {
<ThinkingBlock text={reasoningStream} live />
</div>
)}
{/* Compaction runs between provider turns (nothing streams during it), so
the transient takes over the waiting slot with a specific label. */}
{running && compacting && <WaitingForAgent label="Compacting context…" />}
{running &&
!compacting &&
!reasoningStream &&
(!streaming || streamMode(streaming, items, running) === "hold") &&
!lastItemIsAssistant(items) && <WaitingForAgent />}
@@ -1563,6 +1605,7 @@ export function App() {
onInterrupt={interrupt}
onModeChange={changeMode}
onModelChange={changeModel}
sessionId={sessionId}
workspace={needsWorkspace(agent) ? workspace || "" : undefined}
unattended={unattended}
onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined}
@@ -1570,6 +1613,7 @@ export function App() {
resetKey={sessionId}
usage={usage}
contextWindow={modelContextWindows[model]}
contextBar={contextBar}
placeholder={
agent === "code"
? "Ask the coder to build, fix, or explain… (drop or paste files)"
@@ -1684,12 +1728,12 @@ function lastItemIsAssistant(items: Item[]): boolean {
return false;
}
function WaitingForAgent() {
function WaitingForAgent({ label }: { label?: string }) {
return (
<div className="waiting-transcript">
<div className="waiting-row" aria-live="polite">
<span className="waiting-spinner" />
<span>Waiting for agent...</span>
<span>{label || "Waiting for agent..."}</span>
</div>
</div>
);
+180 -1
View File
@@ -195,6 +195,8 @@ export interface ArtifactContent {
content?: string;
data_url?: string;
truncated?: boolean;
// kind === "folder": a directory listing (models sometimes link a whole package dir).
entries?: { name: string; dir: boolean; size: number }[];
}
export async function getArtifacts(sessionId: string): Promise<ArtifactInfo[]> {
@@ -692,6 +694,9 @@ export interface ModelSettings {
nav_layout?: "flat" | "grouped";
// Sidebar: sessions shown per group before "Show more" (default 5, 150).
sessions_peek?: number;
// Composer: show the context-window fill bar (default FALSE; absent → the chip shows
// the session total). The usage popover keeps both numbers regardless.
context_bar?: boolean;
// Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent.
model_labels?: Record<string, string>;
// {full id → context window in tokens}, verified matrix entries only — drives the
@@ -702,6 +707,12 @@ export interface ModelSettings {
pdf_fallback?: "text" | "images";
pdf_max_pages?: number; // default 20, 1100
pdf_max_mb?: number; // default 10, 110
// Auto-compaction of long histories (OPE-27): trigger = min(threshold% × context
// window, cap tokens); model pins the summarizer ("" → the session's own model).
// Optional so the GUI is robust to an older backend.
compaction_threshold_pct?: number; // default 0.8, 0.100.95
compaction_cap_tokens?: number; // default 250000
compaction_model?: string;
}
export interface PdfSettings {
@@ -722,6 +733,24 @@ export async function setPdfSettings(
return res.json();
}
export interface CompactionSettings {
compaction_threshold_pct: number;
compaction_cap_tokens: number;
compaction_model: string;
}
/** Persist the auto-compaction overrides (threshold %, token cap, summarizer model). */
export async function setCompactionSettings(
patch: Partial<CompactionSettings>,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/settings/compaction`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
return res.json();
}
/** Local page/size probe for a PDF data URL — the composer's attach-time threshold check. */
export async function inspectPdf(
dataUrl: string,
@@ -734,6 +763,18 @@ export async function inspectPdf(
return res.json();
}
/** Persist whether the composer shows the context-window fill bar. */
export async function setContextBar(
shown: boolean,
): Promise<{ ok: boolean; context_bar?: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/settings/context-bar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ context_bar: shown }),
});
return res.json();
}
/** Persist how many sessions a sidebar group shows before "Show more". */
export async function setSessionsPeek(
n: number,
@@ -1050,6 +1091,141 @@ export async function setSessionConnection(
return res.json();
}
// -- Skills (SKILLS-SPEC §4) ----------------------------------------------------
// Scope = folder location: "global" (every session) or "project" (one workspace).
// The session endpoints resolve the effective menu (Settings disables + session mutes).
export interface SkillRow {
name: string;
description: string;
instructions: string;
scope: "global" | "project";
source: string; // "local" | "uploaded"
enabled: boolean;
path: string;
files?: number; // bundled resources beyond SKILL.md (§6 — rich skills are visible)
}
export interface SessionSkillRow {
name: string;
description: string;
scope: "global" | "project";
enabled: boolean; // false = muted for this session only
}
export interface SkillUploadPreview {
ok: boolean;
error?: string;
token?: string;
name?: string;
description?: string;
instructions?: string;
files?: string[];
}
const skillUrl = (path = "") => `${httpBase()}/v1/skills${path}`;
const jsonPost = (body: unknown, method = "POST") => ({
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
export async function listSkills(workspace?: string): Promise<SkillRow[]> {
const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
const res = await fetch(skillUrl(qs));
return (await res.json()).skills ?? [];
}
export async function createSkill(body: {
name: string;
description: string;
instructions: string;
scope?: "global" | "project";
workspace?: string;
}): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(skillUrl(), jsonPost(body));
return res.json();
}
export async function updateSkill(
name: string,
patch: { description?: string; instructions?: string; enabled?: boolean; workspace?: string },
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}`), jsonPost(patch, "PATCH"));
return res.json();
}
export async function revealSkill(name: string): Promise<{ ok: boolean; error?: string }> {
// §6 "Show folder": the backend opens the skill's folder in the OS file manager.
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}/reveal`), jsonPost({}));
return res.json();
}
export async function deleteSkill(
name: string,
workspace?: string,
): Promise<{ ok: boolean; error?: string }> {
const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}${qs}`), { method: "DELETE" });
return res.json();
}
export async function moveSkill(
name: string,
scope: "global" | "project",
workspace?: string,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}/move`), jsonPost({ scope, workspace }));
return res.json();
}
export async function stageSkillUpload(
dataB64: string,
filename = "",
): Promise<SkillUploadPreview> {
const res = await fetch(skillUrl("/upload"), jsonPost({ data_b64: dataB64, filename }));
return res.json();
}
export async function confirmSkillUpload(
token: string,
scope: "global" | "project" = "global",
workspace?: string,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(skillUrl("/upload/confirm"), jsonPost({ token, scope, workspace }));
return res.json();
}
export async function sessionSkills(
sessionId: string,
workspace?: string,
): Promise<SessionSkillRow[]> {
const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/skills${qs}`,
);
return (await res.json()).skills ?? [];
}
export async function setSessionSkill(
sessionId: string,
skill: string,
enabled: boolean,
opts: { clear?: boolean; workspace?: string } = {},
): Promise<{ skills?: SessionSkillRow[]; ok?: boolean; error?: string }> {
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/skills`,
jsonPost({
skill,
enabled,
...(opts.clear ? { clear: true } : {}),
...(opts.workspace ? { workspace: opts.workspace } : {}),
}),
);
return res.json();
}
// -- Inbox + Unattended -------------------------------------------------------
export interface InboxItem {
id: string;
@@ -1811,12 +1987,15 @@ export class Session {
* exactly what the user sees — immune to set_model races across reconnects (a new cowork
* session always reconnects once to adopt its scratch dir, which could drop a queued
* set_model and leave the engine on a stale/resumed model; found 2026-07-04). */
userMessage(text: string, attachments?: unknown[], model?: string) {
userMessage(text: string, attachments?: unknown[], model?: string, skill?: string) {
this.send({
type: "user_message",
text,
...(model ? { model } : {}),
...(attachments?.length ? { attachments } : {}),
// Force-run (SKILLS-SPEC §4.1): the composer's /skill pick rides as its own field;
// the server validates it against the session's effective menu and frames the turn.
...(skill ? { skill } : {}),
});
}
@@ -221,7 +221,7 @@ export function AccessSection({
: roots.length > 0
? `${roots.length} folder${roots.length === 1 ? "" : "s"}`
: null;
const summary = folderPart ? `${sourcesPart} · ${folderPart}` : sourcesPart;
const summary = [sourcesPart, folderPart].filter(Boolean).join(" · ");
return (
<section className="rail-section" ref={rootEl} data-testid="access-section">
@@ -383,6 +383,14 @@ export function AccessSection({
+ Add a source
</button>
)}
{/* Lives with its list (tester ask 2026-07-26): each group's manage link sits
directly under that group, not pooled at the section's bottom. */}
<button
className="mt-1.5 block text-[12px] text-accent font-medium hover:underline text-left"
onClick={() => onOpenIntegrations?.()}
>
Manage all connectors (global)
</button>
</div>
{recommended.length > 0 && (
@@ -456,13 +464,6 @@ export function AccessSection({
)}
{rootsError && <div className="roots-err">{rootsError}</div>}
</div>
<button
className="text-[12px] text-accent font-medium hover:underline text-left"
onClick={() => onOpenIntegrations?.()}
>
Manage all connectors (global)
</button>
</div>
)}
</div>
@@ -110,7 +110,7 @@ describe("ApprovalCard — §35 shapes", () => {
expect(onApprove).toHaveBeenCalledWith("once");
});
it("send_file gets the full external card: destination title, file chip, leaves-the-Mac note", () => {
it("send_file gets the full external card: destination title, file chip, leaves-the-computer note", () => {
render(
<ApprovalCard
item={sendApproval({
@@ -121,7 +121,7 @@ describe("ApprovalCard — §35 shapes", () => {
/>,
);
expect(screen.getByText(/Send a file to/).textContent).toContain("C9");
expect(screen.getByText(/leaves this Mac → Slack/)).toBeTruthy();
expect(screen.getByText(/leaves this computer → Slack/)).toBeTruthy();
expect(screen.getByText(/report\.pdf/)).toBeTruthy();
expect(screen.getByText(/here you go/)).toBeTruthy();
expect(screen.getByText("Allow once")).toBeTruthy();
@@ -159,7 +159,7 @@ describe("ApprovalCard — §35 shapes", () => {
);
expect(screen.getByText(/Run a command — fetch semiconductor stock data/)).toBeTruthy();
expect(screen.getByText(/python3 fetch\.py/)).toBeTruthy();
expect(screen.getByText(/stays on this Mac/)).toBeTruthy();
expect(screen.getByText(/stays on this computer/)).toBeTruthy();
expect(screen.getByText("Always allow this command")).toBeTruthy();
});
});
@@ -213,10 +213,94 @@ describe("InboxItemCard — Allow every time on parked run approvals", () => {
expect(screen.getByText("fetch_data.py")).toBeTruthy();
expect(screen.queryByText("Run `send_message`?")).toBeNull();
expect(screen.getByText(/import json/)).toBeTruthy();
expect(screen.getByText(/stays on this Mac/)).toBeTruthy();
expect(screen.getByText(/stays on this computer/)).toBeTruthy();
// §35 labels; resolution vocabulary unchanged (works on every approver path).
fireEvent.click(screen.getByText("Allow once"));
expect(onResolve).toHaveBeenCalledWith("i1", "allow");
// Old rows without tool data keep the legacy treatment (covered above).
});
});
describe("ApprovalCard — save_skill (SKILLS-SPEC §5.2)", () => {
const skillApproval = (extra: Partial<ApprovalItem> = {}): ApprovalItem =>
sendApproval({
name: "save_skill",
category: "skills",
args: {
name: "weekly-github-report",
description: "Create a concise Monday status report from GitHub activity.",
instructions: "1. Fetch PRs\n2. Write the report",
files: ["fetch_prs.py", "sub/example-report.md"],
},
standingTarget: undefined,
...extra,
});
it("shows name-first title, description, instructions, and every bundled file", () => {
render(<ApprovalCard item={skillApproval()} onApprove={vi.fn()} />);
expect(screen.getByText("weekly-github-report")).toBeTruthy(); // bold obj in the title
expect(screen.getAllByText(/to your skills/).length).toBeGreaterThan(0); // title + footer
// The corner answers WHERE; the footer answers what approving means (§5.2 review round).
expect(screen.getByText("saves to Settings ▸ Skills")).toBeTruthy();
expect(screen.getByText(/usable in every conversation from\s+then on/)).toBeTruthy();
expect(
screen.getByText("Create a concise Monday status report from GitHub activity."),
).toBeTruthy();
expect(screen.getByText(/Fetch PRs/)).toBeTruthy();
const chips = screen.getByTestId("skill-bundle-files");
expect(chips.textContent).toContain("fetch_prs.py");
expect(chips.textContent).toContain("example-report.md"); // basename, not the path
});
it("uses the §7 button copy and never offers a session-wide always", () => {
const onApprove = vi.fn();
render(<ApprovalCard item={skillApproval()} onApprove={onApprove} />);
expect(screen.queryByText("Always allow")).toBeNull(); // every proposal gets its own review
expect(screen.queryByText("Deny")).toBeNull();
fireEvent.click(screen.getByText("Add to my skills"));
expect(onApprove).toHaveBeenCalledWith("once");
fireEvent.click(screen.getByText("Not now"));
expect(onApprove).toHaveBeenCalledWith("deny");
});
});
describe("InboxItemCard — parked save_skill proposals (SKILLS-SPEC §5.2)", () => {
const parked = (): InboxItem => ({
id: "i9",
session_id: "s1",
kind: "approval",
title: "Run `save_skill`?",
body: "",
state: "pending",
resolution: null,
inbox: "default",
created_at: "",
resolved_at: null,
data: {
tool: "save_skill",
arguments: {
name: "weekly-github-report",
description: "Create a concise Monday status report from GitHub activity.",
instructions: "1. Fetch PRs\n2. Write the report",
files: ["fetch_prs.py"],
},
},
});
it("wears the same review surface and button copy as the live card", () => {
const onResolve = vi.fn();
render(<InboxItemCard item={parked()} onResolve={onResolve} />);
expect(screen.getByText("saves to Settings ▸ Skills")).toBeTruthy();
expect(
screen.getByText("Create a concise Monday status report from GitHub activity."),
).toBeTruthy();
expect(screen.getByText(/Fetch PRs/)).toBeTruthy();
expect(screen.getByTestId("skill-bundle-files").textContent).toContain("fetch_prs.py");
expect(screen.getByText(/usable in every conversation/)).toBeTruthy();
expect(screen.queryByText("Allow once")).toBeNull();
fireEvent.click(screen.getByText("Add to my skills"));
expect(onResolve).toHaveBeenCalledWith("i9", "allow");
fireEvent.click(screen.getByText("Not now"));
expect(onResolve).toHaveBeenCalledWith("i9", "deny");
});
});
+58 -6
View File
@@ -32,6 +32,43 @@ const EXTERNAL = new Set(["send_message", "send_file"]);
type ApprovalItem = Extract<Item, { kind: "approval" }>;
// Per-tool button copy (§7): a skill proposal is an "add", not an "allow". Shared with the
// parked Inbox card so both dialects match.
export function approvalActionLabels(name?: string): { allow: string; deny: string } {
return name === "save_skill"
? { allow: "Add to my skills", deny: "Not now" }
: { allow: "Allow once", deny: "Deny" };
}
// save_skill's review surface (SKILLS-SPEC §5.2): description, the full instructions
// (clamped, expandable, scrollable), every bundled file, and the guaranteed footer that
// answers "added WHERE, available WHEN". Shared verbatim with the parked Inbox card —
// one decision, one dialect.
export function SaveSkillPreview({ args }: { args: any }) {
return (
<>
{args?.description && <div className="approval-with">{String(args.description)}</div>}
{args?.instructions && <PreviewBlock text={String(args.instructions)} mono={false} />}
{Array.isArray(args?.files) && args.files.length > 0 && (
<div data-testid="skill-bundle-files">
{args.files.map((f: unknown, i: number) => (
<span className="approval-filechip" key={i}>
<span className="ico">
<Icon name="file" size={13} />
</span>
{String(f).split(/[\\/]/).pop() || String(f)}
</span>
))}
</div>
)}
<div className="approval-with">
Approving adds it to your skills on this computer usable in every conversation from
then on.
</div>
</>
);
}
// A `permissions` proposal on the create_scheduled_task consent card (§25): reads are
// disclosure lines, writes are the standing grants the approval mints.
interface PermissionLine {
@@ -65,14 +102,17 @@ export function scopeNote(
args: any,
category?: string,
): { text: string; external: boolean } {
// save_skill's corner answers WHERE (SKILLS-SPEC §5.2): the exact place to find, edit,
// or turn off the skill afterwards.
if (name === "save_skill") return { text: "saves to Settings ▸ Skills", external: false };
if (category === "connector") return { text: "acts on a connected service", external: true };
if (EXTERNAL.has(name)) {
const platform = String(args?.target ?? "").split(":")[0];
const names: Record<string, string> = { slack: "Slack", telegram: "Telegram" };
return { text: `leaves this Mac${names[platform] || platform || "a connected chat"}`, external: true };
return { text: `leaves this computer${names[platform] || platform || "a connected chat"}`, external: true };
}
const overwrite = name === "write_file" && args?.overwrite;
return { text: "stays on this Mac" + (overwrite ? " · overwrites the existing file" : ""), external: false };
return { text: "stays on this computer" + (overwrite ? " · overwrites the existing file" : ""), external: false };
}
// The proposed content/command, straight from the tool call's ARGS — the file/action
@@ -125,11 +165,13 @@ function Buttons({
onApprove,
runTask,
primaryLabel,
denyLabel = "Deny",
}: {
item: ApprovalItem;
onApprove: (decision: ApprovalDecision) => void;
runTask?: { id: string; title: string } | null;
primaryLabel: string;
denyLabel?: string;
}) {
const connector = item.category === "connector";
const offerStanding = !!(runTask && item.standingTarget);
@@ -152,7 +194,9 @@ function Buttons({
exactly the scope distinction §25 exists to draw. Same rule for run_shell:
the command-scoped button below is the specific (safer) grant, so the
tool-wide one stays out of the card. */}
{!connector && !offerStanding && item.name !== "run_shell" && (
{/* save_skill: no session-wide "always" every skill proposal gets its own review
(SKILLS-SPEC §5: one gate, always). */}
{!connector && !offerStanding && item.name !== "run_shell" && item.name !== "save_skill" && (
<button
className="btn"
title={`Always allow ${TOOL_VERBS[item.name]?.toLowerCase() || item.name} for this session`}
@@ -168,7 +212,7 @@ function Buttons({
)}
<span className="spacer" />
<button className="btn quiet-deny" onClick={() => onApprove("deny")}>
Deny
{denyLabel}
</button>
</div>
);
@@ -252,6 +296,8 @@ export function ApprovalCard({
{item.name === "send_message" && item.args?.text && (
<MessagePreview text={String(item.args.text)} />
)}
{/* save_skill (SKILLS-SPEC §5.2): the arguments ARE the review surface. */}
{item.name === "save_skill" && <SaveSkillPreview args={item.args} />}
{grants.length > 0 && (
<div className="approval-grants" data-testid="approval-grants">
@@ -272,7 +318,7 @@ export function ApprovalCard({
)}
{/* Long-tail tools: no bespoke preview — fall back to the compact args line. */}
{!FILE_WRITES.has(item.name) &&
!["run_shell", "send_message", "send_file"].includes(item.name) &&
!["run_shell", "send_message", "send_file", "save_skill"].includes(item.name) &&
!grants.length &&
shortArgs(item.args) && <div className="approval-rest">{shortArgs(item.args)}</div>}
{reason && <div className="approval-reason">{reason}</div>}
@@ -280,7 +326,13 @@ export function ApprovalCard({
{item.resolved ? (
<div className="resolved">Approved: {item.resolved.replace("_", " ")}</div>
) : (
<Buttons item={item} onApprove={onApprove} runTask={runTask} primaryLabel="Allow once" />
<Buttons
item={item}
onApprove={onApprove}
runTask={runTask}
primaryLabel={approvalActionLabels(item.name).allow}
denyLabel={approvalActionLabels(item.name).deny}
/>
)}
</div>
);
@@ -433,7 +433,7 @@ export function AutomationQuickstart({
<span className="block text-[13px] text-ink font-medium">
One sign-in unlocks every one-click connection
</span>
Connections are brokered by OpenWorker Cloud your tokens stay on this Mac.
Connections are brokered by OpenWorker Cloud your tokens stay on this computer.
<div className="flex items-center gap-3 mt-2">
{signinPhase ? (
<>
@@ -0,0 +1,150 @@
// SKILLS-SPEC §4.6 GUI — the composer's "/" force-run popup: opens only for a leading
// slash, lists only the session's effective (enabled) menu, filters while typing, and the
// picked skill rides onSend as its own field — never as message text.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Composer } from "./Composer";
const MENU = {
skills: [
{ name: "weekly-report", description: "Monday status report", scope: "global", enabled: true },
{ name: "greet", description: "says hello", scope: "project", enabled: true },
{ name: "muted-one", description: "muted here", scope: "global", enabled: false },
],
};
function stubFetch() {
const calls: { url: string; method: string }[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
calls.push({ url, method: (init?.method || "GET").toUpperCase() });
if (url.includes("/skills")) return { ok: true, json: async () => MENU } as Response;
return { ok: true, json: async () => ({}) } as Response;
}),
);
return calls;
}
const props = (extra: Partial<Parameters<typeof Composer>[0]> = {}) => ({
mode: "interactive",
model: "gpt-5.6-sol",
running: false,
connected: true,
sessionId: "s1",
onSend: vi.fn(),
onInterrupt: vi.fn(),
onModeChange: vi.fn(),
onModelChange: vi.fn(),
...extra,
});
const box = () => screen.getByPlaceholderText(/Ask the coworker/);
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("Composer / skills popup", () => {
it("opens on a leading '/' and lists only enabled skills from the effective menu", async () => {
stubFetch();
render(<Composer {...props()} />);
fireEvent.change(box(), { target: { value: "/" } });
await screen.findByTestId("skill-popup");
expect(await screen.findByText("/weekly-report")).toBeTruthy();
expect(screen.getByText("/greet")).toBeTruthy();
expect(screen.queryByText("/muted-one")).toBeNull(); // muted → not offered
expect(screen.getByText("project")).toBeTruthy(); // scope badge
});
it("filters as you type", async () => {
stubFetch();
render(<Composer {...props()} />);
fireEvent.change(box(), { target: { value: "/" } });
await screen.findByText("/weekly-report");
fireEvent.change(box(), { target: { value: "/wee" } });
expect(screen.getByText("/weekly-report")).toBeTruthy();
expect(screen.queryByText("/greet")).toBeNull();
});
it("does NOT open for a mid-text slash", async () => {
stubFetch();
render(<Composer {...props()} />);
fireEvent.change(box(), { target: { value: "rate 5/10 please" } });
expect(screen.queryByTestId("skill-popup")).toBeNull();
});
it("selecting inserts /name inline; the send strips the prefix and carries the skill field", async () => {
stubFetch();
const p = props();
render(<Composer {...p} />);
fireEvent.change(box(), { target: { value: "/gr" } });
fireEvent.click(await screen.findByRole("option", { name: /greet/ }));
expect((box() as HTMLTextAreaElement).value).toBe("/greet "); // inline, no chip
fireEvent.change(box(), { target: { value: "/greet say hi to the team" } });
fireEvent.keyDown(box(), { key: "Enter" });
await waitFor(() => expect(p.onSend).toHaveBeenCalled());
expect(p.onSend).toHaveBeenCalledWith("say hi to the team", [], "greet");
});
it("a skill-only send works and Enter inside the popup never sends the query text", async () => {
stubFetch();
const p = props();
render(<Composer {...p} />);
fireEvent.change(box(), { target: { value: "/wee" } });
await screen.findByText("/weekly-report");
fireEvent.keyDown(box(), { key: "Enter" }); // selects, does not send
expect(p.onSend).not.toHaveBeenCalled();
expect((box() as HTMLTextAreaElement).value).toBe("/weekly-report ");
fireEvent.keyDown(box(), { key: "Enter" }); // now sends, skill-only
await waitFor(() => expect(p.onSend).toHaveBeenCalledWith("", [], "weekly-report"));
});
it("editing the /name prefix away un-picks the skill", async () => {
stubFetch();
const p = props();
render(<Composer {...p} />);
fireEvent.change(box(), { target: { value: "/gr" } });
fireEvent.click(await screen.findByRole("option", { name: /greet/ }));
fireEvent.change(box(), { target: { value: "hello plain" } }); // prefix gone
fireEvent.keyDown(box(), { key: "Enter" });
await waitFor(() => expect(p.onSend).toHaveBeenCalledWith("hello plain", [], undefined));
});
it("Escape closes the popup and no popup ever opens without a sessionId", async () => {
stubFetch();
render(<Composer {...props()} />);
fireEvent.change(box(), { target: { value: "/gr" } });
await screen.findByTestId("skill-popup");
fireEvent.keyDown(box(), { key: "Escape" });
expect(screen.queryByTestId("skill-popup")).toBeNull();
cleanup();
stubFetch();
render(<Composer {...props({ sessionId: undefined })} />);
fireEvent.change(box(), { target: { value: "/" } });
expect(screen.queryByTestId("skill-popup")).toBeNull();
});
});
describe("Composer — the doorway prefill (SKILLS-SPEC §5.2)", () => {
it("a prefill arriving together with a session switch survives the draft clear", async () => {
stubFetch();
const { rerender } = render(<Composer {...props({ resetKey: "s1" })} />);
// The doorway does both in one render: new session (resetKey) + prefill. The clear
// effect must run BEFORE the prefill effect or the prefill is wiped (regression).
rerender(
<Composer
{...props({
resetKey: "s2",
prefill: { text: "Build a new skill for me: release procedure", nonce: 1 },
})}
/>,
);
await waitFor(() => {
expect((box() as HTMLTextAreaElement).value).toBe(
"Build a new skill for me: release procedure",
);
});
});
});
+149 -24
View File
@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import type { Attachment, SessionUsage } from "../types";
import { isPdfFile, readFile } from "../attach";
import { getSettings, inspectPdf } from "../api";
import { getSettings, inspectPdf, sessionSkills, type SessionSkillRow } from "../api";
import { formatTokens, totalTokens } from "../usage";
import { Dropdown, type Option } from "./Dropdown";
import { Icon } from "./Icon";
@@ -59,7 +59,10 @@ interface Props {
modelReady?: boolean;
onConnectModel?: () => void;
onConfigureVoiceInput?: () => void;
onSend: (text: string, attachments?: Attachment[]) => void;
onSend: (text: string, attachments?: Attachment[], skill?: string) => void;
// Feeds the "/" force-run popup (SKILLS-SPEC §4.1 #3): the popup lists this session's
// effective skill menu. Absent (e.g. tests without sessions) → the popup never opens.
sessionId?: string;
onInterrupt: () => void;
onModeChange: (mode: string) => void;
onModelChange: (model: string) => void;
@@ -84,11 +87,53 @@ interface Props {
// Context-window size (tokens) of the ACTIVE model, from the curated matrix;
// undefined hides the fill meter (unverified/custom models) but keeps the counts.
contextWindow?: number;
// Settings toggle (default off): true shows the fill bar instead of the session total.
contextBar?: boolean;
}
export function Composer(props: Props) {
const [text, setText] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
// "/" force-run (SKILLS-SPEC §4.1 #3). The popup derives from the draft: it is open while
// the text is a bare "/query" (no whitespace yet) and no skill is picked. Selecting a row
// inserts "/name " INLINE in the box (Claude-Code style — the slash text IS the state);
// the user keeps typing after it, and on send the prefix is stripped while the skill name
// rides the user_message as its own field. Editing the prefix away un-picks the skill.
const [pendingSkill, setPendingSkill] = useState<SessionSkillRow | null>(null);
const [slashSkills, setSlashSkills] = useState<SessionSkillRow[] | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const prefixIntact =
pendingSkill !== null &&
(text === `/${pendingSkill.name}` || text.startsWith(`/${pendingSkill.name} `));
useEffect(() => {
if (pendingSkill && !prefixIntact) setPendingSkill(null);
}, [pendingSkill, prefixIntact]);
const slashQuery =
!prefixIntact && props.sessionId && text.startsWith("/") && !/\s/.test(text.slice(1))
? text.slice(1).toLowerCase()
: null;
const slashMatches = (slashSkills ?? []).filter((s) =>
s.name.toLowerCase().includes(slashQuery ?? ""),
);
useEffect(() => {
// Fetch on each popup open (fresh menu); drop when closed.
if (slashQuery === null) {
setSlashSkills(null);
setSlashIndex(0);
return;
}
if (slashSkills === null && props.sessionId) {
sessionSkills(props.sessionId, props.workspace)
.then((all) => setSlashSkills(all.filter((s) => s.enabled)))
.catch(() => setSlashSkills([]));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slashQuery === null]);
const pickSkill = (s: SessionSkillRow) => {
setPendingSkill(s);
setText(`/${s.name} `);
textareaRef.current?.focus();
};
const [dragging, setDragging] = useState(false);
const [attachMenuOpen, setAttachMenuOpen] = useState(false);
const [dictation, setDictation] = useState<DictationStatus | null>(null);
@@ -117,6 +162,17 @@ export function Composer(props: Props) {
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}, [text]);
// Clear the draft when the conversation changes, so a half-typed message / picked file doesn't
// bleed from one session into another. Declared BEFORE the prefill effect: when both fire in
// the same render (the Skills doorway starts a new session AND prefills it), effects run in
// declaration order — clear first, then the prefill lands on the fresh session.
useEffect(() => {
setText("");
setAttachments([]);
setPendingSkill(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.resetKey]);
// Apply a prefill (text + attachments) pushed from outside, then focus the composer. Applied at
// most once per nonce (a ref guards against StrictMode/re-render double-fires), and attachments
// are de-duplicated so the same file never lands twice.
@@ -131,14 +187,6 @@ export function Composer(props: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.prefill?.nonce]);
// Clear the draft when the conversation changes, so a half-typed message / picked file doesn't
// bleed from one session into another.
useEffect(() => {
setText("");
setAttachments([]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.resetKey]);
// Dictation is intentionally native-only: the browser/dev build remains a local server client
// and never turns on the browser microphone or ships audio anywhere.
useEffect(() => {
@@ -262,19 +310,54 @@ export function Composer(props: Props) {
const needsModel = props.modelReady === false;
const submit = () => {
const t = text.trim();
if ((!t && attachments.length === 0) || props.running || dictation?.recording || dictationBusy) return;
// While the "/" popup is open the draft is a query, not a message — never send it.
if (slashQuery !== null) return;
// The visible "/name " prefix is UI state, not message text — strip it for the send;
// the skill rides as its own field.
const skill = prefixIntact ? pendingSkill!.name : undefined;
const t = (skill ? text.slice(skill.length + 1) : text).trim();
if (
(!t && attachments.length === 0 && !skill) ||
props.running ||
dictation?.recording ||
dictationBusy
)
return;
// No model connected: keep the draft (don't drop it) and send the user to setup instead.
if (needsModel) {
props.onConnectModel?.();
return;
}
props.onSend(t, attachments);
props.onSend(t, attachments, skill);
setText("");
setAttachments([]);
setPendingSkill(null);
};
const onKey = (e: React.KeyboardEvent) => {
if (slashQuery !== null) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSlashIndex((i) => Math.min(i + 1, Math.max(slashMatches.length - 1, 0)));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSlashIndex((i) => Math.max(i - 1, 0));
return;
}
if (e.key === "Escape") {
e.preventDefault();
setText("");
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const chosen = slashMatches[slashIndex];
if (chosen) pickSkill(chosen);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
@@ -340,7 +423,9 @@ export function Composer(props: Props) {
// The send button is accent only when there's something to send — subtle grey otherwise, so the
// composer isn't carrying a constant blue dot.
const hasContent = text.trim().length > 0 || attachments.length > 0;
// A pinned /skill is sendable content on its own (tester catch 2026-07-26: the arrow
// stayed grey after picking a skill, reading as "stuck").
const hasContent = text.trim().length > 0 || attachments.length > 0 || !!pendingSkill;
return (
<div className="composer-wrap px-6 pb-5 pt-4">
@@ -394,6 +479,37 @@ export function Composer(props: Props) {
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
}}
>
{/* "/" force-run popup in-flow above the textarea; rows are the session's
effective menu only (muted/disabled skills never appear). */}
{slashQuery !== null && (
<div className="px-2 pt-2" data-testid="skill-popup" role="listbox" aria-label="Skills">
{slashSkills === null ? (
<div className="px-2 py-1.5 text-[12px] text-faint">Loading skills</div>
) : slashMatches.length === 0 ? (
<div className="px-2 py-1.5 text-[12px] text-faint">No matching skills.</div>
) : (
slashMatches.map((s, i) => (
<button
key={s.name}
role="option"
aria-selected={i === slashIndex}
className={
"w-full text-left flex items-center gap-2 px-2 py-1.5 rounded-lg " +
(i === slashIndex ? "bg-paper" : "hover:bg-paper")
}
onMouseEnter={() => setSlashIndex(i)}
onClick={() => pickSkill(s)}
>
<span className="text-[13px] font-medium text-accent shrink-0">/{s.name}</span>
<span className="text-[12px] text-faint truncate flex-1">{s.description}</span>
<span className="text-[10.5px] px-1.5 py-0.5 rounded-full border border-line text-faint shrink-0">
{s.scope}
</span>
</button>
))
)}
</div>
)}
<textarea
ref={textareaRef}
className="w-full block px-3.5 pt-3.5 pb-1.5 text-[14.5px]"
@@ -469,13 +585,14 @@ export function Composer(props: Props) {
<span className="ml-auto" />
{/* token usage (OPE-42) a quiet meter+count chip; hidden until the server
reports usage. Fill = context-window occupancy (bounded), count = session
consumption (unbounded, so never a fill). */}
{/* token usage (OPE-42) a quiet chip; hidden until the server reports usage.
Shows the context-window fill bar alone (the session total lives in the
popover), or the session total when there's no window / the bar is off. */}
{!dictation?.recording && props.usage && totalTokens(props.usage) > 0 && (
<UsageChip
usage={props.usage}
contextWindow={props.contextWindow}
contextBar={props.contextBar}
model={props.model}
modelLabels={props.modelLabels}
/>
@@ -572,11 +689,13 @@ export function Composer(props: Props) {
function UsageChip({
usage,
contextWindow,
contextBar,
model,
modelLabels,
}: {
usage: SessionUsage;
contextWindow?: number;
contextBar?: boolean;
model: string;
modelLabels?: Record<string, string>;
}) {
@@ -585,6 +704,8 @@ function UsageChip({
const pct = contextWindow
? Math.min(100, Math.round((usage.context / contextWindow) * 100))
: null;
// Settings can hide the bar; without a known window there is nothing to fill either.
const showBar = pct !== null && contextBar === true;
const labelFor = (id: string) =>
id === "unknown" ? "Unknown model" : modelLabels?.[id] || shortModel(id);
// One field per line, session-summed (owner ask 2026-07-28). Values are cumulative
@@ -605,21 +726,25 @@ function UsageChip({
aria-expanded={open}
aria-label="Token usage"
title={
pct !== null
? `Token usage — ${pct}% of the context window used`
: "Token usage this session"
showBar
? `Context window ${pct}% full · ${formatTokens(total)} tokens this session`
: `Token usage this session: ${formatTokens(total)}`
}
data-testid="usage-chip"
>
{pct !== null && (
<span className="w-7 h-1 rounded-full bg-line overflow-hidden" aria-hidden="true">
{/* The bar is the context-window fill; pairing it with the session TOTAL read as
"total is N% of the window", which it never was. Bar alone when we have a
window, the session total only when we don't (so the chip is never empty). */}
{showBar ? (
<span className="w-12 h-1.5 rounded-full bg-line overflow-hidden" aria-hidden="true">
<span
className="block h-full bg-accent transition-all"
style={{ width: `${Math.max(pct, 4)}%` }}
style={{ width: `${Math.max(pct as number, 4)}%` }}
/>
</span>
) : (
<span className="tabular-nums">{formatTokens(total)}</span>
)}
<span className="tabular-nums">{formatTokens(total)}</span>
</button>
{open && (
<>
+13
View File
@@ -9,6 +9,7 @@ export type IconName =
| "signOut"
| "chat"
| "diamond"
| "book"
| "search"
| "folder"
| "folderPlus"
@@ -66,6 +67,18 @@ export function Icon({
};
switch (name) {
case "book":
// A playbook — Skills are the worker's recipe book (Settings ▸ Skills).
// Hardcover with a full spine + two text lines: "written instructions inside".
// (Owner-picked from a 15px-preview comparison, 2026-07-27.)
return (
<svg {...s}>
<path d="M6 2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z" />
<path d="M8.5 2v20" />
<path d="M11.5 8.5h5" />
<path d="M11.5 12h3.5" />
</svg>
);
case "sparkle":
// Filled 4-point twinkle — crisp at small sizes.
return (
+13 -4
View File
@@ -2,7 +2,13 @@ import { useState, type ReactNode } from "react";
import type { InboxItem } from "../api";
import type { QuestionOption } from "../types";
import { humanizeApprovalTitle } from "../humanize";
import { PreviewBlock, scopeNote, TitleText } from "./ApprovalCard";
import {
approvalActionLabels,
PreviewBlock,
SaveSkillPreview,
scopeNote,
TitleText,
} from "./ApprovalCard";
// One Inbox item, rendered identically in the Inbox list and inline in its own session view
// (answer-in-context). Resolving either place hits the same item id — first responder wins.
@@ -323,7 +329,10 @@ export function InboxItemCard({
<div className="text-[15px] font-semibold mt-0.5 leading-snug">{item.title}</div>
</>
)}
{item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.content === "string" ? (
{item.kind === "approval" && item.data?.tool === "save_skill" ? (
// Parked skill proposals wear the same review surface as the live card (§5.2).
<SaveSkillPreview args={item.data.arguments} />
) : item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.content === "string" ? (
<PreviewBlock text={item.data.arguments.content} />
) : item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.command === "string" ? (
<PreviewBlock text={item.data.arguments.command} />
@@ -337,7 +346,7 @@ export function InboxItemCard({
className={item.data?.tool ? BTN_ACCENT : BTN_PRIMARY}
onClick={() => onResolve(item.id, "allow")}
>
{item.data?.tool ? "Allow once" : "Approve"}
{item.data?.tool ? approvalActionLabels(item.data.tool).allow : "Approve"}
</button>
{/* Task-persistent standing grant (§25) present only when the approval was
raised inside an automation run AND the call can carry a tool+target rule.
@@ -355,7 +364,7 @@ export function InboxItemCard({
className={item.data?.tool ? BTN_QUIET : BTN_BORDERED}
onClick={() => onResolve(item.id, "deny")}
>
Deny
{item.data?.tool ? approvalActionLabels(item.data.tool).deny : "Deny"}
</button>
</div>
) : isQuestion ? (
+3 -3
View File
@@ -122,7 +122,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
<h1 className="text-[19px] font-semibold">Welcome to OpenWorker<span className="beta-tag">BETA</span></h1>
<p className="text-[13px] text-muted mt-0.5 mb-4">
Pick a model provider to get started OpenWorker runs on your own key, and your
key and your data stay on this Mac.
key and your data stay on this computer.
</p>
{!ps.sel ? (
@@ -239,7 +239,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
Sign in for one-click connections
</span>
OpenWorker handles the OAuth for 20+ tools no dev consoles, no pasted keys.
Tokens stay on this Mac.
Tokens stay on this computer.
</span>
{signinPhase ? (
<span className="inline-flex items-center gap-2 text-[12.5px] text-muted shrink-0">
@@ -310,7 +310,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
</div>
<p className="text-[11px] text-faint mt-3">
30+ more tools on the Connectors page add or remove anytime. Tokens stay on
this Mac.
this computer.
</p>
</section>
)}
+28
View File
@@ -159,6 +159,15 @@ export function RightRail({
content={content}
onReload={reloadSelected}
onBack={() => setSelected(null)}
onOpenEntry={(path) =>
setSelected({
path,
name: path.split("/").pop() || path,
kind: kindFromPath(path),
size: 0,
modified_at: 0,
})
}
/>
) : (
<>
@@ -291,12 +300,15 @@ function ArtifactViewer({
content,
onReload,
onBack,
onOpenEntry,
}: {
sessionId: string;
artifact: ArtifactInfo;
content: ArtifactContent | null;
onReload: () => Promise<void>;
onBack: () => void;
// Folder listings: open a child entry in the viewer (files and subfolders alike).
onOpenEntry?: (path: string) => void;
}) {
const [reloadKey, setReloadKey] = useState(0);
const isHtml = content?.kind === "html" && !content.error;
@@ -381,6 +393,22 @@ function ArtifactViewer({
<CsvTable text={content.content || ""} />
) : content.kind === "sheet" ? (
<SheetViewer dataUrl={content.data_url || ""} />
) : content.kind === "folder" ? (
// A linked directory (e.g. a skill package): render the listing, click through.
<div className="artifact-folderlist" data-testid="artifact-folder">
{(content.entries || []).map((e) => (
<button
key={e.name}
className="artifact-folder-row"
onClick={() => onOpenEntry?.(`${artifact.path.replace(/\/+$/, "")}/${e.name}`)}
>
<Icon name={e.dir ? "folder" : "file"} size={14} />
<span className="artifact-folder-name">{e.name}</span>
{!e.dir && <span className="artifact-folder-size">{formatBytes(e.size)}</span>}
</button>
))}
{!content.entries?.length && <div className="rail-muted">This folder is empty.</div>}
</div>
) : content.kind === "office" ? (
<div className="artifact-open-prompt">
<Icon name="panelOpen" size={28} />
+176 -5
View File
@@ -2,11 +2,14 @@ import { useEffect, useState } from "react";
import {
getSettings,
getTrustedWorkspaces,
setCompactionSettings,
setContextBar,
setOnboarded,
setPdfSettings,
setScratchBase,
setSessionsPeek,
setWorkspaceTrusted,
type CompactionSettings,
type ModelSettings,
type PdfSettings,
type WorkspaceCommandTrust,
@@ -38,6 +41,7 @@ import { PanelHead } from "./IntegrationsView";
import { ModelsTab } from "./ManageTabs";
import { GalleryModal } from "./GalleryModal";
import { PersonasTab } from "./PersonasTab";
import { SkillsTab } from "./SkillsTab";
import { showPersonas } from "../flags";
// Settings, restructured (Option 2) into a full-page surface that mirrors IntegrationsView's shell:
@@ -47,7 +51,7 @@ import { showPersonas } from "../flags";
// Models + Personas host the existing tab components inside the page shell (field re-skin to follow).
// "appearance" is the General tab's stable key — callers deep-link with it, so the
// rename (UX-021) changed only the label. "files" folded into General as a card.
type SetTab = "appearance" | "models" | "voice" | "personas";
type SetTab = "appearance" | "models" | "skills" | "voice" | "personas";
const CARD = "rounded-xl2 border border-line bg-panel";
const FIELD_LABEL = "text-[12.5px] font-medium text-ink";
@@ -58,9 +62,10 @@ const BTN_ACCENT = "text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shri
const BTN_BORDERED =
"text-[12.5px] px-3 py-2 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" | "sparkle" }[] = [
const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" | "sparkle" | "book" }[] = [
{ key: "appearance", label: "General", icon: "sliders" },
{ key: "models", label: "Models", icon: "code" },
{ key: "skills", label: "Skills", icon: "book" },
{ key: "voice", label: "Voice input", icon: "mic" },
{ key: "personas", label: "Personas", icon: "sparkle" },
];
@@ -68,9 +73,13 @@ const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" |
export function SettingsView({
initialTab,
onOpenPersona,
onCreateSkill,
}: {
initialTab?: SetTab;
onOpenPersona?: (id: string) => void;
// Skills doorway (SKILLS-SPEC §5.2): start a new conversation with the description
// prefilled — the worker builds the skill and proposes it via save_skill.
onCreateSkill?: (description: string) => void;
}) {
// Personas is flag-gated (hidden for launch) — filter the tab AND coerce a stale
// deep-link to it (openSettings("personas") callers) so the page never opens on a
@@ -118,8 +127,11 @@ export function SettingsView({
not under General. */}
<div className="mt-6">
<TokenSavingsCard />
<CompactionCard />
</div>
</section>
) : tab === "skills" ? (
<SkillsTab onCreateSkill={onCreateSkill} />
) : tab === "voice" ? (
<VoiceInputSection />
) : (
@@ -425,6 +437,8 @@ function AppearanceSection() {
<SidebarCard />
<ContextBarCard />
<FilesCard />
<TrustedWorkspacesCard />
@@ -584,9 +598,9 @@ function UpdateInline() {
// -- Sidebar density -------------------------------------------------------------
// -- Token savings (PDF attachments; owner ask, 2026-07-17) ---------------------
// Attachments replay with EVERY turn, so a big PDF quietly multiplies token spend.
// Auto-compaction of long histories is a planned follow-up (punchlist §7) — until
// then this card is the user's dial: attach thresholds + the fallback for models
// without native PDF support.
// This card is the attachment dial: attach thresholds + the fallback for models
// without native PDF support. (Long-history spend is handled by auto-compaction —
// the CompactionCard below, OPE-27.)
function TokenSavingsCard() {
const [pdf, setPdf] = useState<PdfSettings | null>(null);
@@ -672,6 +686,163 @@ function TokenSavingsCard() {
);
}
// -- Context compaction (OPE-27) ------------------------------------------------
// Long sessions are summarized automatically when they approach the model's context
// limit, so work continues instead of hitting a raw provider error. Two spec'd
// overrides (trigger % + token cap) and the summarizer-model pin — nothing more.
function CompactionCard() {
const [cfg, setCfg] = useState<CompactionSettings | null>(null);
const [models, setModels] = useState<string[]>([]);
const [labels, setLabels] = useState<Record<string, string>>({});
useEffect(() => {
getSettings()
.then((s) => {
setCfg({
compaction_threshold_pct: s.compaction_threshold_pct ?? 0.8,
compaction_cap_tokens: s.compaction_cap_tokens ?? 250_000,
compaction_model: s.compaction_model ?? "",
});
setModels(s.models || []);
setLabels(s.model_labels || {});
})
.catch(() =>
setCfg({
compaction_threshold_pct: 0.8,
compaction_cap_tokens: 250_000,
compaction_model: "",
}),
);
}, []);
const save = async (patch: Partial<CompactionSettings>) => {
setCfg((p) => (p ? { ...p, ...patch } : p));
await setCompactionSettings(patch);
};
if (!cfg) return null;
const modelLabel = (id: string) => labels[id]?.split(" · ")[0] || id;
return (
<div className={CARD + " p-4 mb-4"} data-testid="compaction-card">
<div className={FIELD_LABEL}>Context compaction</div>
<div className={FIELD_HELP}>
Long sessions are compacted automatically: older turns are summarized so the
coworker keeps working instead of running out of context. Your visible transcript
is never changed a small marker shows where compaction happened.
</div>
<div className="mt-3 flex items-center gap-5 flex-wrap">
<label className="flex items-center gap-2.5">
<span className="text-[13px] text-ink">Compact at</span>
<input
type="number"
min={10}
max={95}
value={Math.round(cfg.compaction_threshold_pct * 100)}
data-testid="compaction-threshold"
className="w-16 px-2 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"
onChange={(e) =>
save({
compaction_threshold_pct:
Math.max(10, Math.min(Number(e.target.value) || 80, 95)) / 100,
})
}
/>
<span className="text-[12.5px] text-muted">% of the context window</span>
</label>
<label className="flex items-center gap-2.5">
<span className="text-[13px] text-ink">or at</span>
<input
type="number"
min={10_000}
max={2_000_000}
step={10_000}
value={cfg.compaction_cap_tokens}
data-testid="compaction-cap"
className="w-28 px-2 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"
onChange={(e) =>
save({
compaction_cap_tokens: Math.max(
10_000,
Math.min(Number(e.target.value) || 250_000, 2_000_000),
),
})
}
/>
<span className="text-[12.5px] text-muted">tokens, whichever is smaller</span>
</label>
</div>
<div className={FIELD_HELP}>
The cap makes very-large-context models compact early quality and speed degrade
well before their nominal limit.
</div>
<div className="mt-3 flex items-center gap-2.5">
<span className="text-[13px] text-ink">Summarizer model</span>
<select
value={cfg.compaction_model}
data-testid="compaction-model"
className="px-2 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"
onChange={(e) => save({ compaction_model: e.target.value })}
>
<option value="">Session&rsquo;s own model (default)</option>
{models.map((m) => (
<option key={m} value={m}>
{modelLabel(m)}
</option>
))}
</select>
</div>
<div className={FIELD_HELP}>
The summary is written by this model. The default follows whatever model the
session is using.
</div>
</div>
);
}
// -- Composer: context-window bar (owner ask 2026-07-30) ------------------------
// The chip's bar is context-window occupancy; the session total (unbounded) lives in
// the popover. Some people would rather not watch a meter at all, hence the toggle.
function ContextBarCard() {
const [shown, setShown] = useState<boolean | null>(null);
useEffect(() => {
getSettings()
.then((s) => setShown(s.context_bar === true))
.catch(() => setShown(false));
}, []);
const save = async (next: boolean) => {
setShown(next);
await setContextBar(next);
};
if (shown === null) return null;
return (
<div className={CARD + " p-4 mb-4"} data-testid="context-bar-card">
<div className={FIELD_LABEL}>Composer</div>
<label className="flex items-start gap-3 py-2">
<input
type="checkbox"
className="mt-0.5"
data-testid="context-bar-toggle"
checked={shown}
onChange={(e) => save(e.target.checked)}
/>
<span>
<span className="block text-[13px] text-ink">Show the context window bar</span>
<span className="block text-[12px] text-muted">
A small meter showing how full the model&rsquo;s context window is. Turn it off
to show this session&rsquo;s token total instead; either way the full breakdown
is one click away.
</span>
</span>
</label>
</div>
);
}
function SidebarCard() {
const [peek, setPeek] = useState<number | null>(null);
@@ -0,0 +1,286 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { SkillsTab } from "./SkillsTab";
// SKILLS-SPEC §5/§6 GUI — Settings ▸ Skills: list + badges + rich-skill file counts, form
// validation, the doors (write form / upload-with-preview / doorway-to-conversation).
type Call = { url: string; method: string; body: any };
function stubFetch(routes: { match: string; method?: string; json: any }[]) {
const calls: Call[] = [];
const fn = vi.fn(async (url: string, init?: RequestInit) => {
const method = (init?.method || "GET").toUpperCase();
calls.push({ url, method, body: init?.body ? JSON.parse(String(init.body)) : undefined });
for (const r of routes) {
if (url.includes(r.match) && (!r.method || r.method === method)) {
return { ok: true, json: async () => r.json } as Response;
}
}
return { ok: true, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fn);
return calls;
}
const ROW = {
name: "weekly-report",
description: "Monday status report",
instructions: "1. Collect updates\n2. Write it up",
scope: "global",
source: "local",
enabled: true,
path: "/skills/weekly-report",
};
const UPLOADED_ROW = {
...ROW,
name: "greet",
description: "says hello",
source: "uploaded",
enabled: false,
};
const LIST = { skills: [ROW, UPLOADED_ROW] };
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// The single add-action: open the "Add skill" menu, pick a door (SKILLS-SPEC §5).
const openWriteForm = async () => {
fireEvent.click(await screen.findByRole("button", { name: /Add skill/ }));
fireEvent.click(screen.getByText("Write it myself"));
};
describe("SkillsTab", () => {
it("renders rows with provenance badges and dims disabled skills", async () => {
stubFetch([{ match: "/v1/skills", method: "GET", json: LIST }]);
render(<SkillsTab />);
expect(await screen.findByText("weekly-report")).toBeTruthy();
expect(screen.getByText("Monday status report")).toBeTruthy();
expect(screen.queryByText("global")).toBeNull(); // no scope badges — global-only (§4.7)
expect(screen.getByText("uploaded")).toBeTruthy(); // provenance badge stays
const toggles = screen.getAllByRole("switch");
expect((toggles[0] as HTMLInputElement).checked).toBe(true);
expect((toggles[1] as HTMLInputElement).checked).toBe(false);
});
it("blocks Save until name and instructions are filled", async () => {
stubFetch([{ match: "/v1/skills", method: "GET", json: { skills: [] } }]);
render(<SkillsTab />);
await openWriteForm();
const save = screen.getByText("Save skill") as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "greet" } });
expect(save.disabled).toBe(true); // instructions still empty
fireEvent.change(screen.getByLabelText("Instructions"), {
target: { value: "Say hello." },
});
expect(save.disabled).toBe(false);
});
it("creates a skill (global, no scope field) and refreshes the list", async () => {
const calls = stubFetch([
{ match: "/v1/skills", method: "GET", json: { skills: [] } },
{ match: "/v1/skills", method: "POST", json: { ok: true } },
]);
render(<SkillsTab />);
await openWriteForm();
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "greet" } });
fireEvent.change(screen.getByLabelText("Instructions"), {
target: { value: "Say hello." },
});
fireEvent.click(screen.getByText("Save skill"));
await waitFor(() => {
const post = calls.find((c) => c.method === "POST" && c.url.endsWith("/v1/skills"));
expect(post?.body).toMatchObject({ name: "greet", instructions: "Say hello." });
expect(post?.body.workspace).toBeUndefined(); // global-only: no scope/workspace sent
});
// list re-fetched after save
expect(calls.filter((c) => c.method === "GET" && c.url.includes("/v1/skills")).length).toBeGreaterThan(1);
});
it("edit prefills the form (name locked, body loaded) and PATCHes on save", async () => {
const calls = stubFetch([
{ match: "/v1/skills", method: "GET", json: LIST },
{ match: "/v1/skills/weekly-report", method: "PATCH", json: { ok: true } },
]);
render(<SkillsTab />);
await screen.findByText("weekly-report");
fireEvent.click(screen.getAllByTitle("Edit")[0]);
const name = screen.getByLabelText("Name") as HTMLInputElement;
expect(name.value).toBe("weekly-report");
expect(name.disabled).toBe(true);
const body = screen.getByLabelText("Instructions") as HTMLTextAreaElement;
expect(body.value).toContain("Collect updates");
fireEvent.change(body, { target: { value: "New steps" } });
fireEvent.click(screen.getByText("Save skill"));
await waitFor(() => {
const patch = calls.find((c) => c.method === "PATCH");
expect(patch?.url).toContain("/v1/skills/weekly-report");
expect(patch?.body.instructions).toBe("New steps");
});
});
it("delete is two-step: arm, then DELETE on confirm", async () => {
const calls = stubFetch([
{ match: "/v1/skills", method: "GET", json: LIST },
{ match: "/v1/skills/weekly-report", method: "DELETE", json: { ok: true } },
]);
render(<SkillsTab />);
await screen.findByText("weekly-report");
// arm via the trash button (renders "Confirm delete" once armed)
fireEvent.click(screen.getByLabelText("Delete weekly-report"));
expect(calls.some((c) => c.method === "DELETE")).toBe(false);
const confirm = await screen.findByText("Confirm delete");
fireEvent.click(confirm);
await waitFor(() => {
expect(calls.some((c) => c.method === "DELETE" && c.url.includes("weekly-report"))).toBe(true);
});
});
it("the enabled switch PATCHes {enabled} and teaches the off rule + physics footnote", async () => {
const calls = stubFetch([
{ match: "/v1/skills", method: "GET", json: LIST },
{ match: "/v1/skills/weekly-report", method: "PATCH", json: { ok: true } },
]);
render(<SkillsTab />);
await screen.findByText("weekly-report");
fireEvent.click(screen.getByLabelText("weekly-report enabled"));
await waitFor(() => {
const patch = calls.find((c) => c.method === "PATCH");
expect(patch?.body).toMatchObject({ enabled: false });
});
const status = await screen.findByRole("status");
expect(status.textContent).toContain("weekly-report"); // name-first — WHICH skill
expect(status.textContent).toContain("turned off everywhere");
expect(status.textContent).toContain("clean slate"); // the guaranteed remedy, in place
});
it("upload shows the parsed preview and installs nothing until confirmed", async () => {
const calls = stubFetch([
{ match: "/v1/skills/upload/confirm", method: "POST", json: { ok: true } },
{
match: "/v1/skills/upload",
method: "POST",
json: {
ok: true,
token: "t1",
name: "greet",
description: "says hello",
instructions: "Say hello warmly.",
files: ["notes.txt"],
},
},
{ match: "/v1/skills", method: "GET", json: { skills: [] } },
]);
render(<SkillsTab />);
const input = (await screen.findByLabelText("Upload a skill archive")) as HTMLInputElement;
const file = new File([new Uint8Array([80, 75, 3, 4])], "greet.zip", { type: "application/zip" });
fireEvent.change(input, { target: { files: [file] } });
await screen.findByText("Review before installing");
expect(screen.getByText("Say hello warmly.")).toBeTruthy();
expect(screen.getByText(/notes\.txt/)).toBeTruthy();
expect(calls.some((c) => c.url.includes("/upload/confirm"))).toBe(false); // preview ≠ install
fireEvent.click(screen.getByText("Install skill"));
await waitFor(() => {
const confirm = calls.find((c) => c.url.includes("/upload/confirm"));
expect(confirm?.body).toMatchObject({ token: "t1" });
});
});
it("Add skill menu: three doors; Create with OpenWorker hands off to a conversation", async () => {
const calls = stubFetch([{ match: "/v1/skills", method: "GET", json: { skills: [] } }]);
const onCreateSkill = vi.fn();
render(<SkillsTab onCreateSkill={onCreateSkill} />);
fireEvent.click(await screen.findByRole("button", { name: /Add skill/ }));
// The three doors (§5), each with its teaching subtitle.
expect(screen.getByText("Write it myself")).toBeTruthy();
expect(screen.getByText("Import a file")).toBeTruthy();
expect(screen.getByText(/you review before it installs/)).toBeTruthy();
expect(screen.getByText(/asks before adding it to\s+your skills/)).toBeTruthy();
fireEvent.click(screen.getByText("Create with OpenWorker"));
// Straight to the conversation — the composer is where you describe it (§5.2).
expect(onCreateSkill).toHaveBeenCalledWith("");
// Settings never drafts: no POST of any kind happened.
expect(calls.some((c) => c.method === "POST")).toBe(false);
});
it("offers no scope UI at all — skills are global (§4.7)", async () => {
stubFetch([{ match: "/v1/skills", method: "GET", json: { skills: [] } }]);
render(<SkillsTab />);
await openWriteForm();
expect(screen.queryByText("Available in")).toBeNull();
expect(screen.queryByLabelText("Everywhere")).toBeNull();
expect(screen.queryByLabelText("Only one project")).toBeNull();
expect(screen.queryByText(/Move to/)).toBeNull();
});
it("shows the new-session confirmation line after creating a skill", async () => {
stubFetch([
{ match: "/v1/skills", method: "GET", json: { skills: [] } },
{ match: "/v1/skills", method: "POST", json: { ok: true } },
]);
render(<SkillsTab />);
await openWriteForm();
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "greet" } });
fireEvent.change(screen.getByLabelText("Instructions"), { target: { value: "x" } });
fireEvent.click(screen.getByText("Save skill"));
const status = await screen.findByRole("status");
expect(status.textContent).toContain("greet"); // name-first — WHICH skill
expect(status.textContent).toContain("can now use it in every conversation");
});
it("the list is the page: no standing add-surfaces, no drafting remnants", async () => {
stubFetch([{ match: "/v1/skills", method: "GET", json: { skills: [] } }]);
render(<SkillsTab onCreateSkill={vi.fn()} />);
await screen.findByRole("button", { name: /Add skill/ });
// No permanently-open description box or draft-era UI (§5.2/§9) — adding is menu-only.
expect(screen.queryByLabelText("Describe the skill")).toBeNull();
expect(screen.queryByText("Start a conversation")).toBeNull();
expect(screen.queryByText("Ask OpenWorker to revise")).toBeNull();
expect(screen.queryByText(/Not a chat/)).toBeNull();
// The menu closes after picking a door.
await openWriteForm();
expect(screen.queryByText("Write it myself")).toBeNull();
expect(screen.getByText("Save skill")).toBeTruthy();
});
it("surfaces server-side validation errors", async () => {
stubFetch([
{ match: "/v1/skills", method: "GET", json: { skills: [] } },
{ match: "/v1/skills", method: "POST", json: { ok: false, error: "A skill named 'x' already exists in that scope." } },
]);
render(<SkillsTab />);
await openWriteForm();
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } });
fireEvent.change(screen.getByLabelText("Instructions"), { target: { value: "y" } });
fireEvent.click(screen.getByText("Save skill"));
expect(await screen.findByRole("alert")).toBeTruthy();
expect(screen.getByText(/already exists/)).toBeTruthy();
});
});
describe("SkillsTab — rich-skill disclosure (§6)", () => {
it("shows a file count only when a skill bundles resources", async () => {
stubFetch([
{
match: "/v1/skills",
method: "GET",
json: {
skills: [
{ name: "plain", description: "d", instructions: "i", scope: "global", source: "local", enabled: true, path: "/p", files: 0 },
{ name: "rich", description: "d", instructions: "i", scope: "global", source: "uploaded", enabled: true, path: "/r", files: 3 },
],
},
},
]);
render(<SkillsTab />);
const note = await screen.findByTitle("Show folder");
expect(note.textContent).toContain("3 files");
// The one-file skill carries no count at all — only rich skills are marked.
expect(screen.getAllByTitle("Show folder")).toHaveLength(1);
});
});
+440
View File
@@ -0,0 +1,440 @@
import { useRef, useState } from "react";
import { useEffect } from "react";
import {
createSkill,
deleteSkill,
listSkills,
revealSkill,
stageSkillUpload,
confirmSkillUpload,
updateSkill,
type SkillRow,
type SkillUploadPreview,
} from "../api";
import { Icon } from "./Icon";
// Settings ▸ Skills (SKILLS-SPEC §5/§6) — the management home: the LIST is the page; every
// add-surface appears only when summoned from the single "Add skill" menu (the three doors:
// write form / import / start-a-conversation). Everything a user creates here is GLOBAL —
// "skills are things your worker knows everywhere". Creation-by-AI is a CONVERSATION (the
// menu's third door starts one; the worker proposes via save_skill) — there is no
// in-Settings drafting and no description box: the composer is where you describe it.
// Persona-bundled skills arrive with personas (§10), managed on the persona page, not here.
const CARD = "rounded-xl2 border border-line bg-panel";
const FIELD_LABEL = "text-[12.5px] font-medium text-ink";
const INPUT =
"w-full min-w-0 px-3 py-2 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent";
const BTN_ACCENT =
"text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shrink-0 disabled:opacity-40";
const BTN_BORDERED =
"text-[12.5px] px-3 py-2 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
const BADGE =
"text-[11px] px-2 py-0.5 rounded-full border border-line bg-paper text-muted shrink-0";
type Editor = {
mode: "new" | "edit";
name: string;
description: string;
instructions: string;
};
const emptyEditor = (): Editor => ({
mode: "new",
name: "",
description: "",
instructions: "",
});
async function fileToB64(file: File): Promise<string> {
// FileReader fallback: File.arrayBuffer is missing in some webviews (and jsdom).
const buf =
typeof file.arrayBuffer === "function"
? await file.arrayBuffer()
: await new Promise<ArrayBuffer>((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result as ArrayBuffer);
r.onerror = () => reject(r.error);
r.readAsArrayBuffer(file);
});
const bytes = new Uint8Array(buf);
let bin = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(bin);
}
export function SkillsTab({
onCreateSkill,
}: {
// The doorway (SKILLS-SPEC §5.2): starts a new conversation with the description
// prefilled in the composer — the worker builds the skill and proposes it via save_skill.
onCreateSkill?: (description: string) => void;
}) {
const [rows, setRows] = useState<SkillRow[]>([]);
const [editor, setEditor] = useState<Editor | null>(null);
const [upload, setUpload] = useState<SkillUploadPreview | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [armedDelete, setArmedDelete] = useState<string | null>(null);
const [error, setError] = useState("");
// The state-change callout (SKILLS-SPEC §4.1 #2): name-first so the user knows WHICH
// skill, and visually distinct so it can't be skimmed past (tester ask 2026-07-27).
const [notice, setNotice] = useState<{ name: string; text: string; tone: "ok" | "warn" } | null>(
null,
);
const fileInput = useRef<HTMLInputElement>(null);
// Confirmation copy (SKILLS-SPEC §4.1 #2): name-first, outcome + remedy only, in words a
// person already owns — now / everywhere / off / start a new one. Never mechanism ("the
// model will be told…") or engineering timing ("from the next message") — owner-driver
// review rounds, 2026-07-27. The engine countermands disabled-but-loaded skills silently;
// the copy promises only the guaranteed part.
const CONFIRMATION = "— the worker can now use it in every conversation.";
const OFF_NOTE =
"turned off everywhere. If a conversation already used it, start a new one for a completely clean slate.";
const DELETE_NOTE =
"removed. If a conversation already used it, start a new one for a completely clean slate.";
const refresh = () => listSkills().then(setRows);
useEffect(() => {
refresh();
}, []);
const fail = (res: { ok?: boolean; error?: string }) => {
setNotice(null);
if (res.ok === false) {
setError(res.error || "Something went wrong.");
return true;
}
setError("");
return false;
};
const save = async () => {
if (!editor) return;
const res =
editor.mode === "new"
? await createSkill({
name: editor.name.trim(),
description: editor.description.trim(),
instructions: editor.instructions,
})
: await updateSkill(editor.name, {
description: editor.description.trim(),
instructions: editor.instructions,
});
if (fail(res)) return;
setEditor(null);
if (editor.mode === "new")
setNotice({ name: editor.name.trim(), text: CONFIRMATION, tone: "ok" });
refresh();
};
const onPickFile = async (file: File | undefined) => {
if (!file) return;
const res = await stageSkillUpload(await fileToB64(file), file.name);
if (fail(res)) return;
setUpload(res);
};
const confirmUpload = async () => {
if (!upload?.token) return;
const res = await confirmSkillUpload(upload.token);
if (fail(res)) return;
setUpload(null);
setNotice({ name: upload.name || "Skill", text: CONFIRMATION, tone: "ok" });
refresh();
};
const remove = async (row: SkillRow) => {
if (armedDelete !== row.name) {
setArmedDelete(row.name);
return;
}
setArmedDelete(null);
const res = await deleteSkill(row.name);
if (fail(res)) return;
setNotice({ name: row.name, text: DELETE_NOTE, tone: "warn" });
refresh();
};
return (
<section>
<div className="flex items-start justify-between gap-3 mb-4">
<div>
<h2 className="text-[16px] font-semibold">Skills</h2>
<p className="text-[12.5px] text-muted mt-1 leading-relaxed">
Reusable instructions the worker can follow in every conversation. Off here means
off everywhere.
</p>
</div>
{/* One add-action, three doors behind it (SKILLS-SPEC §5): the list is the page. */}
<div className="relative shrink-0">
<button
className={BTN_ACCENT}
aria-haspopup="menu"
aria-expanded={addOpen}
onClick={() => setAddOpen((v) => !v)}
>
<span className="inline-flex items-center gap-1.5">
<Icon name="plus" size={13} /> Add skill
</span>
</button>
{addOpen ? (
<>
<div className="fixed inset-0 z-10" onClick={() => setAddOpen(false)} />
<div
role="menu"
className="absolute right-0 top-full mt-1.5 w-80 rounded-xl2 border border-line bg-panel shadow-xl z-20 p-1.5"
onKeyDown={(e) => e.key === "Escape" && setAddOpen(false)}
>
<button
role="menuitem"
className="w-full text-left px-3 py-2 rounded-lg hover:bg-paper"
onClick={() => {
setAddOpen(false);
setEditor(emptyEditor());
}}
>
<div className="text-[13px] font-medium">Write it myself</div>
<div className="text-[11.5px] text-muted">
A name, a description, and the instructions
</div>
</button>
<button
role="menuitem"
className="w-full text-left px-3 py-2 rounded-lg hover:bg-paper"
onClick={() => {
setAddOpen(false);
fileInput.current?.click();
}}
>
<div className="text-[13px] font-medium">Import a file</div>
<div className="text-[11.5px] text-muted">
A .zip or SKILL.md someone shared you review before it installs
</div>
</button>
<button
role="menuitem"
className="w-full text-left px-3 py-2 rounded-lg hover:bg-paper disabled:opacity-40"
disabled={!onCreateSkill}
onClick={() => {
setAddOpen(false);
onCreateSkill?.("");
}}
>
<div className="text-[13px] font-medium">Create with OpenWorker</div>
<div className="text-[11.5px] text-muted">
Starts a conversation the worker builds it and asks before adding it to
your skills
</div>
</button>
</div>
</>
) : null}
</div>
</div>
<input
ref={fileInput}
type="file"
accept=".zip,.md"
className="hidden"
aria-label="Upload a skill archive"
onChange={(e) => {
onPickFile(e.target.files?.[0]);
e.target.value = "";
}}
/>
{error ? (
<div className="text-[12.5px] text-red-500 mb-3" role="alert">
{error}
</div>
) : null}
{notice ? (
<div
role="status"
className={
"mb-3 flex items-start gap-2 rounded-lg border px-3 py-2 text-[12.5px] " +
(notice.tone === "ok"
? "bg-tealSoft/70 text-tealInk border-tealInk/20"
: "bg-warnSoft/70 text-warnInk border-warnInk/20")
}
>
<span className="min-w-0">
<b>{notice.name}</b> {notice.text}
</span>
<button
className="ml-auto shrink-0 opacity-60 hover:opacity-100"
aria-label="Dismiss"
onClick={() => setNotice(null)}
>
</button>
</div>
) : null}
{upload ? (
<div className={`${CARD} p-4 mb-4`}>
<div className="text-[13px] font-medium mb-1">Review before installing</div>
<p className="text-[12.5px] text-muted mb-3">
Read the instructions installing a skill means the worker will follow them.
</p>
<div className="text-[13px] mb-1">
<span className="font-medium">{upload.name}</span>
<span className="text-muted"> {upload.description || "no description"}</span>
</div>
<pre className="text-[12px] bg-paper border border-line rounded-lg p-3 whitespace-pre-wrap max-h-64 overflow-y-auto mb-2">
{upload.instructions}
</pre>
{upload.files?.length ? (
<div className="text-[12px] text-muted mb-2">
Bundled files: {upload.files.join(", ")}
</div>
) : null}
<div className="flex gap-2 mt-3">
<button className={BTN_ACCENT} onClick={confirmUpload}>
Install skill
</button>
<button className={BTN_BORDERED} onClick={() => setUpload(null)}>
Cancel
</button>
</div>
</div>
) : null}
{editor ? (
<div className={`${CARD} p-4 mb-4`}>
<div className="text-[13px] font-medium mb-3">
{editor.mode === "new" ? "New skill" : `Edit ${editor.name}`}
</div>
<label className={FIELD_LABEL} htmlFor="skill-name">
Name
</label>
<input
id="skill-name"
className={`${INPUT} mt-1 mb-3`}
value={editor.name}
disabled={editor.mode === "edit"}
placeholder="weekly-report"
onChange={(e) => setEditor({ ...editor, name: e.target.value })}
/>
<label className={FIELD_LABEL} htmlFor="skill-desc">
Description
</label>
<input
id="skill-desc"
className={`${INPUT} mt-1 mb-3`}
value={editor.description}
placeholder="One line the worker uses to decide when this applies"
onChange={(e) => setEditor({ ...editor, description: e.target.value })}
/>
<label className={FIELD_LABEL} htmlFor="skill-instructions">
Instructions
</label>
<textarea
id="skill-instructions"
className={`${INPUT} mt-1 mb-3 min-h-[140px] font-mono`}
value={editor.instructions}
placeholder={"1. Gather last week's updates\n2. Write the report, under 300 words"}
onChange={(e) => setEditor({ ...editor, instructions: e.target.value })}
/>
<div className="flex gap-2 mt-3">
<button
className={BTN_ACCENT}
disabled={!editor.name.trim() || !editor.instructions.trim()}
onClick={save}
>
Save skill
</button>
<button className={BTN_BORDERED} onClick={() => setEditor(null)}>
Cancel
</button>
</div>
</div>
) : null}
<div className={`${CARD} divide-y divide-line`}>
{rows.length === 0 && !editor ? (
<div className="p-5 text-[13px] text-muted">
No skills yet <b>Add skill</b> teaches your worker its first one, like
prepare my Monday status report.
</div>
) : null}
{rows.map((row) => (
<div key={row.name} className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-[13px] font-medium ${row.enabled ? "" : "text-muted"}`}>
{row.name}
</span>
{row.source !== "local" ? <span className={BADGE}>{row.source}</span> : null}
{/* §6: a rich skill must not look identical to a one-file one. Styled as a
chip with a folder icon so it READS as clickable (live drive: plain
text hid the affordance). */}
{row.files ? (
<button
className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded-md border border-line bg-paper text-muted hover:text-ink hover:border-lineStrong shrink-0"
title="Show folder"
onClick={() => revealSkill(row.name)}
>
<Icon name="folder" size={11} /> {row.files} file{row.files === 1 ? "" : "s"}
</button>
) : null}
</div>
{/* Full description, wrapping a skill's one-liner is its menu entry; cutting
it mid-word hid what the skill does (live drive). */}
<div className="text-[12px] text-muted leading-relaxed">{row.description}</div>
</div>
<button
className={BTN_BORDERED}
title="Edit"
onClick={() =>
setEditor({
mode: "edit",
name: row.name,
description: row.description,
instructions: row.instructions,
})
}
>
<Icon name="pencil" size={13} />
</button>
<button
className={BTN_BORDERED}
aria-label={`Delete ${row.name}`}
onClick={() => remove(row)}
onBlur={() => setArmedDelete(null)}
>
{armedDelete === row.name ? "Confirm delete" : <Icon name="trash" size={13} />}
</button>
<label className="inline-flex items-center gap-1.5 text-[12px] text-muted">
<input
type="checkbox"
role="switch"
aria-label={`${row.name} enabled`}
checked={row.enabled}
onChange={(e) => {
const on = e.target.checked;
updateSkill(row.name, { enabled: on }).then((res) => {
if (!fail(res))
setNotice({
name: row.name,
text: on ? CONFIRMATION : OFF_NOTE,
tone: on ? "ok" : "warn",
});
refresh();
});
}}
/>
On
</label>
</div>
))}
</div>
</section>
);
}
+32 -2
View File
@@ -6,6 +6,28 @@ import { Markdown } from "./Markdown";
import { ConnectorMessageCard } from "./ConnectorMessageCard";
import { Icon } from "./Icon";
// Long user pastes swallow the transcript (owner ask 2026-07-30): clamp past a generous
// threshold with a more…/less… toggle. Normal typed messages never see the control; the
// full text still drives copy (BubbleMeta) and is what the model received.
const USER_CLAMP_CHARS = 1200;
function ClampedUserText({ text }: { text: string }) {
const [open, setOpen] = useState(false);
if (text.length <= USER_CLAMP_CHARS) return <>{text}</>;
return (
<>
{open ? text : text.slice(0, USER_CLAMP_CHARS).trimEnd() + "…"}
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="block ml-auto mt-1.5 text-[12.5px] font-medium opacity-75 hover:opacity-100"
>
{open ? "less…" : "more…"}
</button>
</>
);
}
// Hover affordances for a message bubble (FB-005): copy the raw text + the message's time.
// Lives in a ZERO-HEIGHT strip under the bubble (absolute, inside the transcript's 20px gap)
// so revealing it on group-hover never shifts the layout. `ts` is unix seconds — canonical
@@ -165,7 +187,15 @@ function StepRow({ tool, approval }: { tool: ToolItem; approval?: ApprovalItem }
<span className={"w-3.5 text-center text-[10px] shrink-0 " + (failed ? "text-danger" : running ? "text-accent" : "text-ok")}>
{running ? <span className="spinner" data-testid="step-running" /> : "●"}
</span>
<LineText line={humanizeTool(tool.name, tool.args)} />
<LineText
line={
// A refused load must not read as a success — "Used skill:" is the trust line
// (SKILLS-SPEC §4.1 #4), so a blocked attempt gets honest wording instead.
tool.name === "load_skill" && tool.preview?.includes('"error"')
? { pre: "Tried skill: ", obj: String(tool.args?.name ?? ""), post: " — not available" }
: humanizeTool(tool.name, tool.args)
}
/>
{approval && approvalChip(approval.resolved)}
{!!tool.standingRule && (
<span
@@ -398,7 +428,7 @@ export function Transcript({ items, running, streamingText, onRetry }: Props) {
)}
</div>
)}
{item.text}
<ClampedUserText text={item.text} />
</div>
<BubbleMeta text={item.text} ts={item.ts} align="right" />
</div>
+17
View File
@@ -0,0 +1,17 @@
// SKILLS-SPEC §4.6 GUI — the transcript trust line: a load_skill tool call always renders
// as a human-readable "Used skill: X" step, whether model-invoked or forced via /skill.
import { describe, expect, it } from "vitest";
import { humanizeTool } from "./humanize";
describe("humanizeTool(load_skill)", () => {
it("renders the Used-skill line with the skill name", () => {
const line = humanizeTool("load_skill", { name: "incident-summary" });
expect(line.pre).toBe("Used skill: ");
expect(line.obj).toBe("incident-summary");
});
it("stays safe on null/missing args", () => {
expect(humanizeTool("load_skill", null).obj).toBe("");
expect(humanizeTool("load_skill", {}).obj).toBe("");
});
});
+9
View File
@@ -88,6 +88,10 @@ export function humanizeTool(name: string, args: any): HumanLine {
}
case "explore":
return { pre: "Sent a sub-agent to explore — ", obj: `${trunc(String(a.task ?? a.prompt ?? ""), 60)}` };
case "load_skill":
// SKILLS-SPEC §4.1 #4 — the trust line: the transcript always shows the moment a
// skill's instructions were picked up, model-invoked or forced via /skill.
return { pre: "Used skill: ", obj: String(a.name ?? "") };
case "ask_user":
return { pre: "Asked you a question" };
case "propose_plan":
@@ -131,6 +135,11 @@ export function humanizeApprovalTitle(name: string, args: any): HumanLine {
return a.title
? { pre: "Create the automation ", obj: `${trunc(String(a.title), 60)}` }
: { pre: "Create an automation" };
case "save_skill":
// SKILLS-SPEC §5.2/§7: "Add", never "install"; destination is "your skills".
return a.name
? { pre: "Add skill ", obj: String(a.name), post: " to your skills" }
: { pre: "Add a skill to your skills" };
default:
return { pre: `Use ${name}` };
}
@@ -83,6 +83,20 @@ describe("itemsFromMessages model switch", () => {
});
});
describe("itemsFromMessages compaction", () => {
it("replays the persisted compacted marker as an info notice (the divider)", () => {
const items = itemsFromMessages([
{ role: "user", content: "hi" },
{ role: "notice", kind: "compacted", text: "Context compacted — earlier turns were summarized" },
] as any);
expect(items[1]).toEqual({
kind: "notice",
tone: "info",
text: "Context compacted — earlier turns were summarized",
});
});
});
describe("itemsFromMessages reasoning", () => {
it("attaches the reasoning sidecar to assistant items; thinking-only messages still render", () => {
const items = itemsFromMessages([
+7 -1
View File
@@ -33,6 +33,9 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
continue;
}
const user = userItemFromContent(m.content);
// Force-run (`/skill …`): `_display` holds the user's literal line; `content` carries
// the model-facing framing. Render what the user typed — one truthful bubble.
if (typeof m._display === "string" && m._display) user.text = m._display;
// `ts` (unix seconds) is the server's canonical-message stamp; older sessions have none.
if (typeof m.ts === "number") user.ts = m.ts;
if (user.text || user.attachments?.length) items.push(user);
@@ -72,7 +75,10 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
? { kind: "notice", tone: "warn", text: "Interrupted." }
: m.kind === "model_switch"
? { kind: "notice", tone: "info", text: m.text || "Model switched" }
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
: m.kind === "compacted"
? // The subtle "compacted here" divider (OPE-27) — the transcript itself is intact.
{ kind: "notice", tone: "info", text: m.text || "Context compacted" }
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
);
}
// system messages are omitted; tool-result messages are folded into the tool row above
+1 -1
View File
@@ -499,7 +499,7 @@ export function ProviderForm({
)}
{info && !info.needs_key && (
<p className="text-[11.5px] text-faint mt-2">
No API key needed Ollama runs models on this Mac.{" "}
No API key needed Ollama runs models on this computer.{" "}
<button
className="text-muted underline decoration-line underline-offset-2 hover:text-ink"
onClick={() => openExternal("https://ollama.com/download")}
+14
View File
@@ -397,6 +397,9 @@ body {
margin-top: 11px; font-family: var(--mono); font-size: 12px; color: var(--muted);
background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 8px 11px;
overflow-wrap: anywhere; white-space: pre-wrap; line-height: 1.55;
/* Long previews (a 170-line skill) must scroll INSIDE the card expanded content
otherwise outgrows the viewport with no way to read or reach "show less". */
max-height: 42vh; overflow-y: auto;
}
.approval-prev-more {
display: block; margin-top: 4px; font: inherit; font-family: -apple-system, sans-serif;
@@ -1603,3 +1606,14 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
fussy on a pill); small + high keeps the wordmark the star. */
vertical-align: 3px;
}
/* Folder artifact listing (a linked package dir renders as rows, not "not found"). */
.artifact-folderlist { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; }
.artifact-folder-row {
display: flex; align-items: center; gap: 9px; width: 100%; text-align: left;
padding: 7px 10px; border: 0; border-radius: 8px; background: none; cursor: pointer;
color: var(--ink); font-size: 12.5px;
}
.artifact-folder-row:hover { background: var(--paper); }
.artifact-folder-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.artifact-folder-size { font-size: 11px; color: var(--faint); }
+2
View File
@@ -18,6 +18,8 @@ export type EventType =
| "input_rejected"
| "interrupted"
| "model_changed"
| "compacting"
| "compacted"
| "turn_done";
export interface WsEvent {