mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-10 22:20:27 +00:00
Merge branch 'main' into feature/memory
This commit is contained in:
+59
-11
@@ -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
@@ -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, 1–50).
|
||||
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, 1–100
|
||||
pdf_max_mb?: number; // default 10, 1–10
|
||||
// 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.10–0.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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,43 @@ const EXTERNAL = new Set(["send_message", "send_file"]);
|
||||
|
||||
type ApprovalItem = Extract<Item, { kind: "approval" }>;
|
||||
|
||||
// Per-tool button copy (§7): a skill proposal is an "add", not an "allow". Shared with the
|
||||
// parked Inbox card so both dialects match.
|
||||
export function approvalActionLabels(name?: string): { allow: string; deny: string } {
|
||||
return name === "save_skill"
|
||||
? { allow: "Add to my skills", deny: "Not now" }
|
||||
: { allow: "Allow once", deny: "Deny" };
|
||||
}
|
||||
|
||||
// save_skill's review surface (SKILLS-SPEC §5.2): description, the full instructions
|
||||
// (clamped, expandable, scrollable), every bundled file, and the guaranteed footer that
|
||||
// answers "added WHERE, available WHEN". Shared verbatim with the parked Inbox card —
|
||||
// one decision, one dialect.
|
||||
export function SaveSkillPreview({ args }: { args: any }) {
|
||||
return (
|
||||
<>
|
||||
{args?.description && <div className="approval-with">{String(args.description)}</div>}
|
||||
{args?.instructions && <PreviewBlock text={String(args.instructions)} mono={false} />}
|
||||
{Array.isArray(args?.files) && args.files.length > 0 && (
|
||||
<div data-testid="skill-bundle-files">
|
||||
{args.files.map((f: unknown, i: number) => (
|
||||
<span className="approval-filechip" key={i}>
|
||||
<span className="ico">
|
||||
<Icon name="file" size={13} />
|
||||
</span>
|
||||
{String(f).split(/[\\/]/).pop() || String(f)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="approval-with">
|
||||
Approving adds it to your skills on this computer — usable in every conversation from
|
||||
then on.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// A `permissions` proposal on the create_scheduled_task consent card (§25): reads are
|
||||
// disclosure lines, writes are the standing grants the approval mints.
|
||||
interface PermissionLine {
|
||||
@@ -65,14 +102,17 @@ export function scopeNote(
|
||||
args: any,
|
||||
category?: string,
|
||||
): { text: string; external: boolean } {
|
||||
// save_skill's corner answers WHERE (SKILLS-SPEC §5.2): the exact place to find, edit,
|
||||
// or turn off the skill afterwards.
|
||||
if (name === "save_skill") return { text: "saves to Settings ▸ Skills", external: false };
|
||||
if (category === "connector") return { text: "acts on a connected service", external: true };
|
||||
if (EXTERNAL.has(name)) {
|
||||
const platform = String(args?.target ?? "").split(":")[0];
|
||||
const names: Record<string, string> = { slack: "Slack", telegram: "Telegram" };
|
||||
return { text: `leaves this Mac → ${names[platform] || platform || "a connected chat"}`, external: true };
|
||||
return { text: `leaves this computer → ${names[platform] || platform || "a connected chat"}`, external: true };
|
||||
}
|
||||
const overwrite = name === "write_file" && args?.overwrite;
|
||||
return { text: "stays on this Mac" + (overwrite ? " · overwrites the existing file" : ""), external: false };
|
||||
return { text: "stays on this computer" + (overwrite ? " · overwrites the existing file" : ""), external: false };
|
||||
}
|
||||
|
||||
// The proposed content/command, straight from the tool call's ARGS — the file/action
|
||||
@@ -125,11 +165,13 @@ function Buttons({
|
||||
onApprove,
|
||||
runTask,
|
||||
primaryLabel,
|
||||
denyLabel = "Deny",
|
||||
}: {
|
||||
item: ApprovalItem;
|
||||
onApprove: (decision: ApprovalDecision) => void;
|
||||
runTask?: { id: string; title: string } | null;
|
||||
primaryLabel: string;
|
||||
denyLabel?: string;
|
||||
}) {
|
||||
const connector = item.category === "connector";
|
||||
const offerStanding = !!(runTask && item.standingTarget);
|
||||
@@ -152,7 +194,9 @@ function Buttons({
|
||||
exactly the scope distinction §25 exists to draw. Same rule for run_shell:
|
||||
the command-scoped button below is the specific (safer) grant, so the
|
||||
tool-wide one stays out of the card. */}
|
||||
{!connector && !offerStanding && item.name !== "run_shell" && (
|
||||
{/* save_skill: no session-wide "always" — every skill proposal gets its own review
|
||||
(SKILLS-SPEC §5: one gate, always). */}
|
||||
{!connector && !offerStanding && item.name !== "run_shell" && item.name !== "save_skill" && (
|
||||
<button
|
||||
className="btn"
|
||||
title={`Always allow ${TOOL_VERBS[item.name]?.toLowerCase() || item.name} for this session`}
|
||||
@@ -168,7 +212,7 @@ function Buttons({
|
||||
)}
|
||||
<span className="spacer" />
|
||||
<button className="btn quiet-deny" onClick={() => onApprove("deny")}>
|
||||
Deny
|
||||
{denyLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -252,6 +296,8 @@ export function ApprovalCard({
|
||||
{item.name === "send_message" && item.args?.text && (
|
||||
<MessagePreview text={String(item.args.text)} />
|
||||
)}
|
||||
{/* save_skill (SKILLS-SPEC §5.2): the arguments ARE the review surface. */}
|
||||
{item.name === "save_skill" && <SaveSkillPreview args={item.args} />}
|
||||
|
||||
{grants.length > 0 && (
|
||||
<div className="approval-grants" data-testid="approval-grants">
|
||||
@@ -272,7 +318,7 @@ export function ApprovalCard({
|
||||
)}
|
||||
{/* Long-tail tools: no bespoke preview — fall back to the compact args line. */}
|
||||
{!FILE_WRITES.has(item.name) &&
|
||||
!["run_shell", "send_message", "send_file"].includes(item.name) &&
|
||||
!["run_shell", "send_message", "send_file", "save_skill"].includes(item.name) &&
|
||||
!grants.length &&
|
||||
shortArgs(item.args) && <div className="approval-rest">{shortArgs(item.args)}</div>}
|
||||
{reason && <div className="approval-reason">{reason}</div>}
|
||||
@@ -280,7 +326,13 @@ export function ApprovalCard({
|
||||
{item.resolved ? (
|
||||
<div className="resolved">Approved: {item.resolved.replace("_", " ")}</div>
|
||||
) : (
|
||||
<Buttons item={item} onApprove={onApprove} runTask={runTask} primaryLabel="Allow once" />
|
||||
<Buttons
|
||||
item={item}
|
||||
onApprove={onApprove}
|
||||
runTask={runTask}
|
||||
primaryLabel={approvalActionLabels(item.name).allow}
|
||||
denyLabel={approvalActionLabels(item.name).deny}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -433,7 +433,7 @@ export function AutomationQuickstart({
|
||||
<span className="block text-[13px] text-ink font-medium">
|
||||
One sign-in unlocks every one-click connection
|
||||
</span>
|
||||
Connections are brokered by OpenWorker Cloud — your tokens stay on this Mac.
|
||||
Connections are brokered by OpenWorker Cloud — your tokens stay on this computer.
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
{signinPhase ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// SKILLS-SPEC §4.6 GUI — the composer's "/" force-run popup: opens only for a leading
|
||||
// slash, lists only the session's effective (enabled) menu, filters while typing, and the
|
||||
// picked skill rides onSend as its own field — never as message text.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { Composer } from "./Composer";
|
||||
|
||||
const MENU = {
|
||||
skills: [
|
||||
{ name: "weekly-report", description: "Monday status report", scope: "global", enabled: true },
|
||||
{ name: "greet", description: "says hello", scope: "project", enabled: true },
|
||||
{ name: "muted-one", description: "muted here", scope: "global", enabled: false },
|
||||
],
|
||||
};
|
||||
|
||||
function stubFetch() {
|
||||
const calls: { url: string; method: string }[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, method: (init?.method || "GET").toUpperCase() });
|
||||
if (url.includes("/skills")) return { ok: true, json: async () => MENU } as Response;
|
||||
return { ok: true, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
return calls;
|
||||
}
|
||||
|
||||
const props = (extra: Partial<Parameters<typeof Composer>[0]> = {}) => ({
|
||||
mode: "interactive",
|
||||
model: "gpt-5.6-sol",
|
||||
running: false,
|
||||
connected: true,
|
||||
sessionId: "s1",
|
||||
onSend: vi.fn(),
|
||||
onInterrupt: vi.fn(),
|
||||
onModeChange: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
const box = () => screen.getByPlaceholderText(/Ask the coworker/);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Composer / skills popup", () => {
|
||||
it("opens on a leading '/' and lists only enabled skills from the effective menu", async () => {
|
||||
stubFetch();
|
||||
render(<Composer {...props()} />);
|
||||
fireEvent.change(box(), { target: { value: "/" } });
|
||||
await screen.findByTestId("skill-popup");
|
||||
expect(await screen.findByText("/weekly-report")).toBeTruthy();
|
||||
expect(screen.getByText("/greet")).toBeTruthy();
|
||||
expect(screen.queryByText("/muted-one")).toBeNull(); // muted → not offered
|
||||
expect(screen.getByText("project")).toBeTruthy(); // scope badge
|
||||
});
|
||||
|
||||
it("filters as you type", async () => {
|
||||
stubFetch();
|
||||
render(<Composer {...props()} />);
|
||||
fireEvent.change(box(), { target: { value: "/" } });
|
||||
await screen.findByText("/weekly-report");
|
||||
fireEvent.change(box(), { target: { value: "/wee" } });
|
||||
expect(screen.getByText("/weekly-report")).toBeTruthy();
|
||||
expect(screen.queryByText("/greet")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT open for a mid-text slash", async () => {
|
||||
stubFetch();
|
||||
render(<Composer {...props()} />);
|
||||
fireEvent.change(box(), { target: { value: "rate 5/10 please" } });
|
||||
expect(screen.queryByTestId("skill-popup")).toBeNull();
|
||||
});
|
||||
|
||||
it("selecting inserts /name inline; the send strips the prefix and carries the skill field", async () => {
|
||||
stubFetch();
|
||||
const p = props();
|
||||
render(<Composer {...p} />);
|
||||
fireEvent.change(box(), { target: { value: "/gr" } });
|
||||
fireEvent.click(await screen.findByRole("option", { name: /greet/ }));
|
||||
expect((box() as HTMLTextAreaElement).value).toBe("/greet "); // inline, no chip
|
||||
fireEvent.change(box(), { target: { value: "/greet say hi to the team" } });
|
||||
fireEvent.keyDown(box(), { key: "Enter" });
|
||||
await waitFor(() => expect(p.onSend).toHaveBeenCalled());
|
||||
expect(p.onSend).toHaveBeenCalledWith("say hi to the team", [], "greet");
|
||||
});
|
||||
|
||||
it("a skill-only send works and Enter inside the popup never sends the query text", async () => {
|
||||
stubFetch();
|
||||
const p = props();
|
||||
render(<Composer {...p} />);
|
||||
fireEvent.change(box(), { target: { value: "/wee" } });
|
||||
await screen.findByText("/weekly-report");
|
||||
fireEvent.keyDown(box(), { key: "Enter" }); // selects, does not send
|
||||
expect(p.onSend).not.toHaveBeenCalled();
|
||||
expect((box() as HTMLTextAreaElement).value).toBe("/weekly-report ");
|
||||
fireEvent.keyDown(box(), { key: "Enter" }); // now sends, skill-only
|
||||
await waitFor(() => expect(p.onSend).toHaveBeenCalledWith("", [], "weekly-report"));
|
||||
});
|
||||
|
||||
it("editing the /name prefix away un-picks the skill", async () => {
|
||||
stubFetch();
|
||||
const p = props();
|
||||
render(<Composer {...p} />);
|
||||
fireEvent.change(box(), { target: { value: "/gr" } });
|
||||
fireEvent.click(await screen.findByRole("option", { name: /greet/ }));
|
||||
fireEvent.change(box(), { target: { value: "hello plain" } }); // prefix gone
|
||||
fireEvent.keyDown(box(), { key: "Enter" });
|
||||
await waitFor(() => expect(p.onSend).toHaveBeenCalledWith("hello plain", [], undefined));
|
||||
});
|
||||
|
||||
it("Escape closes the popup and no popup ever opens without a sessionId", async () => {
|
||||
stubFetch();
|
||||
render(<Composer {...props()} />);
|
||||
fireEvent.change(box(), { target: { value: "/gr" } });
|
||||
await screen.findByTestId("skill-popup");
|
||||
fireEvent.keyDown(box(), { key: "Escape" });
|
||||
expect(screen.queryByTestId("skill-popup")).toBeNull();
|
||||
cleanup();
|
||||
stubFetch();
|
||||
render(<Composer {...props({ sessionId: undefined })} />);
|
||||
fireEvent.change(box(), { target: { value: "/" } });
|
||||
expect(screen.queryByTestId("skill-popup")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer — the doorway prefill (SKILLS-SPEC §5.2)", () => {
|
||||
it("a prefill arriving together with a session switch survives the draft clear", async () => {
|
||||
stubFetch();
|
||||
const { rerender } = render(<Composer {...props({ resetKey: "s1" })} />);
|
||||
// The doorway does both in one render: new session (resetKey) + prefill. The clear
|
||||
// effect must run BEFORE the prefill effect or the prefill is wiped (regression).
|
||||
rerender(
|
||||
<Composer
|
||||
{...props({
|
||||
resetKey: "s2",
|
||||
prefill: { text: "Build a new skill for me: release procedure", nonce: 1 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect((box() as HTMLTextAreaElement).value).toBe(
|
||||
"Build a new skill for me: release procedure",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import type { Attachment, SessionUsage } from "../types";
|
||||
import { isPdfFile, readFile } from "../attach";
|
||||
import { getSettings, inspectPdf } from "../api";
|
||||
import { getSettings, inspectPdf, sessionSkills, type SessionSkillRow } from "../api";
|
||||
import { formatTokens, totalTokens } from "../usage";
|
||||
import { Dropdown, type Option } from "./Dropdown";
|
||||
import { Icon } from "./Icon";
|
||||
@@ -59,7 +59,10 @@ interface Props {
|
||||
modelReady?: boolean;
|
||||
onConnectModel?: () => void;
|
||||
onConfigureVoiceInput?: () => void;
|
||||
onSend: (text: string, attachments?: Attachment[]) => void;
|
||||
onSend: (text: string, attachments?: Attachment[], skill?: string) => void;
|
||||
// Feeds the "/" force-run popup (SKILLS-SPEC §4.1 #3): the popup lists this session's
|
||||
// effective skill menu. Absent (e.g. tests without sessions) → the popup never opens.
|
||||
sessionId?: string;
|
||||
onInterrupt: () => void;
|
||||
onModeChange: (mode: string) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -9,6 +9,7 @@ export type IconName =
|
||||
| "signOut"
|
||||
| "chat"
|
||||
| "diamond"
|
||||
| "book"
|
||||
| "search"
|
||||
| "folder"
|
||||
| "folderPlus"
|
||||
@@ -66,6 +67,18 @@ export function Icon({
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case "book":
|
||||
// A playbook — Skills are the worker's recipe book (Settings ▸ Skills).
|
||||
// Hardcover with a full spine + two text lines: "written instructions inside".
|
||||
// (Owner-picked from a 15px-preview comparison, 2026-07-27.)
|
||||
return (
|
||||
<svg {...s}>
|
||||
<path d="M6 2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z" />
|
||||
<path d="M8.5 2v20" />
|
||||
<path d="M11.5 8.5h5" />
|
||||
<path d="M11.5 12h3.5" />
|
||||
</svg>
|
||||
);
|
||||
case "sparkle":
|
||||
// Filled 4-point twinkle — crisp at small sizes.
|
||||
return (
|
||||
|
||||
@@ -1,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
|
||||
|
||||
@@ -122,7 +122,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
|
||||
<h1 className="text-[19px] font-semibold">Welcome to OpenWorker<span className="beta-tag">BETA</span></h1>
|
||||
<p className="text-[13px] text-muted mt-0.5 mb-4">
|
||||
Pick a model provider to get started — OpenWorker runs on your own key, and your
|
||||
key and your data stay on this Mac.
|
||||
key and your data stay on this computer.
|
||||
</p>
|
||||
|
||||
{!ps.sel ? (
|
||||
@@ -239,7 +239,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
|
||||
Sign in for one-click connections
|
||||
</span>
|
||||
OpenWorker handles the OAuth for 20+ tools — no dev consoles, no pasted keys.
|
||||
Tokens stay on this Mac.
|
||||
Tokens stay on this computer.
|
||||
</span>
|
||||
{signinPhase ? (
|
||||
<span className="inline-flex items-center gap-2 text-[12.5px] text-muted shrink-0">
|
||||
@@ -310,7 +310,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
|
||||
</div>
|
||||
<p className="text-[11px] text-faint mt-3">
|
||||
30+ more tools on the Connectors page — add or remove anytime. Tokens stay on
|
||||
this Mac.
|
||||
this computer.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -159,6 +159,15 @@ export function RightRail({
|
||||
content={content}
|
||||
onReload={reloadSelected}
|
||||
onBack={() => setSelected(null)}
|
||||
onOpenEntry={(path) =>
|
||||
setSelected({
|
||||
path,
|
||||
name: path.split("/").pop() || path,
|
||||
kind: kindFromPath(path),
|
||||
size: 0,
|
||||
modified_at: 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -291,12 +300,15 @@ function ArtifactViewer({
|
||||
content,
|
||||
onReload,
|
||||
onBack,
|
||||
onOpenEntry,
|
||||
}: {
|
||||
sessionId: string;
|
||||
artifact: ArtifactInfo;
|
||||
content: ArtifactContent | null;
|
||||
onReload: () => Promise<void>;
|
||||
onBack: () => void;
|
||||
// Folder listings: open a child entry in the viewer (files and subfolders alike).
|
||||
onOpenEntry?: (path: string) => void;
|
||||
}) {
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const isHtml = content?.kind === "html" && !content.error;
|
||||
@@ -381,6 +393,22 @@ function ArtifactViewer({
|
||||
<CsvTable text={content.content || ""} />
|
||||
) : content.kind === "sheet" ? (
|
||||
<SheetViewer dataUrl={content.data_url || ""} />
|
||||
) : content.kind === "folder" ? (
|
||||
// A linked directory (e.g. a skill package): render the listing, click through.
|
||||
<div className="artifact-folderlist" data-testid="artifact-folder">
|
||||
{(content.entries || []).map((e) => (
|
||||
<button
|
||||
key={e.name}
|
||||
className="artifact-folder-row"
|
||||
onClick={() => onOpenEntry?.(`${artifact.path.replace(/\/+$/, "")}/${e.name}`)}
|
||||
>
|
||||
<Icon name={e.dir ? "folder" : "file"} size={14} />
|
||||
<span className="artifact-folder-name">{e.name}</span>
|
||||
{!e.dir && <span className="artifact-folder-size">{formatBytes(e.size)}</span>}
|
||||
</button>
|
||||
))}
|
||||
{!content.entries?.length && <div className="rail-muted">This folder is empty.</div>}
|
||||
</div>
|
||||
) : content.kind === "office" ? (
|
||||
<div className="artifact-open-prompt">
|
||||
<Icon name="panelOpen" size={28} />
|
||||
|
||||
@@ -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’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’s context window is. Turn it off
|
||||
to show this session’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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// SKILLS-SPEC §4.6 GUI — the transcript trust line: a load_skill tool call always renders
|
||||
// as a human-readable "Used skill: X" step, whether model-invoked or forced via /skill.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { humanizeTool } from "./humanize";
|
||||
|
||||
describe("humanizeTool(load_skill)", () => {
|
||||
it("renders the Used-skill line with the skill name", () => {
|
||||
const line = humanizeTool("load_skill", { name: "incident-summary" });
|
||||
expect(line.pre).toBe("Used skill: ");
|
||||
expect(line.obj).toBe("incident-summary");
|
||||
});
|
||||
|
||||
it("stays safe on null/missing args", () => {
|
||||
expect(humanizeTool("load_skill", null).obj).toBe("");
|
||||
expect(humanizeTool("load_skill", {}).obj).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,10 @@ export function humanizeTool(name: string, args: any): HumanLine {
|
||||
}
|
||||
case "explore":
|
||||
return { pre: "Sent a sub-agent to explore — ", obj: `“${trunc(String(a.task ?? a.prompt ?? ""), 60)}”` };
|
||||
case "load_skill":
|
||||
// SKILLS-SPEC §4.1 #4 — the trust line: the transcript always shows the moment a
|
||||
// skill's instructions were picked up, model-invoked or forced via /skill.
|
||||
return { pre: "Used skill: ", obj: String(a.name ?? "") };
|
||||
case "ask_user":
|
||||
return { pre: "Asked you a question" };
|
||||
case "propose_plan":
|
||||
@@ -131,6 +135,11 @@ export function humanizeApprovalTitle(name: string, args: any): HumanLine {
|
||||
return a.title
|
||||
? { pre: "Create the automation ", obj: `“${trunc(String(a.title), 60)}”` }
|
||||
: { pre: "Create an automation" };
|
||||
case "save_skill":
|
||||
// SKILLS-SPEC §5.2/§7: "Add", never "install"; destination is "your skills".
|
||||
return a.name
|
||||
? { pre: "Add skill ", obj: String(a.name), post: " to your skills" }
|
||||
: { pre: "Add a skill to your skills" };
|
||||
default:
|
||||
return { pre: `Use ${name}` };
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -499,7 +499,7 @@ export function ProviderForm({
|
||||
)}
|
||||
{info && !info.needs_key && (
|
||||
<p className="text-[11.5px] text-faint mt-2">
|
||||
No API key needed — Ollama runs models on this Mac.{" "}
|
||||
No API key needed — Ollama runs models on this computer.{" "}
|
||||
<button
|
||||
className="text-muted underline decoration-line underline-offset-2 hover:text-ink"
|
||||
onClick={() => openExternal("https://ollama.com/download")}
|
||||
|
||||
@@ -397,6 +397,9 @@ body {
|
||||
margin-top: 11px; font-family: var(--mono); font-size: 12px; color: var(--muted);
|
||||
background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 8px 11px;
|
||||
overflow-wrap: anywhere; white-space: pre-wrap; line-height: 1.55;
|
||||
/* Long previews (a 170-line skill) must scroll INSIDE the card — expanded content
|
||||
otherwise outgrows the viewport with no way to read or reach "show less". */
|
||||
max-height: 42vh; overflow-y: auto;
|
||||
}
|
||||
.approval-prev-more {
|
||||
display: block; margin-top: 4px; font: inherit; font-family: -apple-system, sans-serif;
|
||||
@@ -1603,3 +1606,14 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
|
||||
fussy on a pill); small + high keeps the wordmark the star. */
|
||||
vertical-align: 3px;
|
||||
}
|
||||
|
||||
/* Folder artifact listing (a linked package dir renders as rows, not "not found"). */
|
||||
.artifact-folderlist { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; }
|
||||
.artifact-folder-row {
|
||||
display: flex; align-items: center; gap: 9px; width: 100%; text-align: left;
|
||||
padding: 7px 10px; border: 0; border-radius: 8px; background: none; cursor: pointer;
|
||||
color: var(--ink); font-size: 12.5px;
|
||||
}
|
||||
.artifact-folder-row:hover { background: var(--paper); }
|
||||
.artifact-folder-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.artifact-folder-size { font-size: 11px; color: var(--faint); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user