Merge branch 'main' into feature/memory

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