Add support for Skills (#391)

Global & per-workspace skill.
Per-persona skills will be improved later as we re-design that abstraction, as per roadmap.
This commit is contained in:
Devika Verma
2026-08-01 08:50:53 -07:00
committed by GitHub
parent e0cb129d20
commit 70e4610a3e
40 changed files with 3853 additions and 95 deletions
+29 -9
View File
@@ -211,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");
};
@@ -613,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;
@@ -861,10 +866,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
@@ -1349,6 +1357,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 />
@@ -1584,6 +1603,7 @@ export function App() {
onInterrupt={interrupt}
onModeChange={changeMode}
onModelChange={changeModel}
sessionId={sessionId}
workspace={needsWorkspace(agent) ? workspace || "" : undefined}
unattended={unattended}
onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined}
+141 -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[]> {
@@ -1089,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;
@@ -1846,12 +1983,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",
);
});
});
});
+128 -14
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;
@@ -91,6 +94,46 @@ interface Props {
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);
@@ -119,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.
@@ -133,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(() => {
@@ -264,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();
@@ -342,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">
@@ -396,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]"
+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
@@ -1,7 +1,13 @@
import { useState, type ReactNode } from "react";
import type { InboxItem } from "../api";
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.
@@ -88,7 +94,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} />
@@ -102,7 +111,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.
@@ -120,7 +129,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>
) : item.kind === "question" ? (
+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} />
+10 -2
View File
@@ -41,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:
@@ -50,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";
@@ -61,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" },
];
@@ -71,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
@@ -124,6 +130,8 @@ export function SettingsView({
<CompactionCard />
</div>
</section>
) : tab === "skills" ? (
<SkillsTab onCreateSkill={onCreateSkill} />
) : tab === "voice" ? (
<VoiceInputSection />
) : (
@@ -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>
);
}
+9 -1
View File
@@ -187,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
+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}` };
}
+3
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);
+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); }