mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 06:30:25 +00:00
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:
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user