diff --git a/studio/knip.ts b/studio/knip.ts
index 8c20a3df87..db5052d3f6 100644
--- a/studio/knip.ts
+++ b/studio/knip.ts
@@ -18,9 +18,7 @@ const config: KnipConfig = {
"src/features/**",
// Reached only through the UI kit until later PRs in the stacked series
// land their first app-level consumers; each line leaves with that PR.
- "src/hooks/use-is-truncated.ts",
"src/hooks/use-mobile.ts",
- "src/lib/typography.ts",
],
ignoreDependencies: [
// Tailwind v4 is imported via CSS (@import "tailwindcss"), not JS
@@ -31,17 +29,13 @@ const config: KnipConfig = {
// (these leave the list as their first reached consumers land later in
// the stacked series: lucide-react/cmdk/tooltip with the workspace shell,
// select/switch/tabs with the surface pages)
- "lucide-react",
- "cmdk",
"@radix-ui/react-alert-dialog",
- "@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-label",
"@radix-ui/react-scroll-area",
"@radix-ui/react-select",
"@radix-ui/react-switch",
"@radix-ui/react-tabs",
- "@radix-ui/react-tooltip",
"@radix-ui/react-avatar",
"@radix-ui/react-checkbox",
"@radix-ui/react-popover",
diff --git a/studio/src/app/workspace/layout.tsx b/studio/src/app/workspace/layout.tsx
new file mode 100644
index 0000000000..31a8200472
--- /dev/null
+++ b/studio/src/app/workspace/layout.tsx
@@ -0,0 +1,52 @@
+import { TopNav } from "@/components/shell/top-nav";
+import { RuntimeStatusProvider } from "@/features/agent/runtime-status";
+import { StorageHealthBanner } from "@/features/agent/storage-health-banner";
+import { ShortcutsProvider } from "@/lib/shortcuts/use-shortcuts";
+
+/**
+ * The workspace shell: a fixed dark-green radial gradient carrying the top
+ * navigation bar, with all five surfaces rendered inside one rounded card
+ * that follows the theme. The gradient itself is a fixed brand colour —
+ * identical in light and dark themes — so only the card interior themes.
+ *
+ * `RuntimeStatusProvider` stays outermost: its offline banner renders above
+ * the top nav at full width. Workspace sections manage their own scrolling
+ * and padding inside the card (`h-full overflow-y-auto …`).
+ */
+export default function WorkspaceLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+ {/* The design's green radial gradient; dark mode deepens each stop so
+ the shell recedes behind the dark card instead of outglowing it.
+ Top padding tracks the status-bar safe area: the installed iOS
+ PWA (black-translucent status bar + viewport-fit cover) draws
+ under the clock, so the nav must start below it while the
+ gradient still paints behind it. */}
+
+ {/* Storage health rides the same full-width banner band as the
+ runtime status banners: visible from every surface, because a
+ degraded store shows up as chats missing from the sidebar. */}
+
+
+ {/* relative makes the card the containing block for absolutely-
+ positioned descendants with no positioned ancestor of their own
+ — notably the hidden form-integration checkbox Radix renders
+ beside each Switch inside a
+
+
+ );
+}
diff --git a/studio/src/app/workspace/shortcuts/page.tsx b/studio/src/app/workspace/shortcuts/page.tsx
new file mode 100644
index 0000000000..1e9aade517
--- /dev/null
+++ b/studio/src/app/workspace/shortcuts/page.tsx
@@ -0,0 +1,65 @@
+import type { Metadata } from "next";
+import { keycaps, SHORTCUT_GROUPS, SHORTCUTS } from "@/lib/shortcuts/registry";
+import { pageTitleClass } from "@/lib/typography";
+
+export const metadata: Metadata = { title: "Keyboard shortcuts — Workspace" };
+
+/** A single keycap. */
+function Key({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Reference page listing every keyboard shortcut, rendered directly from the
+ * shortcut registry so it can't drift from the live bindings. Reached from the
+ * profile menu and by pressing `?`.
+ */
+export default function KeyboardShortcutsPage() {
+ return (
+
+
+
+
+ Keyboard shortcuts
+
+
+ Work faster with the keyboard. On Windows and Linux, use Ctrl
+ wherever ⌘ is shown.
+
+
+
+
+ {SHORTCUT_GROUPS.map((group) => (
+
+
+ {group}
+
+
+ {SHORTCUTS.filter((s) => s.group === group).map((s) => (
+
+
+ {s.description}
+
+
+ {keycaps(s.combo).map((k, i) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional keycaps
+ {k}
+ ))}
+
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/studio/src/components/app/nav-items.ts b/studio/src/components/app/nav-items.ts
new file mode 100644
index 0000000000..d0dbfd1ccd
--- /dev/null
+++ b/studio/src/components/app/nav-items.ts
@@ -0,0 +1,76 @@
+/**
+ * The Atrium workspace navigation configuration — the single canonical
+ * nav-items module. The top nav is generic over the `ShellNav` built here:
+ * a single row of workspace destinations — the in-app chat plus the state
+ * that outlives a turn (Skills, Memory, Scheduled). The wordmark and home
+ * both point at Chats.
+ */
+
+import { Clock, GraduationCap, MessageCircle, Settings } from "lucide-react";
+import type { ComponentType } from "react";
+import { ATRIUM_WORKSPACE_HOME } from "@/lib/feature-flags";
+
+interface NavItem {
+ /** Stable key for React keys and test selectors. */
+ readonly key: string;
+ /** Nav label (pill text when active, tooltip when inactive). */
+ readonly label: string;
+ /** Absolute route this destination points at. */
+ readonly href: string;
+ /** Nav icon. */
+ readonly icon: ComponentType<{ className?: string }>;
+}
+
+/**
+ * The console's navigation configuration. One value drives the whole shell:
+ * the top-nav destinations and the wordmark's home link.
+ */
+export interface ShellNav {
+ /** The console's root route (used for the "home" active-state rule). */
+ readonly homeHref: string;
+ /** Where the wordmark links, if different from `homeHref` (e.g. Atrium
+ sends the logo to the workspace chats rather than the gateway home). */
+ readonly logoHref?: string;
+ /** `aria-label` for the `` landmark. */
+ readonly navLabel: string;
+ /** Ordered nav destinations. */
+ readonly items: readonly NavItem[];
+}
+
+/** Builds the Atrium workspace `ShellNav`. */
+export function buildUserNav(): ShellNav {
+ return {
+ // The workspace root (redirects to chat). Kept distinct from the Chats
+ // item's href so Chats highlights on any /workspace/chat/* route rather
+ // than only the exact base path (the "console root" exact-match rule).
+ homeHref: "/workspace",
+ logoHref: ATRIUM_WORKSPACE_HOME,
+ navLabel: "Main navigation",
+ items: [
+ {
+ key: "chat",
+ label: "Chats",
+ href: "/workspace/chat",
+ icon: MessageCircle,
+ },
+ {
+ key: "agent-schedules",
+ label: "Scheduled",
+ href: "/workspace/schedules",
+ icon: Clock,
+ },
+ {
+ key: "agent-skills",
+ label: "Skills",
+ href: "/workspace/skills",
+ icon: GraduationCap,
+ },
+ {
+ key: "settings",
+ label: "Settings",
+ href: "/workspace/settings",
+ icon: Settings,
+ },
+ ],
+ };
+}
diff --git a/studio/src/components/shell/atrium-search-data.ts b/studio/src/components/shell/atrium-search-data.ts
new file mode 100644
index 0000000000..d275378f28
--- /dev/null
+++ b/studio/src/components/shell/atrium-search-data.ts
@@ -0,0 +1,82 @@
+import { Brain, CalendarClock, MessageSquare, Sparkles } from "lucide-react";
+import type {
+ AgentSession,
+ CronJob,
+ MemoryEntry,
+} from "@/features/agent/types";
+import type { HarnessSkillInfo } from "@/lib/harness/client";
+import type { SearchEntry, SearchGroup } from "./search-types";
+
+/**
+ * Global-search index for the workspace surfaces, built from live daemon
+ * data on every change (Studio is daemon-only — there is nothing to index at
+ * module load). Transcripts are not indexed, so chat entries carry no body:
+ * matches land on titles and metadata only.
+ */
+export function buildAtriumSearchEntries(input: {
+ sessions: readonly AgentSession[];
+ jobs: readonly CronJob[];
+ skills: readonly HarnessSkillInfo[];
+ memories: readonly MemoryEntry[];
+}): SearchEntry[] {
+ const entries: SearchEntry[] = [];
+
+ for (const session of input.sessions) {
+ entries.push({
+ id: `chat-${session.id}`,
+ source: "atrium",
+ category: "chat",
+ title: session.title,
+ subtitle: session.workspace || session.model || "Chat",
+ href: `/workspace/chat/${encodeURIComponent(session.id)}`,
+ icon: MessageSquare,
+ keywords: [session.id],
+ });
+ }
+
+ for (const job of input.jobs) {
+ entries.push({
+ id: `schedule-${job.name}`,
+ source: "atrium",
+ category: "schedule",
+ title: job.name,
+ subtitle: job.schedule,
+ href: `/workspace/schedules/${encodeURIComponent(job.name)}`,
+ icon: CalendarClock,
+ keywords: [job.instruction],
+ });
+ }
+
+ for (const skill of input.skills) {
+ entries.push({
+ id: `skill-${skill.name}`,
+ source: "atrium",
+ category: "skill",
+ title: skill.name,
+ subtitle: skill.description,
+ href: `/workspace/skills/${encodeURIComponent(skill.name)}`,
+ icon: Sparkles,
+ });
+ }
+
+ for (const entry of input.memories) {
+ entries.push({
+ id: `memory-${entry.id}`,
+ source: "atrium",
+ category: "memory",
+ title: entry.title,
+ subtitle: entry.content,
+ href: `/workspace/settings/memory/${encodeURIComponent(entry.id)}`,
+ icon: Brain,
+ });
+ }
+
+ return entries;
+}
+
+export const ATRIUM_SEARCH_GROUPS: readonly SearchGroup[] = [
+ { category: "chat", heading: "Chats", source: "atrium" },
+ { category: "memory", heading: "Memory", source: "atrium" },
+ { category: "skill", heading: "Skills", source: "atrium" },
+ { category: "schedule", heading: "Scheduled", source: "atrium" },
+];
diff --git a/studio/src/components/shell/global-search.tsx b/studio/src/components/shell/global-search.tsx
new file mode 100644
index 0000000000..47db4e4250
--- /dev/null
+++ b/studio/src/components/shell/global-search.tsx
@@ -0,0 +1,166 @@
+"use client";
+
+import { Search } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useCallback, useMemo, useState } from "react";
+import {
+ ATRIUM_SEARCH_GROUPS,
+ buildAtriumSearchEntries,
+} from "@/components/shell/atrium-search-data";
+import { createStaticSearchProvider } from "@/components/shell/search-static";
+import type { SearchEntry } from "@/components/shell/search-types";
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ useAgentCron,
+ useAgentMemory,
+ useAgentSessions,
+} from "@/features/agent";
+import { useAgentSkills } from "@/features/agent/hooks/use-agent-skills";
+import { useShortcut } from "@/lib/shortcuts/use-shortcuts";
+import { useThreadSessionIds } from "@/lib/thread-map";
+import { cn } from "@/lib/utils";
+
+/** Atrium workspace results. */
+const ALL_GROUPS = [...ATRIUM_SEARCH_GROUPS];
+
+/**
+ * The global nav search in the shell topbar. Opens a ⌘/Ctrl-K command palette
+ * (built on cmdk) whose filtering is delegated to a `SearchProvider` — an
+ * in-memory index over the live daemon data (sessions, schedules, skills,
+ * memory keys), rebuilt whenever that data changes — so cmdk's own fuzzy
+ * filter is turned off (`shouldFilter={false}`). Transcripts are not indexed:
+ * only titles and metadata match. Selecting a result routes to it.
+ */
+export function GlobalSearch() {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+
+ const { sessions } = useAgentSessions();
+ const { jobs } = useAgentCron();
+ const { skills } = useAgentSkills();
+ const { entries: memories } = useAgentMemory();
+ // Thread-backing sessions stay out of search, mirroring the chat sidebar:
+ // a thread's only entry point is the reply indicator on its parent message.
+ const threadSessionIds = useThreadSessionIds();
+
+ const provider = useMemo(
+ () =>
+ createStaticSearchProvider(
+ buildAtriumSearchEntries({
+ sessions: sessions.filter((s) => !threadSessionIds.has(s.id)),
+ jobs,
+ skills,
+ memories,
+ }),
+ ),
+ [sessions, jobs, skills, memories, threadSessionIds],
+ );
+
+ // App-wide shortcuts, wired through the central dispatcher (this component
+ // is mounted in the shell topbar, so they live on every workspace page):
+ // ⌘K toggles the palette; `?` and ⌘/ open the keyboard-shortcuts reference
+ // (⌘/ also works while typing); ⌘, opens settings.
+ useShortcut("search.open", () => setOpen((prev) => !prev));
+ useShortcut("shortcuts.open", () => router.push("/workspace/shortcuts"));
+ useShortcut("shortcuts.open.mod", () => router.push("/workspace/shortcuts"));
+ useShortcut("settings.open", () => router.push("/workspace/settings"));
+
+ const results = useMemo(() => provider.query(query), [provider, query]);
+ const byCategory = useMemo(() => {
+ const map = new Map();
+ for (const r of results) {
+ const list = map.get(r.entry.category) ?? [];
+ list.push(r);
+ map.set(r.entry.category, list);
+ }
+ return map;
+ }, [results]);
+
+ const handleSelect = useCallback(
+ (entry: SearchEntry) => {
+ setOpen(false);
+ router.push(entry.href);
+ },
+ [router],
+ );
+
+ return (
+ <>
+ setOpen(true)}
+ aria-label="Search"
+ className={cn(
+ "flex h-9 items-center gap-2 rounded-md border border-border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ // Icon-only on narrow screens; a full search field from `sm` up.
+ "size-9 justify-center px-0 min-[500px]:w-64 min-[500px]:justify-start min-[500px]:px-3",
+ )}
+ >
+
+
+ Search…
+
+
+ ⌘ K
+
+
+
+
+
+
+ No results found.
+ {ALL_GROUPS.map(({ category, heading }) => {
+ const hits = byCategory.get(category);
+ if (!hits || hits.length === 0) return null;
+ return (
+
+ {hits.map(({ entry, snippet }) => {
+ const Icon = entry.icon;
+ return (
+ handleSelect(entry)}
+ >
+
+
+ {entry.title}
+
+ {snippet ?? entry.subtitle}
+
+
+
+ );
+ })}
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/studio/src/components/shell/search-static.test.ts b/studio/src/components/shell/search-static.test.ts
new file mode 100644
index 0000000000..20067bc3e2
--- /dev/null
+++ b/studio/src/components/shell/search-static.test.ts
@@ -0,0 +1,73 @@
+import { Server } from "lucide-react";
+import { describe, expect, it } from "vitest";
+import { createStaticSearchProvider } from "./search-static";
+import type { SearchEntry } from "./search-types";
+
+function entry(
+ partial: Partial & Pick,
+): SearchEntry {
+ return {
+ source: "atrium",
+ category: "chat",
+ title: "Untitled",
+ subtitle: "",
+ href: `/x/${partial.id}`,
+ icon: Server,
+ ...partial,
+ };
+}
+
+const ENTRIES: SearchEntry[] = [
+ entry({
+ id: "a",
+ title: "Auth service",
+ subtitle: "Platform",
+ keywords: ["login", "oauth"],
+ }),
+ entry({
+ id: "b",
+ title: "Billing chat",
+ subtitle: "Finance",
+ body: "We debated whether to sign the jsonwebtoken payload before caching it downstream.",
+ }),
+ entry({ id: "c", title: "Random note", subtitle: "Misc" }),
+];
+
+describe("createStaticSearchProvider", () => {
+ const provider = createStaticSearchProvider(ENTRIES);
+
+ it("returns every entry for an empty query", () => {
+ expect(provider.query("")).toHaveLength(ENTRIES.length);
+ expect(provider.query(" ")).toHaveLength(ENTRIES.length);
+ });
+
+ it("matches on title, subtitle and keywords", () => {
+ expect(provider.query("auth").map((r) => r.entry.id)).toEqual(["a"]);
+ expect(provider.query("oauth").map((r) => r.entry.id)).toEqual(["a"]);
+ expect(provider.query("finance").map((r) => r.entry.id)).toEqual(["b"]);
+ });
+
+ it("narrows with multi-token AND rather than fuzzing", () => {
+ // Both tokens present in entry a's haystack.
+ expect(provider.query("auth login").map((r) => r.entry.id)).toEqual(["a"]);
+ // "auth" matches a, "finance" matches b — no single entry has both.
+ expect(provider.query("auth finance")).toHaveLength(0);
+ });
+
+ it("finds terms inside a body and returns a snippet for body-only hits", () => {
+ const hits = provider.query("jsonwebtoken");
+ expect(hits.map((r) => r.entry.id)).toEqual(["b"]);
+ expect(hits[0].snippet).toContain("jsonwebtoken");
+ expect(hits[0].snippet).toMatch(/…/);
+ });
+
+ it("does not attach a snippet when the match is in the title", () => {
+ const hits = provider.query("billing");
+ expect(hits.map((r) => r.entry.id)).toEqual(["b"]);
+ expect(hits[0].snippet).toBeUndefined();
+ });
+
+ it("is case-insensitive", () => {
+ expect(provider.query("AUTH").map((r) => r.entry.id)).toEqual(["a"]);
+ });
+});
diff --git a/studio/src/components/shell/search-static.ts b/studio/src/components/shell/search-static.ts
new file mode 100644
index 0000000000..fae0879d93
--- /dev/null
+++ b/studio/src/components/shell/search-static.ts
@@ -0,0 +1,66 @@
+import type { SearchEntry, SearchProvider, SearchResult } from "./search-types";
+
+/**
+ * A static, in-memory search provider. Each entry is flattened once into a
+ * lowercased haystack; a query matches when every whitespace-separated token is
+ * a substring of the haystack (so multi-word queries narrow rather than fuzz).
+ * When the match lands in an entry's `body` (not its title), a short snippet
+ * around the first token is returned for display.
+ *
+ * The same interface can be backed by a server search later — the palette only
+ * depends on `SearchProvider`.
+ */
+export function createStaticSearchProvider(
+ entries: readonly SearchEntry[],
+): SearchProvider {
+ const indexed = entries.map((entry) => ({
+ entry,
+ haystack: [
+ entry.title,
+ entry.subtitle,
+ ...(entry.keywords ?? []),
+ entry.body ?? "",
+ ]
+ .join(" ")
+ .toLowerCase(),
+ }));
+
+ return {
+ query(q: string): SearchResult[] {
+ const tokens = q.trim().toLowerCase().split(/\s+/).filter(Boolean);
+ if (tokens.length === 0) {
+ return entries.map((entry) => ({ entry }));
+ }
+ const results: SearchResult[] = [];
+ for (const { entry, haystack } of indexed) {
+ if (!tokens.every((t) => haystack.includes(t))) continue;
+ const titleHasMatch = tokens.some((t) =>
+ entry.title.toLowerCase().includes(t),
+ );
+ const snippet =
+ !titleHasMatch && entry.body
+ ? snippetFor(entry.body, tokens)
+ : undefined;
+ results.push({ entry, snippet });
+ }
+ return results;
+ },
+ };
+}
+
+/** A trimmed excerpt of `body` around the first matching token. */
+function snippetFor(body: string, tokens: string[]): string | undefined {
+ const lower = body.toLowerCase();
+ let idx = -1;
+ for (const t of tokens) {
+ const at = lower.indexOf(t);
+ if (at !== -1 && (idx === -1 || at < idx)) idx = at;
+ }
+ if (idx === -1) return undefined;
+ const start = Math.max(0, idx - 32);
+ const end = Math.min(body.length, idx + 48);
+ let excerpt = body.slice(start, end).replace(/\s+/g, " ").trim();
+ if (start > 0) excerpt = `…${excerpt}`;
+ if (end < body.length) excerpt = `${excerpt}…`;
+ return excerpt;
+}
diff --git a/studio/src/components/shell/search-types.ts b/studio/src/components/shell/search-types.ts
new file mode 100644
index 0000000000..3ae59e1783
--- /dev/null
+++ b/studio/src/components/shell/search-types.ts
@@ -0,0 +1,51 @@
+import type { ComponentType } from "react";
+
+/**
+ * Shared shapes for the global search. Each domain (admin fixtures, Atrium
+ * workspace) owns its own typed registry and tags its entries with a `source`;
+ * the palette merges them at the edge. Filtering goes through a `SearchProvider`
+ * so the static in-memory index can later be swapped for a server-backed one
+ * without touching the UI.
+ */
+
+type SearchSource = "admin" | "atrium";
+
+export interface SearchEntry {
+ /** Stable key for React keys and cmdk values. */
+ readonly id: string;
+ readonly source: SearchSource;
+ /** Grouping key within the entry's source (e.g. "chat", "connector"). */
+ readonly category: string;
+ /** Primary line shown in the result row. */
+ readonly title: string;
+ /** Muted secondary line (e.g. project name, email, host). */
+ readonly subtitle: string;
+ /** Absolute route this result navigates to. */
+ readonly href: string;
+ /** Leading icon for the row. */
+ readonly icon: ComponentType<{ className?: string }>;
+ /** Extra free text to match on but not display. */
+ readonly keywords?: readonly string[];
+ /**
+ * Long-form body (e.g. a chat transcript) — matched, and a snippet around the
+ * match is shown when the hit lands here rather than in the title.
+ */
+ readonly body?: string;
+}
+
+export interface SearchGroup {
+ readonly category: string;
+ readonly heading: string;
+ readonly source: SearchSource;
+}
+
+export interface SearchResult {
+ readonly entry: SearchEntry;
+ /** A short excerpt around the match when it's in the body, not the title. */
+ readonly snippet?: string;
+}
+
+export interface SearchProvider {
+ /** Results for a query (all entries when the query is empty). */
+ query(q: string): SearchResult[];
+}
diff --git a/studio/src/components/shell/top-nav.tsx b/studio/src/components/shell/top-nav.tsx
new file mode 100644
index 0000000000..89d429d7c5
--- /dev/null
+++ b/studio/src/components/shell/top-nav.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { buildUserNav } from "@/components/app/nav-items";
+import { GlobalSearch } from "@/components/shell/global-search";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+/**
+ * The workspace top navigation bar. It sits directly on the fixed dark-green
+ * gradient (see `workspace/layout.tsx`), so every colour here is a fixed
+ * brand colour, identical in light and dark themes — only the card below it
+ * follows the theme.
+ *
+ * Left: the Stacklok logo mark, painted white via a CSS mask over the brand
+ * SVG (the asset itself is never recoloured). Centre-right: the pill nav from
+ * `buildUserNav()` — the active route renders as a light pill with icon +
+ * label, inactive routes are icon-only round buttons with a tooltip.
+ * Far right: the global search trigger (the existing ⌘K `GlobalSearch`
+ * palette), restyled from the outside for the dark band.
+ *
+ * A Client Component: it needs `usePathname()` for the active state, and the
+ * nav config carries icon component references that must stay inside the
+ * client module graph.
+ */
+export function TopNav() {
+ const pathname = usePathname() ?? "";
+ const nav = buildUserNav();
+
+ return (
+
+ );
+}
diff --git a/studio/src/features/agent/composer-capabilities.ts b/studio/src/features/agent/composer-capabilities.ts
new file mode 100644
index 0000000000..713049295a
--- /dev/null
+++ b/studio/src/features/agent/composer-capabilities.ts
@@ -0,0 +1,74 @@
+import { listHarnessAgents, listHarnessCommands } from "@/lib/harness/client";
+
+/**
+ * What the chat composer can pull into a message: the `@agent` mentions and
+ * the `/slash` commands its autocomplete offers.
+ *
+ * Both come from the daemon — the resolved subagent inventory
+ * (`GET /v1/agents`) and the workspace's discovered slash commands
+ * (`GET /v1/commands`) — refreshed by the runtime-status provider on every
+ * (re)connect, because a daemon restart can change either.
+ *
+ * A module-level registry rather than React state: tiptap's suggestion
+ * plugins read these lists per keystroke from plain callbacks that live
+ * outside the component tree.
+ */
+
+export interface AgentMention {
+ /** Handle inserted after `@` (the agent slug). */
+ readonly handle: string;
+ readonly name: string;
+ readonly description: string;
+}
+
+export interface SlashCommand {
+ /** Name inserted after `/`. */
+ readonly name: string;
+ readonly description: string;
+}
+
+/** Turns an agent name into an `@`-mention handle, e.g. "Code Reviewer" → "code-reviewer". */
+function toHandle(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
+
+let agentMentions: readonly AgentMention[] = [];
+let slashCommands: readonly SlashCommand[] = [];
+
+export function getAgentMentions(): readonly AgentMention[] {
+ return agentMentions;
+}
+
+export function getSlashCommands(): readonly SlashCommand[] {
+ return slashCommands;
+}
+
+/**
+ * Re-reads both capability lists from the daemon. Either list failing leaves
+ * the previous value in place — an autocomplete that briefly lags a restart
+ * beats one that flickers empty on every transient error.
+ */
+export async function refreshComposerCapabilities(): Promise {
+ const [agents, commands] = await Promise.allSettled([
+ listHarnessAgents(),
+ listHarnessCommands(),
+ ]);
+ if (agents.status === "fulfilled") {
+ agentMentions = agents.value.map((agent) => ({
+ handle: toHandle(agent.name),
+ name: agent.name,
+ description: agent.description,
+ }));
+ }
+ if (commands.status === "fulfilled") {
+ slashCommands = commands.value
+ .filter((command) => command.name)
+ .map((command) => ({
+ name: command.name,
+ description: command.description,
+ }));
+ }
+}
diff --git a/studio/src/features/agent/hooks/use-agent-cron.test.ts b/studio/src/features/agent/hooks/use-agent-cron.test.ts
new file mode 100644
index 0000000000..c6f5570270
--- /dev/null
+++ b/studio/src/features/agent/hooks/use-agent-cron.test.ts
@@ -0,0 +1,167 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ScheduleRow, ScheduleSpecDraft } from "@/lib/protocol";
+import { useAgentCron } from "./use-agent-cron";
+
+/**
+ * Pins the rename sequencing: the daemon has no rename on the wire, so a
+ * new-name save is re-create → (pause, when the old entry was paused) →
+ * delete-old, with fail-safe failure arms — a failed create leaves the old
+ * schedule untouched, and a failed delete reports that both entries exist.
+ */
+
+const { calls, harnessScheduleAction, listScheduleRows, saveHarnessSchedule } =
+ vi.hoisted(() => {
+ const calls: string[] = [];
+ return {
+ calls,
+ harnessScheduleAction: vi.fn(async (name: string, action: string) => {
+ calls.push(`${action}:${name}`);
+ }),
+ listScheduleRows: vi.fn(async () => []),
+ saveHarnessSchedule: vi.fn(
+ async (draft: { name: string }, opts: { update: boolean }) => {
+ calls.push(`${opts.update ? "update" : "create"}:${draft.name}`);
+ },
+ ),
+ };
+ });
+
+vi.mock("@/lib/harness/client", () => ({
+ harnessScheduleAction,
+ listScheduleRows,
+ saveHarnessSchedule,
+}));
+
+vi.mock("../runtime-status", () => ({
+ useRuntimeStatus: () => ({ connected: true }),
+}));
+
+function makeRow(overrides: Partial): ScheduleRow {
+ return {
+ name: "old-name",
+ prompt: "do the thing",
+ cron: "0 9 * * *",
+ oneShotAt: null,
+ timezone: "",
+ workspace: "",
+ profile: "",
+ mode: 2,
+ mutating: false,
+ maxFires: 0,
+ limits: { maxTurns: 0, maxToolCalls: 0, maxConsecutiveFailures: 0 },
+ oneShotRetry: false,
+ oneShotMaxRetries: 0,
+ enabled: true,
+ fireCount: 3,
+ nextFireAt: null,
+ lastFireAt: null,
+ fireStage: "idle",
+ lastFireSessionId: "",
+ owner: "",
+ carried: {
+ selectorProvider: "openrouter",
+ selectorModel: "some/model",
+ misfire: 1,
+ singleton: true,
+ carryContext: false,
+ fireTimeoutSeconds: 90,
+ parts: [],
+ },
+ ...overrides,
+ };
+}
+
+const draft: ScheduleSpecDraft = {
+ name: "new-name",
+ prompt: "do the thing",
+ trigger: { kind: "cron", cron: "0 9 * * *", timezone: "" },
+ profile: "",
+ workspace: "",
+ mode: 2,
+ mutating: false,
+ maxFires: 0,
+ limits: { maxTurns: 0, maxToolCalls: 0, maxConsecutiveFailures: 0 },
+ oneShotRetry: false,
+ oneShotMaxRetries: 0,
+};
+
+async function settledHook() {
+ const rendered = renderHook(() => useAgentCron());
+ await waitFor(() => expect(rendered.result.current.isLoading).toBe(false));
+ calls.length = 0;
+ return rendered;
+}
+
+describe("renameAndUpdateFromDraft", () => {
+ beforeEach(() => {
+ calls.length = 0;
+ });
+
+ it("creates the new name (carrying the stored spec) then deletes the old", async () => {
+ const previous = makeRow({ enabled: true });
+ const { result } = await settledHook();
+
+ await act(async () => {
+ await result.current.renameAndUpdateFromDraft(draft, previous);
+ });
+
+ expect(calls).toEqual(["create:new-name", "delete:old-name"]);
+ expect(saveHarnessSchedule).toHaveBeenCalledWith(draft, {
+ update: false,
+ carried: previous.carried,
+ });
+ expect(result.current.error).toBeNull();
+ });
+
+ it("mirrors a paused state onto the new name before deleting the old", async () => {
+ const previous = makeRow({ enabled: false });
+ const { result } = await settledHook();
+
+ await act(async () => {
+ await result.current.renameAndUpdateFromDraft(draft, previous);
+ });
+
+ expect(calls).toEqual([
+ "create:new-name",
+ "pause:new-name",
+ "delete:old-name",
+ ]);
+ });
+
+ it("leaves the old schedule untouched when the create fails", async () => {
+ saveHarnessSchedule.mockRejectedValueOnce(new Error("name is taken"));
+ const previous = makeRow({ enabled: false });
+ const { result } = await settledHook();
+
+ await act(async () => {
+ await expect(
+ result.current.renameAndUpdateFromDraft(draft, previous),
+ ).rejects.toThrow("name is taken");
+ });
+
+ // No pause, no delete: the failure arm never touches the old entry.
+ expect(calls).toEqual([]);
+ expect(result.current.error).toBe("name is taken");
+ });
+
+ it("reports that both entries exist when the delete fails", async () => {
+ harnessScheduleAction.mockImplementationOnce(async (name, action) => {
+ calls.push(`${action}:${name}`);
+ throw new Error("store is read-only");
+ });
+ const previous = makeRow({ enabled: true });
+ const { result } = await settledHook();
+
+ await act(async () => {
+ await expect(
+ result.current.renameAndUpdateFromDraft(draft, previous),
+ ).rejects.toThrow(/both entries exist/);
+ });
+
+ expect(calls).toEqual(["create:new-name", "delete:old-name"]);
+ expect(result.current.error).toContain('"new-name"');
+ expect(result.current.error).toContain('delete "old-name" manually');
+ expect(result.current.error).toContain("store is read-only");
+ });
+});
diff --git a/studio/src/features/agent/hooks/use-agent-cron.ts b/studio/src/features/agent/hooks/use-agent-cron.ts
new file mode 100644
index 0000000000..eb5ff6d0ca
--- /dev/null
+++ b/studio/src/features/agent/hooks/use-agent-cron.ts
@@ -0,0 +1,235 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import {
+ harnessScheduleAction,
+ listScheduleRows,
+ saveHarnessSchedule,
+} from "@/lib/harness/client";
+import {
+ PERMISSION_MODES,
+ type ScheduleCarriedSpec,
+ type ScheduleRow,
+ type ScheduleSpecDraft,
+} from "@/lib/protocol";
+import { useRuntimeStatus } from "../runtime-status";
+import type { CreateCronOpts, CronJob } from "../types";
+
+/** Fields the create-schedule form supplies on top of the shared opts. */
+export type CreateJobInput = CreateCronOpts & { enabled?: boolean };
+
+function toCronJob(row: ScheduleRow): CronJob {
+ return {
+ id: row.name,
+ name: row.name,
+ schedule: row.cron || "one-shot",
+ instruction: row.prompt,
+ enabled: row.enabled,
+ status: row.fireStage === "idle" ? "idle" : "running",
+ lastRunAt: row.lastFireAt,
+ output:
+ row.fireCount > 0
+ ? `${row.fireCount} fire${row.fireCount === 1 ? "" : "s"} so far`
+ : null,
+ lastRunSessionId: row.lastFireSessionId || undefined,
+ prompt: row.prompt,
+ };
+}
+
+/**
+ * Scheduled agent runs, backed by the daemon's schedule registry.
+ *
+ * A live schedule fires an agent run unattended, so this hook reads the
+ * daemon's durable state back after every action rather than predicting what
+ * an action produced.
+ *
+ * A deployment without a schedule store answers the list with an error
+ * (there is no scheduler to be empty): that is the distinct NOT-WIRED state,
+ * never conflated with an empty registry.
+ */
+export function useAgentCron() {
+ const { connected } = useRuntimeStatus();
+ const [rows, setRows] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [notWired, setNotWired] = useState(null);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async (signal?: AbortSignal) => {
+ try {
+ const schedules = await listScheduleRows(signal);
+ if (signal?.aborted) return;
+ setRows(schedules);
+ setNotWired(null);
+ } catch (caught) {
+ if (signal?.aborted) return;
+ setRows([]);
+ setNotWired(caught instanceof Error ? caught.message : String(caught));
+ } finally {
+ if (!signal?.aborted) setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!connected) return;
+ const controller = new AbortController();
+ void load(controller.signal);
+ return () => controller.abort();
+ }, [connected, load]);
+
+ const refresh = useCallback(async () => {
+ await load();
+ }, [load]);
+
+ /** Runs one action then re-reads durable state; refusals surface verbatim. */
+ const perform = useCallback(
+ async (action: () => Promise) => {
+ setError(null);
+ try {
+ await action();
+ } catch (caught) {
+ setError(caught instanceof Error ? caught.message : String(caught));
+ throw caught;
+ } finally {
+ await load();
+ }
+ },
+ [load],
+ );
+
+ const createJob = useCallback(
+ async (opts: CreateJobInput) => {
+ // The quick-create path builds a read-only schedule: plan mode, not
+ // mutating — the pairing the daemon accepts without a write opt-in.
+ const draft: ScheduleSpecDraft = {
+ name: opts.name,
+ prompt: opts.instruction,
+ trigger: { kind: "cron", cron: opts.schedule, timezone: "" },
+ profile: "",
+ workspace: "",
+ mode: PERMISSION_MODES.PERMISSION_MODE_PLAN,
+ mutating: false,
+ maxFires: 0,
+ limits: { maxTurns: 0, maxToolCalls: 0, maxConsecutiveFailures: 0 },
+ oneShotRetry: false,
+ oneShotMaxRetries: 0,
+ };
+ await perform(() => saveHarnessSchedule(draft, { update: false }));
+ const job = rows.find((row) => row.name === opts.name);
+ return job ? toCronJob(job) : undefined;
+ },
+ [perform, rows],
+ );
+
+ /** Full authoring path: the dialog builds the draft, the daemon judges it. */
+ const createFromDraft = useCallback(
+ async (draft: ScheduleSpecDraft) => {
+ await perform(() => saveHarnessSchedule(draft, { update: false }));
+ },
+ [perform],
+ );
+
+ /**
+ * PUT replaces the whole spec, so the row's carried fields must ride along
+ * or every field this UI has no control for would be silently deleted.
+ */
+ const updateFromDraft = useCallback(
+ async (draft: ScheduleSpecDraft, carried: ScheduleCarriedSpec) => {
+ await perform(() =>
+ saveHarnessSchedule(draft, { update: true, carried }),
+ );
+ },
+ [perform],
+ );
+
+ /**
+ * Save an edit under a NEW name. The daemon has no rename — PUT force-stamps
+ * the path name onto the body and fire history is keyed by name — so a
+ * rename is re-create + delete, in fail-safe order: create the new name
+ * first (carrying the stored spec fields), mirror a paused state onto it,
+ * then delete the old entry. A failed create leaves the old schedule
+ * untouched; a failure after the create is reported honestly as "both
+ * entries now exist" rather than pretending success. Run history stays with
+ * the old name and is deleted with it.
+ */
+ const renameAndUpdateFromDraft = useCallback(
+ async (draft: ScheduleSpecDraft, previous: ScheduleRow) => {
+ const errorDetail = (caught: unknown) =>
+ caught instanceof Error ? caught.message : String(caught);
+ await perform(async () => {
+ await saveHarnessSchedule(draft, {
+ update: false,
+ carried: previous.carried,
+ });
+ if (!previous.enabled) {
+ try {
+ await harnessScheduleAction(draft.name, "pause");
+ } catch (caught) {
+ throw new Error(
+ `Created "${draft.name}" but pausing it failed — both entries exist now; ` +
+ `pause "${draft.name}" and delete "${previous.name}" manually. (${errorDetail(caught)})`,
+ );
+ }
+ }
+ try {
+ await harnessScheduleAction(previous.name, "delete");
+ } catch (caught) {
+ throw new Error(
+ `Created "${draft.name}" but deleting the old "${previous.name}" failed — ` +
+ `both entries exist now; delete "${previous.name}" manually. (${errorDetail(caught)})`,
+ );
+ }
+ });
+ },
+ [perform],
+ );
+
+ const runJob = useCallback(
+ async (jobId: string) => {
+ // FireNow is synchronous on the daemon: this await lasts the whole run.
+ await perform(() => harnessScheduleAction(jobId, "fire"));
+ },
+ [perform],
+ );
+
+ const deleteJob = useCallback(
+ async (jobId: string) => {
+ await perform(() => harnessScheduleAction(jobId, "delete"));
+ },
+ [perform],
+ );
+
+ const pauseJob = useCallback(
+ async (jobId: string) => {
+ await perform(() => harnessScheduleAction(jobId, "pause"));
+ },
+ [perform],
+ );
+
+ const resumeJob = useCallback(
+ async (jobId: string) => {
+ await perform(() => harnessScheduleAction(jobId, "resume"));
+ },
+ [perform],
+ );
+
+ return {
+ jobs: rows.map(toCronJob),
+ /** Full decoded rows: mode/mutating/workspace badges, carried spec for edits. */
+ rows,
+ isLoading: isLoading && connected,
+ isSupported: notWired === null,
+ /** The daemon's own words for why scheduling is unavailable, when it is. */
+ notWired,
+ error,
+ harnessLive: connected,
+ createJob,
+ createFromDraft,
+ updateFromDraft,
+ renameAndUpdateFromDraft,
+ runJob,
+ deleteJob,
+ pauseJob,
+ resumeJob,
+ refresh,
+ };
+}
diff --git a/studio/src/features/agent/hooks/use-agent-memory.ts b/studio/src/features/agent/hooks/use-agent-memory.ts
new file mode 100644
index 0000000000..e39d5584f9
--- /dev/null
+++ b/studio/src/features/agent/hooks/use-agent-memory.ts
@@ -0,0 +1,78 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { fetchHarnessUserModel } from "@/lib/harness/client";
+import { useRuntimeStatus } from "../runtime-status";
+import type { MemoryEntry } from "../types";
+
+/**
+ * Agent memory for the current operator: the daemon's user model — durable
+ * facts the agent stored, shared across every project.
+ *
+ * `canWrite` is always false: the daemon exposes no write endpoint for the
+ * user model, because the agent curates it through injection-scanned tool
+ * calls. A value typed by hand would land in the model's turn-0 context
+ * without passing that check, so the UI must not offer editing.
+ *
+ * A daemon running with `--no-user-model` answers the index read with an
+ * error; that is a distinct DISABLED state, rendered as such — never as
+ * placeholder content.
+ */
+export function useAgentMemory() {
+ const { connected } = useRuntimeStatus();
+ const [entries, setEntries] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [disabledReason, setDisabledReason] = useState(null);
+ const [store, setStore] = useState({ sizeBytes: 0, sha256: "" });
+
+ const load = useCallback(async (signal?: AbortSignal) => {
+ setIsLoading(true);
+ try {
+ const model = await fetchHarnessUserModel(signal);
+ if (signal?.aborted) return;
+ setDisabledReason(null);
+ setStore({ sizeBytes: model.sizeBytes, sha256: model.sha256 });
+ setEntries(
+ model.entries.map((fact) => ({
+ id: fact.key,
+ title: fact.key,
+ content: fact.description,
+ section: "user model",
+ updatedAt: 0,
+ })),
+ );
+ } catch (caught) {
+ if (signal?.aborted) return;
+ setEntries([]);
+ setDisabledReason(
+ caught instanceof Error ? caught.message : String(caught),
+ );
+ } finally {
+ if (!signal?.aborted) setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!connected) return;
+ const controller = new AbortController();
+ void load(controller.signal);
+ return () => controller.abort();
+ }, [connected, load]);
+
+ /** Re-reads the store; the agent may have written facts since the last read. */
+ const refresh = useCallback(async () => {
+ await load();
+ }, [load]);
+
+ return {
+ entries,
+ isLoading: isLoading && connected,
+ refresh,
+ isSupported: disabledReason === null,
+ /** Why the user model is unavailable (e.g. --no-user-model), when it is. */
+ disabledReason,
+ store,
+ harnessLive: connected,
+ canWrite: false,
+ };
+}
diff --git a/studio/src/features/agent/hooks/use-agent-roster.ts b/studio/src/features/agent/hooks/use-agent-roster.ts
new file mode 100644
index 0000000000..539f547f18
--- /dev/null
+++ b/studio/src/features/agent/hooks/use-agent-roster.ts
@@ -0,0 +1,48 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { listHarnessAgents } from "@/lib/harness/client";
+import { useRuntimeStatus } from "../runtime-status";
+
+/** One row of the daemon's resolved agent inventory (`GET /v1/agents`). */
+export interface RosterAgent {
+ name: string;
+ description: string;
+ /** Pinned model id; empty = "auto" (inherits the session model / router). */
+ model: string;
+ /** Effective read-only tool scope at the delegation call site. */
+ tools: string[];
+ /** Raw frontmatter permission mode ("" means default). */
+ permissionMode: string;
+ /** Optional def color hint — a UX tint only, never execution-relevant. */
+ color: string;
+}
+
+/**
+ * The daemon's real agent roster, read-only. Gated on the runtime being
+ * connected; an unreachable daemon yields an empty roster, never demo data.
+ */
+export function useAgentRoster() {
+ const { connected } = useRuntimeStatus();
+ const [agents, setAgents] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ if (!connected) return;
+ const controller = new AbortController();
+ setIsLoading(true);
+ listHarnessAgents(controller.signal)
+ .then((roster) => {
+ if (!controller.signal.aborted) setAgents(roster);
+ })
+ .catch(() => {
+ if (!controller.signal.aborted) setAgents([]);
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setIsLoading(false);
+ });
+ return () => controller.abort();
+ }, [connected]);
+
+ return { agents, isLoading: isLoading && connected };
+}
diff --git a/studio/src/features/agent/hooks/use-agent-sessions.ts b/studio/src/features/agent/hooks/use-agent-sessions.ts
new file mode 100644
index 0000000000..c5f596c197
--- /dev/null
+++ b/studio/src/features/agent/hooks/use-agent-sessions.ts
@@ -0,0 +1,235 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ createHarnessSession,
+ deleteHarnessSession,
+ fetchAllSessions,
+ renameHarnessSession,
+} from "@/lib/harness/client";
+import type { SessionSummary } from "@/lib/protocol";
+import { useRuntimeStatus } from "../runtime-status";
+import type { AgentSession, CreateSessionOpts } from "../types";
+
+const POLL_INTERVAL_MS = 20_000;
+
+function toAgentSession(summary: SessionSummary): AgentSession {
+ return {
+ id: summary.sessionId,
+ title: summary.title || "Untitled chat",
+ projectId: null,
+ model: summary.modelId,
+ createdAt: summary.createdAt,
+ updatedAt: summary.modifiedAt,
+ pinned: false,
+ archived: false,
+ messageCount: summary.turns,
+ isStreaming: summary.state === "running",
+ inputTokens: 0,
+ outputTokens: 0,
+ unread: false,
+ estimatedCost: null,
+ contextLength: null,
+ lastPromptTokens: null,
+ thresholdTokens: null,
+ state: summary.state,
+ workspace: summary.workspace,
+ canRename: summary.canRename,
+ canDelete: summary.canDelete,
+ renameReason: summary.renameReason,
+ deleteReason: summary.deleteReason,
+ titleProvenance: summary.titleProvenance,
+ debugTargetSessionId: summary.debugTargetSessionId,
+ };
+}
+
+/**
+ * The chat list, backed by the daemon's session store — the record of chats.
+ *
+ * Invariants (from the server-backed-chats design):
+ * - Only chats appear: rows whose one not-a-chat reason is
+ * `inspect_only_kind` (subagents, team members, scheduled fires) are
+ * filtered by the decoder.
+ * - A row is removed only when a COMPLETE inventory walk proves it gone; a
+ * partial walk merges and never deletes.
+ * - Action eligibility (rename/delete) comes from the row's capabilities,
+ * never re-derived client-side.
+ * - A rename is optimistic but adopts the daemon's clamped title echo, and
+ * rolls back when the daemon refuses.
+ */
+export function useAgentSessions() {
+ const { connected } = useRuntimeStatus();
+ const [sessions, setSessions] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const loadedOnce = useRef(false);
+
+ const load = useCallback(async (signal?: AbortSignal) => {
+ try {
+ const walk = await fetchAllSessions(signal);
+ if (signal?.aborted) return;
+ const chats = walk.sessions
+ .filter((summary) => summary.isChat)
+ .map(toAgentSession);
+ setSessions((previous) => {
+ if (walk.complete) return chats;
+ // Incomplete walk: update what we saw, keep what we did not.
+ const seen = new Map(chats.map((chat) => [chat.id, chat]));
+ const merged = previous.map((chat) => seen.get(chat.id) ?? chat);
+ const known = new Set(previous.map((chat) => chat.id));
+ return [...merged, ...chats.filter((chat) => !known.has(chat.id))];
+ });
+ setError(null);
+ loadedOnce.current = true;
+ } catch (caught) {
+ if (signal?.aborted) return;
+ setError(caught instanceof Error ? caught.message : String(caught));
+ } finally {
+ if (!signal?.aborted) setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!connected) return;
+ const controller = new AbortController();
+ void load(controller.signal);
+ const timer = setInterval(() => {
+ void load(controller.signal);
+ }, POLL_INTERVAL_MS);
+ return () => {
+ controller.abort();
+ clearInterval(timer);
+ };
+ }, [connected, load]);
+
+ const refreshSessions = useCallback(async () => {
+ await load();
+ }, [load]);
+
+ /** Creates a daemon session and returns its row. The id IS the daemon id. */
+ const createSession = useCallback(
+ async (_opts: CreateSessionOpts = {}) => {
+ const sessionId = await createHarnessSession("default");
+ const session: AgentSession = {
+ id: sessionId,
+ title: "Untitled chat",
+ projectId: null,
+ model: "",
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ pinned: false,
+ archived: false,
+ unread: false,
+ messageCount: 0,
+ isStreaming: false,
+ inputTokens: 0,
+ outputTokens: 0,
+ estimatedCost: null,
+ contextLength: null,
+ lastPromptTokens: null,
+ thresholdTokens: null,
+ };
+ setSessions((previous) => [session, ...previous]);
+ void load();
+ return session;
+ },
+ [load],
+ );
+
+ const deleteSession = useCallback(async (id: string) => {
+ try {
+ await deleteHarnessSession(id);
+ } catch (caught) {
+ // Only a 404 proves the session is already gone; any other refusal
+ // keeps the row (the daemon may recover it).
+ const message = caught instanceof Error ? caught.message : String(caught);
+ if (!/not found|404/i.test(message)) {
+ setError(message);
+ throw caught;
+ }
+ }
+ setSessions((previous) => previous.filter((s) => s.id !== id));
+ }, []);
+
+ // Title-provenance rule (F4): renames HERE are always operator-initiated
+ // (the rename dialog), so clobbering is impossible by construction — the
+ // daemon stamps the echo `title_provenance: "operator"`, mirrored in the
+ // optimistic update below. Any FUTURE auto-titling (background summarizers,
+ // unread-driven renames, …) must instead check the row first and skip when
+ // `titleProvenance === "operator"` — an auto-rename must never clobber a
+ // hand-set title. (Today's other rename callers — the model-switch fork and
+ // thread creation — rename only their own freshly-minted session, so no
+ // clobber path exists; this note is the guard for the next caller.)
+ const renameSession = useCallback(async (id: string, title: string) => {
+ let previousTitle = "";
+ let previousProvenance: string | undefined;
+ setSessions((previous) =>
+ previous.map((session) => {
+ if (session.id !== id) return session;
+ previousTitle = session.title;
+ previousProvenance = session.titleProvenance;
+ return {
+ ...session,
+ title,
+ titleProvenance: "operator",
+ updatedAt: Date.now(),
+ };
+ }),
+ );
+ try {
+ const echoed = await renameHarnessSession(id, title);
+ setSessions((previous) =>
+ previous.map((session) =>
+ session.id === id ? { ...session, title: echoed } : session,
+ ),
+ );
+ return undefined;
+ } catch (caught) {
+ setSessions((previous) =>
+ previous.map((session) =>
+ session.id === id
+ ? {
+ ...session,
+ title: previousTitle,
+ titleProvenance: previousProvenance,
+ }
+ : session,
+ ),
+ );
+ setError(caught instanceof Error ? caught.message : String(caught));
+ return undefined;
+ }
+ }, []);
+
+ // The daemon has no pin/archive concept; these are client-side niceties
+ // that live only for the current page.
+ const pinSession = useCallback(async (id: string, pinned: boolean) => {
+ setSessions((previous) =>
+ previous.map((session) =>
+ session.id === id ? { ...session, pinned } : session,
+ ),
+ );
+ return undefined;
+ }, []);
+
+ const archiveSession = useCallback(async (id: string, archived: boolean) => {
+ setSessions((previous) =>
+ previous.map((session) =>
+ session.id === id ? { ...session, archived } : session,
+ ),
+ );
+ return undefined;
+ }, []);
+
+ return {
+ sessions,
+ isLoading: isLoading && !loadedOnce.current,
+ error,
+ createSession,
+ deleteSession,
+ renameSession,
+ pinSession,
+ archiveSession,
+ refreshSessions,
+ };
+}
diff --git a/studio/src/features/agent/hooks/use-agent-skills.ts b/studio/src/features/agent/hooks/use-agent-skills.ts
new file mode 100644
index 0000000000..ed9b555174
--- /dev/null
+++ b/studio/src/features/agent/hooks/use-agent-skills.ts
@@ -0,0 +1,180 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import {
+ createHarnessSkill,
+ createHarnessSkillFiles,
+ type DisabledSkillInfo,
+ deleteHarnessSkill,
+ fetchHarnessSkillBody,
+ fetchHarnessSkillFile,
+ type HarnessSkillInfo,
+ type HarnessSkillUploadFile,
+ listDisabledHarnessSkills,
+ listHarnessSkillFiles,
+ listHarnessSkills,
+ saveHarnessSkillBody,
+ setHarnessSkillEnabled,
+} from "@/lib/harness/client";
+import { useRuntimeStatus } from "../runtime-status";
+
+/**
+ * The daemon's resolved skill inventory (`GET /v1/skills`) plus, in managed
+ * mode, the controller-owned management surface: the `.disabled/` holding
+ * area, SKILL.md body read/write, enable/disable, and delete.
+ *
+ * The inventory itself stays metadata-only by design: the model sees each
+ * skill's name and one-line summary until it chooses to load one. Management
+ * goes through the local controller (the daemon has no skill write API — its
+ * skills snapshot is resolved once at startup), so every mutation may restart
+ * the daemon and is unavailable in external mode (`manageable` is false and
+ * the controller would answer 409 anyway).
+ */
+export function useAgentSkills() {
+ const { connected, mode } = useRuntimeStatus();
+ const manageable = mode === "managed";
+ const [skills, setSkills] = useState([]);
+ const [disabled, setDisabled] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [actionError, setActionError] = useState(null);
+
+ // isLoading starts true and is never re-raised: post-action re-reads keep
+ // the table rendered instead of flashing it back to skeletons.
+ const load = useCallback(
+ async (signal?: AbortSignal) => {
+ // The disabled list is best-effort decoration: a controller hiccup (or
+ // external mode racing the first status probe) must never break the
+ // read-only inventory, so its failure renders as "no disabled skills".
+ const disabledInventory = manageable
+ ? listDisabledHarnessSkills(signal).catch(
+ () => [] as DisabledSkillInfo[],
+ )
+ : Promise.resolve([] as DisabledSkillInfo[]);
+ try {
+ const inventory = await listHarnessSkills(signal);
+ if (signal?.aborted) return;
+ setSkills(inventory);
+ setError(null);
+ } catch (caught) {
+ if (signal?.aborted) return;
+ setSkills([]);
+ setError(caught instanceof Error ? caught.message : String(caught));
+ } finally {
+ if (!signal?.aborted) setIsLoading(false);
+ }
+ const parked = await disabledInventory;
+ if (!signal?.aborted) setDisabled(parked);
+ },
+ [manageable],
+ );
+
+ useEffect(() => {
+ if (!connected) return;
+ const controller = new AbortController();
+ void load(controller.signal);
+ return () => controller.abort();
+ }, [connected, load]);
+
+ const refresh = useCallback(async () => {
+ await load();
+ }, [load]);
+
+ /** Runs one management action then re-reads durable state; refusals surface
+ * verbatim via `actionError`. Mutations restart the daemon, and the
+ * controller holds the response until it is back up, so the re-read lands
+ * on the restarted inventory. */
+ const perform = useCallback(
+ async (action: () => Promise) => {
+ setActionError(null);
+ try {
+ await action();
+ } catch (caught) {
+ setActionError(
+ caught instanceof Error ? caught.message : String(caught),
+ );
+ throw caught;
+ } finally {
+ await load();
+ }
+ },
+ [load],
+ );
+
+ /** Reads a skill's SKILL.md (works for disabled skills too). */
+ const fetchBody = useCallback(
+ (name: string, signal?: AbortSignal) => fetchHarnessSkillBody(name, signal),
+ [],
+ );
+
+ /** Lists the files bundled in a skill's folder (managed mode only). */
+ const fetchFiles = useCallback(
+ (name: string, signal?: AbortSignal) => listHarnessSkillFiles(name, signal),
+ [],
+ );
+
+ /** Reads one bundled text file from a skill's folder (managed mode only). */
+ const fetchFile = useCallback(
+ (name: string, path: string, signal?: AbortSignal) =>
+ fetchHarnessSkillFile(name, path, signal),
+ [],
+ );
+
+ /** Creates a new skill (enabled). Like every mutation, restarts the daemon. */
+ const create = useCallback(
+ async (name: string, body: string) => {
+ await perform(() => createHarnessSkill(name, body));
+ },
+ [perform],
+ );
+
+ /** Creates a whole folder skill from a zip/folder upload's files. */
+ const createFiles = useCallback(
+ async (name: string, files: HarnessSkillUploadFile[]) => {
+ await perform(() => createHarnessSkillFiles(name, files));
+ },
+ [perform],
+ );
+
+ const saveBody = useCallback(
+ async (name: string, body: string) => {
+ await perform(() => saveHarnessSkillBody(name, body));
+ },
+ [perform],
+ );
+
+ const setEnabled = useCallback(
+ async (name: string, enabled: boolean) => {
+ await perform(() => setHarnessSkillEnabled(name, enabled));
+ },
+ [perform],
+ );
+
+ const remove = useCallback(
+ async (name: string) => {
+ await perform(() => deleteHarnessSkill(name));
+ },
+ [perform],
+ );
+
+ return {
+ skills,
+ /** Skills parked in the controller's `.disabled/` holding area. */
+ disabled,
+ /** False in external mode: the deployment owns its skills dir. */
+ manageable,
+ isLoading: isLoading && connected,
+ error,
+ /** The controller's own words for a refused management action. */
+ actionError,
+ create,
+ createFiles,
+ fetchBody,
+ fetchFiles,
+ fetchFile,
+ saveBody,
+ setEnabled,
+ remove,
+ refresh,
+ };
+}
diff --git a/studio/src/features/agent/hooks/use-storage-health.ts b/studio/src/features/agent/hooks/use-storage-health.ts
new file mode 100644
index 0000000000..9bffb85a99
--- /dev/null
+++ b/studio/src/features/agent/hooks/use-storage-health.ts
@@ -0,0 +1,46 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import {
+ fetchStorageHealth,
+ isStorageDegraded,
+ type StorageHealth,
+} from "@/lib/harness/storage";
+import { useRuntimeStatus } from "../runtime-status";
+
+/**
+ * Reads the daemon's storage health (ADR 0226) once per (re)connect, gated on
+ * `capabilities.storage_health`. Quiet by design: an unsupported daemon, a
+ * management-authorization refusal, or any read failure all report null —
+ * the banner this feeds must only ever appear on a POSITIVE degraded signal,
+ * never because the probe itself could not run.
+ */
+export function useStorageHealth(): {
+ health: StorageHealth | null;
+ degraded: boolean;
+} {
+ const { connected, serverCapabilities } = useRuntimeStatus();
+ const supported = serverCapabilities.storage_health === true;
+ const [health, setHealth] = useState(null);
+
+ useEffect(() => {
+ if (!connected || !supported) {
+ setHealth(null);
+ return;
+ }
+ const controller = new AbortController();
+ fetchStorageHealth(controller.signal)
+ .then((result) => {
+ if (!controller.signal.aborted) setHealth(result);
+ })
+ .catch(() => {
+ if (!controller.signal.aborted) setHealth(null);
+ });
+ return () => controller.abort();
+ }, [connected, supported]);
+
+ return {
+ health,
+ degraded: health !== null && isStorageDegraded(health),
+ };
+}
diff --git a/studio/src/features/agent/index.ts b/studio/src/features/agent/index.ts
new file mode 100644
index 0000000000..15547a93a9
--- /dev/null
+++ b/studio/src/features/agent/index.ts
@@ -0,0 +1,31 @@
+// Hooks
+export { useAgentCron } from "./hooks/use-agent-cron";
+export { useAgentMemory } from "./hooks/use-agent-memory";
+export { type RosterAgent, useAgentRoster } from "./hooks/use-agent-roster";
+export { useAgentSessions } from "./hooks/use-agent-sessions";
+
+// Types
+export type {
+ AgentMessage,
+ AgentProject,
+ AgentRoster,
+ AgentSession,
+ ApprovalChoice,
+ ApprovalRequest,
+ Artifact,
+ Attachment,
+ ClarificationRequest,
+ CreateCronOpts,
+ CreateSessionOpts,
+ CronJob,
+ CronRunRecord,
+ DelegationInfo,
+ FileContent,
+ FileEntry,
+ GitInfo,
+ MemoryEntry,
+ ModelInfo,
+ Skill,
+ StreamEvent,
+ ToolCallInfo,
+} from "./types";
diff --git a/studio/src/features/agent/runtime-status.tsx b/studio/src/features/agent/runtime-status.tsx
new file mode 100644
index 0000000000..c4d0a779d2
--- /dev/null
+++ b/studio/src/features/agent/runtime-status.tsx
@@ -0,0 +1,222 @@
+"use client";
+
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import {
+ fetchHarnessCompatibility,
+ fetchHarnessControlStatus,
+ type HarnessCompatibility,
+ type HarnessControlStatus,
+ probeHarness,
+ setActiveHarnessProvider,
+} from "@/lib/harness/client";
+import { refreshComposerCapabilities } from "./composer-capabilities";
+
+const POLL_INTERVAL_MS = 5_000;
+
+export type RuntimeConnectionState = "connecting" | "connected" | "offline";
+
+export interface RuntimeStatus {
+ state: RuntimeConnectionState;
+ /** True exactly when state === "connected"; the common gate for loads. */
+ connected: boolean;
+ /** "external" when Studio proxies to MECATL_BASE_URL; "managed" otherwise. */
+ mode: "managed" | "external";
+ provider: string;
+ /** True when `provider` is the offline mock — see MockProviderNotice. */
+ isMock: boolean;
+ /** Provider names configured in auth.yaml (never credentials). */
+ configuredProviders: string[];
+ /** Whether the ToolHive LLM gateway is reachable right now. */
+ toolhiveAvailable: boolean;
+ gateway: { name: string; url: string } | null;
+ /** Why the daemon is unreachable, when it is. */
+ detail: string;
+ /** The daemon's open feature registry (GET /v1/compatibility, ADR 0248).
+ * Empty against an older daemon — every feature-gated surface must treat
+ * absence as "not supported", never assume. */
+ features: ReadonlySet;
+ /** The operator-enabled server capabilities off the same document. */
+ serverCapabilities: Record;
+ /** Operator-set deployment label ("" when unset / older daemon). */
+ deployment: string;
+ /** False only when the daemon reports an API major Studio does not speak. */
+ apiCompatible: boolean;
+ /** Forces an immediate re-probe (the offline screen's Retry). */
+ refresh: () => Promise;
+ /**
+ * Switches the daemon's active provider ("mock", "toolhive", or a name in
+ * `configuredProviders`) and restarts it, then re-probes. Managed mode
+ * only — external mode's controller has no daemon to restart.
+ */
+ switchProvider: (kind: string) => Promise;
+}
+
+const RuntimeStatusContext = createContext(null);
+
+/**
+ * The single connection authority for every daemon-backed surface.
+ *
+ * Studio is daemon-only: there is no demo fallback, so an unreachable daemon
+ * is a real state every surface must render. This provider polls the daemon
+ * (via /api/mecatl) and the controller (via /api/mecatl-control) every five
+ * seconds and exposes one shared answer, replacing the prototype's
+ * per-hook one-shot probes — which could disagree with each other and never
+ * noticed a daemon that died after mount.
+ */
+export function RuntimeStatusProvider({ children }: { children: ReactNode }) {
+ const [state, setState] = useState("connecting");
+ const [detail, setDetail] = useState("");
+ const [control, setControl] = useState(null);
+ const [mode, setMode] = useState<"managed" | "external">("managed");
+ const [compat, setCompat] = useState(null);
+ const capabilitiesLoaded = useRef(false);
+
+ const probe = useCallback(async (signal?: AbortSignal) => {
+ const [daemon, controlStatus] = await Promise.all([
+ probeHarness(signal),
+ fetchHarnessControlStatus(signal),
+ ]);
+ if (signal?.aborted) return;
+ setControl(controlStatus);
+ if (controlStatus?.mode) setMode(controlStatus.mode);
+ if (daemon.live) {
+ setState("connected");
+ setDetail("");
+ if (!capabilitiesLoaded.current) {
+ capabilitiesLoaded.current = true;
+ void refreshComposerCapabilities();
+ // Feature detection rides each (re)connect: a restart may be a
+ // different daemon version. Best-effort — an older daemon without
+ // the endpoint reports null and every gate reads "unsupported".
+ fetchHarnessCompatibility(signal)
+ .then((doc) => {
+ if (!signal?.aborted) setCompat(doc);
+ })
+ .catch(() => {
+ if (!signal?.aborted) setCompat(null);
+ });
+ }
+ } else {
+ setState("offline");
+ setDetail(daemon.detail);
+ // The next reconnect re-reads mentions and commands: a restart may have
+ // changed the resolved roster.
+ capabilitiesLoaded.current = false;
+ }
+ }, []);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ void probe(controller.signal);
+ const timer = setInterval(() => {
+ void probe(controller.signal);
+ }, POLL_INTERVAL_MS);
+ return () => {
+ controller.abort();
+ clearInterval(timer);
+ };
+ }, [probe]);
+
+ const refresh = useCallback(async () => {
+ await probe();
+ }, [probe]);
+
+ // Memo-free derivations: both are cheap and re-render-safe.
+ const featureSet = new Set(compat?.features ?? []);
+ // 0/absent = older daemon (compatible by definition of the additive era);
+ // a REPORTED major other than 1 is a real skew Studio must not hide.
+ const apiCompatible = compat === null || compat.apiMajor <= 1;
+
+ const switchProvider = useCallback(
+ async (kind: string) => {
+ await setActiveHarnessProvider(kind);
+ await probe();
+ },
+ [probe],
+ );
+
+ return (
+
+ {state === "offline" && (
+
+ )}
+ {state === "connected" && !apiCompatible && (
+
+
+ This daemon speaks API v{compat?.apiMajor} — Studio supports v1.
+
+
+ Some features may not work; update Studio or the daemon.
+
+
+ )}
+ {children}
+
+ );
+}
+
+function OfflineBanner({
+ detail,
+ onRetry,
+}: {
+ detail: string;
+ onRetry: () => void;
+}) {
+ return (
+
+ Mecatl is unreachable.
+
+ {detail || "Run `task build`, then `task studio:dev` to start it."}
+
+
+ Retry
+
+
+ );
+}
+
+export function useRuntimeStatus(): RuntimeStatus {
+ const context = useContext(RuntimeStatusContext);
+ if (!context) {
+ throw new Error(
+ "useRuntimeStatus must be used inside RuntimeStatusProvider",
+ );
+ }
+ return context;
+}
diff --git a/studio/src/features/agent/storage-health-banner.tsx b/studio/src/features/agent/storage-health-banner.tsx
new file mode 100644
index 0000000000..cee0ecedd5
--- /dev/null
+++ b/studio/src/features/agent/storage-health-banner.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { TriangleAlert } from "lucide-react";
+import { useStorageHealth } from "./hooks/use-storage-health";
+
+/**
+ * The degraded-store banner (ADR 0226): when the daemon reports its session
+ * store unavailable, corrupt families, or a failed background job, chats can
+ * silently vanish from the sidebar — this says why. A healthy store (or a
+ * daemon without `capabilities.storage_health`) renders nothing.
+ *
+ * Mounted in the workspace shell alongside the runtime status banners;
+ * exported standalone so other surfaces can adopt it later.
+ */
+export function StorageHealthBanner() {
+ const { health, degraded } = useStorageHealth();
+ if (!degraded || health === null) return null;
+
+ const detail = !health.available
+ ? health.unavailableReason || "The session store cannot be read."
+ : health.corruptCount > 0
+ ? `${health.corruptCount} stored session${
+ health.corruptCount === 1 ? "" : "s"
+ } can no longer be loaded.`
+ : health.lastFailure;
+
+ return (
+
+
+
+ Session storage is degraded — some chats may be missing.
+
+ {detail && {detail} }
+
+ );
+}
diff --git a/studio/src/lib/feature-flags.ts b/studio/src/lib/feature-flags.ts
new file mode 100644
index 0000000000..5869d56482
--- /dev/null
+++ b/studio/src/lib/feature-flags.ts
@@ -0,0 +1,7 @@
+/**
+ * The app is Atrium-only: a single workspace console with no gateway or admin
+ * surfaces and no presentation-mode switching.
+ */
+
+/** Where auth and route fallbacks send the viewer (the workspace chats). */
+export const ATRIUM_WORKSPACE_HOME = "/workspace/chat";
diff --git a/studio/src/lib/protocol/index.ts b/studio/src/lib/protocol/index.ts
index f4af950948..75b5d68ee8 100644
--- a/studio/src/lib/protocol/index.ts
+++ b/studio/src/lib/protocol/index.ts
@@ -7,6 +7,7 @@ export {
decodeScheduleFires,
decodeScheduleRows,
encodeScheduleSpec,
+ PERMISSION_MODES,
type ScheduleCarriedSpec,
type ScheduleFireRow,
type ScheduleRow,
diff --git a/studio/src/lib/protocol/schedules.ts b/studio/src/lib/protocol/schedules.ts
index 1cfec8091e..682abade3e 100644
--- a/studio/src/lib/protocol/schedules.ts
+++ b/studio/src/lib/protocol/schedules.ts
@@ -134,7 +134,7 @@ export type ScheduleSpecDraft = {
oneShotMaxRetries: number;
};
-const PERMISSION_MODES: Record = {
+export const PERMISSION_MODES: Record = {
PERMISSION_MODE_UNSPECIFIED: 0,
PERMISSION_MODE_DEFAULT: 1,
PERMISSION_MODE_PLAN: 2,
diff --git a/studio/src/lib/shortcuts/registry.test.ts b/studio/src/lib/shortcuts/registry.test.ts
new file mode 100644
index 0000000000..73450cfa79
--- /dev/null
+++ b/studio/src/lib/shortcuts/registry.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from "vitest";
+import {
+ comboFiresWhileTyping,
+ keycaps,
+ matchCombo,
+ SHORTCUT_GROUPS,
+ SHORTCUTS,
+} from "./registry";
+
+/** Build a minimal KeyboardEvent-like object for matchCombo. */
+function ev(
+ key: string,
+ mods: Partial<{
+ meta: boolean;
+ ctrl: boolean;
+ shift: boolean;
+ alt: boolean;
+ }> = {},
+): KeyboardEvent {
+ return {
+ key,
+ metaKey: mods.meta ?? false,
+ ctrlKey: mods.ctrl ?? false,
+ shiftKey: mods.shift ?? false,
+ altKey: mods.alt ?? false,
+ } as KeyboardEvent;
+}
+
+describe("shortcut registry", () => {
+ it("has unique ids", () => {
+ const ids = SHORTCUTS.map((s) => s.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("every shortcut belongs to a known group", () => {
+ for (const s of SHORTCUTS) {
+ expect(SHORTCUT_GROUPS).toContain(
+ s.group as (typeof SHORTCUT_GROUPS)[number],
+ );
+ }
+ });
+
+ it("pins the app-wide bindings to their combos", () => {
+ const byId = new Map(SHORTCUTS.map((s) => [s.id, s.combo]));
+ expect(byId.get("search.open")).toBe("mod+k");
+ expect(byId.get("settings.open")).toBe("mod+,");
+ expect(byId.get("shortcuts.open")).toBe("?");
+ expect(byId.get("shortcuts.open.mod")).toBe("mod+/");
+ expect(byId.get("chat.toggleList")).toBe("mod+b");
+ expect(byId.get("close.esc")).toBe("esc");
+ // Deliberately NOT mod+n: browsers reserve ⌘N/Ctrl+N (new window) and the
+ // page can't intercept it, so "New chat" stays on the preventable ⌘⇧O.
+ expect(byId.get("chat.new")).toBe("mod+shift+o");
+ });
+});
+
+describe("keycaps", () => {
+ it("renders modifiers and keys", () => {
+ expect(keycaps("mod+k")).toEqual(["⌘", "K"]);
+ expect(keycaps("mod+shift+n")).toEqual(["⌘", "⇧", "N"]);
+ expect(keycaps("down")).toEqual(["↓"]);
+ expect(keycaps("?")).toEqual(["?"]);
+ expect(keycaps("shift+enter")).toEqual(["⇧", "Enter"]);
+ });
+});
+
+describe("matchCombo", () => {
+ it("matches modifier combos (⌘ or Ctrl)", () => {
+ expect(matchCombo("mod+k", ev("k", { meta: true }))).toBe(true);
+ expect(matchCombo("mod+k", ev("k", { ctrl: true }))).toBe(true);
+ expect(matchCombo("mod+k", ev("k"))).toBe(false); // no modifier
+ });
+
+ it("matches plain keys and arrow aliases", () => {
+ expect(matchCombo("j", ev("j"))).toBe(true);
+ expect(matchCombo("j", ev("J", { shift: true }))).toBe(true); // capital J
+ expect(matchCombo("down", ev("ArrowDown"))).toBe(true);
+ expect(matchCombo("up", ev("ArrowUp"))).toBe(true);
+ });
+
+ it("matches symbol keys without needing an explicit shift", () => {
+ expect(matchCombo("?", ev("?", { shift: true }))).toBe(true);
+ expect(matchCombo("/", ev("/"))).toBe(true);
+ });
+
+ it("rejects when a modifier is present but not wanted", () => {
+ expect(matchCombo("j", ev("j", { meta: true }))).toBe(false);
+ expect(matchCombo("down", ev("ArrowDown", { alt: true }))).toBe(false);
+ });
+
+ it("requires shift when the combo declares it", () => {
+ expect(
+ matchCombo("mod+shift+n", ev("n", { meta: true, shift: true })),
+ ).toBe(true);
+ expect(matchCombo("mod+shift+n", ev("n", { meta: true }))).toBe(false);
+ });
+
+ it("matches mod + punctuation combos", () => {
+ expect(matchCombo("mod+,", ev(",", { meta: true }))).toBe(true);
+ expect(matchCombo("mod+,", ev(",", { ctrl: true }))).toBe(true);
+ expect(matchCombo("mod+,", ev(","))).toBe(false);
+ expect(matchCombo("mod+/", ev("/", { meta: true }))).toBe(true);
+ expect(matchCombo("mod+/", ev("/"))).toBe(false);
+ });
+
+ it("matches esc via its alias", () => {
+ expect(matchCombo("esc", ev("Escape"))).toBe(true);
+ expect(matchCombo("esc", ev("Escape", { meta: true }))).toBe(false);
+ });
+});
+
+describe("comboFiresWhileTyping", () => {
+ it("allows mod combos and bare esc, suppresses plain keys", () => {
+ expect(comboFiresWhileTyping("mod+k")).toBe(true);
+ expect(comboFiresWhileTyping("mod+shift+o")).toBe(true);
+ expect(comboFiresWhileTyping("esc")).toBe(true);
+ expect(comboFiresWhileTyping("j")).toBe(false);
+ expect(comboFiresWhileTyping("?")).toBe(false);
+ expect(comboFiresWhileTyping("shift+enter")).toBe(false);
+ });
+});
diff --git a/studio/src/lib/shortcuts/registry.ts b/studio/src/lib/shortcuts/registry.ts
new file mode 100644
index 0000000000..af83340b43
--- /dev/null
+++ b/studio/src/lib/shortcuts/registry.ts
@@ -0,0 +1,178 @@
+/**
+ * The single source of truth for keyboard shortcuts. The dispatcher matches
+ * live key events against these combos, and the docs page renders its keycaps
+ * from the same list — so the documentation can't drift from the behaviour.
+ *
+ * A shortcut only fires if a component has registered a handler for its `id`
+ * (see `useShortcut`); entries without a handler are documentation-only (their
+ * behaviour lives inside a component, e.g. Enter to send in the composer).
+ *
+ * Combos are `+`-joined tokens: `mod` (⌘ on macOS / Ctrl elsewhere), `shift`,
+ * `alt`, then a key (`k`, `up`, `enter`, `?`, `/`, `,`, `@`, `esc`). `keycaps()`
+ * derives the display and `matchCombo()` matches an event — one grammar, no
+ * duplicated key lists.
+ */
+
+export interface ShortcutDef {
+ readonly id: string;
+ readonly combo: string;
+ readonly description: string;
+ readonly group: string;
+}
+
+export const SHORTCUTS: readonly ShortcutDef[] = [
+ // General
+ {
+ id: "search.open",
+ combo: "mod+k",
+ description: "Open search",
+ group: "General",
+ },
+ {
+ id: "settings.open",
+ combo: "mod+,",
+ description: "Open settings",
+ group: "General",
+ },
+ {
+ id: "shortcuts.open",
+ combo: "?",
+ description: "Show keyboard shortcuts",
+ group: "General",
+ },
+ {
+ id: "shortcuts.open.mod",
+ combo: "mod+/",
+ description: "Show keyboard shortcuts (also while typing)",
+ group: "General",
+ },
+ {
+ id: "chat.toggleList",
+ combo: "mod+b",
+ description: "Toggle the chat list",
+ group: "General",
+ },
+ // Esc is layered: an open dialog/menu handles its own Escape first (Radix
+ // and the composer's autocomplete both consume the event, so the dispatcher
+ // never sees it); this binding is the fallback beneath them.
+ {
+ id: "close.esc",
+ combo: "esc",
+ description: "Close the side panel — or stop the running turn",
+ group: "General",
+ },
+
+ // Chats
+ // NB: avoid browser-reserved combos. ⌘N / ⌘⇧N open a new (incognito) window
+ // and can't be intercepted, so "New chat" uses ⌘⇧O (preventable) — the same
+ // shortcut other web chat apps use.
+ {
+ id: "chat.new",
+ combo: "mod+shift+o",
+ description: "New chat",
+ group: "Chats",
+ },
+ {
+ id: "chat.prev",
+ combo: "up",
+ description: "Previous chat",
+ group: "Chats",
+ },
+ { id: "chat.next", combo: "down", description: "Next chat", group: "Chats" },
+ {
+ id: "chat.next.vim",
+ combo: "j",
+ description: "Next chat (vim-style)",
+ group: "Chats",
+ },
+ {
+ id: "chat.prev.vim",
+ combo: "k",
+ description: "Previous chat (vim-style)",
+ group: "Chats",
+ },
+
+ // Composer (behaviour lives in the composer; documentation-only here)
+ {
+ id: "composer.send",
+ combo: "enter",
+ description:
+ "Send — while the agent is replying: queue or steer, per Settings → Personalize",
+ group: "Composer",
+ },
+ {
+ id: "composer.newline",
+ combo: "shift+enter",
+ description:
+ "Insert a new line — while the agent is replying: the opposite of your Enter preference",
+ group: "Composer",
+ },
+ {
+ id: "composer.slash",
+ combo: "/",
+ description: "Slash commands and skills",
+ group: "Composer",
+ },
+ {
+ id: "composer.mention",
+ combo: "@",
+ description: "Mention an agent",
+ group: "Composer",
+ },
+] as const;
+
+/** Groups in render order. */
+export const SHORTCUT_GROUPS = ["General", "Chats", "Composer"] as const;
+
+const CAP_LABEL: Record = {
+ mod: "⌘",
+ shift: "⇧",
+ alt: "⌥",
+ up: "↑",
+ down: "↓",
+ left: "←",
+ right: "→",
+ enter: "Enter",
+ esc: "Esc",
+};
+
+/** Display keycaps for a combo, e.g. "mod+k" → ["⌘", "K"]. */
+export function keycaps(combo: string): string[] {
+ return combo
+ .split("+")
+ .map((p) => CAP_LABEL[p] ?? (p.length === 1 ? p.toUpperCase() : p));
+}
+
+const KEY_ALIAS: Record = {
+ up: "arrowup",
+ down: "arrowdown",
+ left: "arrowleft",
+ right: "arrowright",
+ esc: "escape",
+};
+
+/** True when a live key event matches a combo. `mod` = ⌘ or Ctrl. */
+export function matchCombo(combo: string, e: KeyboardEvent): boolean {
+ const parts = combo.split("+");
+ const key = parts[parts.length - 1];
+ const wantMod = parts.includes("mod");
+ const wantShift = parts.includes("shift");
+ const wantAlt = parts.includes("alt");
+ const hasMod = e.metaKey || e.ctrlKey;
+ if (wantMod !== hasMod) return false;
+ if (wantAlt !== e.altKey) return false;
+ // Only enforce shift when the combo asks for it — symbol keys like "?" carry
+ // their own implicit shift in `e.key`.
+ if (wantShift && !e.shiftKey) return false;
+ return e.key.toLowerCase() === (KEY_ALIAS[key] ?? key);
+}
+
+/**
+ * True when the combo may fire while the user is typing in an editable field:
+ * combos carrying `mod` (the standard desktop-app rule — ⌘/Ctrl chords are
+ * commands, not text), plus bare `esc` (it never inserts text, and Esc must
+ * interrupt a streaming run even while the caret sits in the composer).
+ */
+export function comboFiresWhileTyping(combo: string): boolean {
+ return combo.split("+").includes("mod") || combo === "esc";
+}
diff --git a/studio/src/lib/shortcuts/use-shortcuts.tsx b/studio/src/lib/shortcuts/use-shortcuts.tsx
new file mode 100644
index 0000000000..23a386be8c
--- /dev/null
+++ b/studio/src/lib/shortcuts/use-shortcuts.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { createContext, useContext, useEffect, useRef } from "react";
+import { comboFiresWhileTyping, matchCombo, SHORTCUTS } from "./registry";
+
+type Registry = {
+ register: (id: string, handler: () => void) => void;
+ unregister: (id: string) => void;
+};
+
+const ShortcutContext = createContext(null);
+
+function isTyping(el: Element | null): boolean {
+ const node = el as HTMLElement | null;
+ return (
+ !!node &&
+ (node.tagName === "INPUT" ||
+ node.tagName === "TEXTAREA" ||
+ node.isContentEditable)
+ );
+}
+
+/**
+ * Owns the single global keydown listener. Resolves each event against the
+ * shortcut registry and calls the handler a component registered for that id.
+ * Non-modifier shortcuts (except Esc) are suppressed while the user is typing,
+ * and an event something closer to the key already consumed — a Radix
+ * dialog/menu dismissing on Escape, the composer's autocomplete menu — is
+ * skipped via `defaultPrevented`, so those layers always win over globals.
+ */
+export function ShortcutsProvider({ children }: { children: React.ReactNode }) {
+ const handlers = useRef(new Map void>());
+
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.defaultPrevented || e.isComposing) return;
+ const typing = isTyping(document.activeElement);
+ for (const def of SHORTCUTS) {
+ const handler = handlers.current.get(def.id);
+ if (!handler) continue;
+ if (typing && !comboFiresWhileTyping(def.combo)) continue;
+ if (matchCombo(def.combo, e)) {
+ e.preventDefault();
+ handler();
+ return;
+ }
+ }
+ };
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, []);
+
+ const value = useRef({
+ register: (id, handler) => handlers.current.set(id, handler),
+ unregister: (id) => handlers.current.delete(id),
+ }).current;
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Register a handler for a shortcut id. The latest handler is always used (no
+ * re-registration churn), and it's removed on unmount.
+ */
+export function useShortcut(id: string, handler: () => void) {
+ const ctx = useContext(ShortcutContext);
+ const ref = useRef(handler);
+ ref.current = handler;
+ useEffect(() => {
+ if (!ctx) return;
+ const stable = () => ref.current();
+ ctx.register(id, stable);
+ return () => ctx.unregister(id);
+ }, [id, ctx]);
+}
diff --git a/studio/src/lib/thread-map.test.ts b/studio/src/lib/thread-map.test.ts
new file mode 100644
index 0000000000..d6f5a42ae3
--- /dev/null
+++ b/studio/src/lib/thread-map.test.ts
@@ -0,0 +1,300 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { AgentMessage } from "@/features/agent";
+import {
+ composeThreadPrompt,
+ getThreadSession,
+ isThreadSession,
+ readThreadMap,
+ registerThreadSession,
+ sliceThreadReplies,
+ stripRootQuote,
+ syncThreadActivity,
+ threadKeyForMessage,
+ threadTitleFromRoot,
+} from "./thread-map";
+
+const msg = (
+ role: AgentMessage["role"],
+ content: string,
+ timestamp = 0,
+): AgentMessage => ({
+ id: `id-${role}-${content.length}`,
+ role,
+ content,
+ timestamp,
+});
+
+describe("threadKeyForMessage", () => {
+ it("is stable for the same role and content (live vs rehydrated ids differ)", () => {
+ const live = { ...msg("assistant", "hello world"), id: "assistant-123" };
+ const rehydrated = {
+ ...msg("assistant", "hello world"),
+ id: "history-assistant-4",
+ };
+ expect(threadKeyForMessage(live)).toBe(threadKeyForMessage(rehydrated));
+ });
+
+ it("distinguishes role and content", () => {
+ expect(threadKeyForMessage(msg("user", "same text"))).not.toBe(
+ threadKeyForMessage(msg("assistant", "same text")),
+ );
+ expect(threadKeyForMessage(msg("user", "one"))).not.toBe(
+ threadKeyForMessage(msg("user", "two")),
+ );
+ });
+});
+
+describe("threadTitleFromRoot", () => {
+ it("prefixes and keeps a short root verbatim", () => {
+ expect(threadTitleFromRoot("Fix the login bug")).toBe(
+ "Thread: Fix the login bug",
+ );
+ });
+
+ it("collapses newlines and runs of whitespace into single spaces", () => {
+ expect(threadTitleFromRoot("line one\nline two\n\nthree")).toBe(
+ "Thread: line one line two three",
+ );
+ });
+
+ it("clamps the snippet to ~40 chars with an ellipsis", () => {
+ const title = threadTitleFromRoot("a".repeat(100));
+ expect(title).toBe(`Thread: ${"a".repeat(40)}…`);
+ });
+
+ it("names an empty root honestly", () => {
+ expect(threadTitleFromRoot(" ")).toBe("Thread: (empty message)");
+ });
+});
+
+describe("composeThreadPrompt", () => {
+ it("quotes a single-line root above the user's text", () => {
+ expect(composeThreadPrompt("the root", "my reply")).toBe(
+ "> the root\n\nmy reply",
+ );
+ });
+
+ it("carries newlines as '> ' continuation lines (blank lines as '>')", () => {
+ expect(composeThreadPrompt("first\n\nsecond", "ok")).toBe(
+ "> first\n>\n> second\n\nok",
+ );
+ });
+
+ it("clamps the quoted root at ~500 chars", () => {
+ const prompt = composeThreadPrompt("x".repeat(600), "reply");
+ expect(prompt).toBe(`> ${"x".repeat(500)}…\n\nreply`);
+ });
+
+ it("keeps the user's text verbatim after the blank line", () => {
+ const prompt = composeThreadPrompt("root", "multi\nline reply");
+ expect(prompt.endsWith("\n\nmulti\nline reply")).toBe(true);
+ });
+});
+
+describe("sliceThreadReplies", () => {
+ const root = "the root message\nwith two lines";
+ const quoted = composeThreadPrompt(root, "first thread reply");
+
+ it("returns nothing when the transcript is only seeded parent history", () => {
+ const history = [
+ msg("user", "parent question"),
+ msg("assistant", "parent answer"),
+ ];
+ expect(sliceThreadReplies(history, root)).toEqual([]);
+ });
+
+ it("returns messages from the quoted first reply onward", () => {
+ const messages = [
+ msg("user", "parent question"),
+ msg("assistant", "parent answer"),
+ msg("user", quoted),
+ msg("assistant", "thread answer"),
+ ];
+ const replies = sliceThreadReplies(messages, root);
+ expect(replies).toHaveLength(2);
+ expect(replies[0].content).toBe(quoted);
+ expect(replies[1].content).toBe("thread answer");
+ });
+
+ it("ignores an assistant message that merely contains the quote block", () => {
+ const messages = [msg("assistant", quoted)];
+ expect(sliceThreadReplies(messages, root)).toEqual([]);
+ });
+});
+
+/** This vitest environment ships a method-less localStorage shim (Node's
+ * --localstorage-file stub shadows jsdom's), so storage tests stub a real
+ * in-memory Storage; the global afterEach unstubs it. */
+function memoryStorage(): Storage {
+ let store = new Map();
+ return {
+ get length() {
+ return store.size;
+ },
+ clear: () => {
+ store = new Map();
+ },
+ getItem: (key: string) => store.get(key) ?? null,
+ key: (index: number) => [...store.keys()][index] ?? null,
+ removeItem: (key: string) => {
+ store.delete(key);
+ },
+ setItem: (key: string, value: string) => {
+ store.set(key, String(value));
+ },
+ };
+}
+
+describe("thread map storage", () => {
+ beforeEach(() => {
+ vi.stubGlobal("localStorage", memoryStorage());
+ });
+
+ it("returns null / empty for an unknown parent session", () => {
+ expect(getThreadSession("parent-1", "key-1")).toBeNull();
+ expect(readThreadMap("parent-1")).toEqual({});
+ });
+
+ it("registers a thread session and reads it back with zeroed activity", () => {
+ registerThreadSession("parent-1", "key-1", "thread-abc");
+ expect(getThreadSession("parent-1", "key-1")).toBe("thread-abc");
+ expect(readThreadMap("parent-1")["key-1"]).toEqual({
+ sessionId: "thread-abc",
+ replyCount: 0,
+ lastReplyAt: 0,
+ });
+ });
+
+ it("keeps parent sessions isolated from each other", () => {
+ registerThreadSession("parent-1", "key-1", "thread-abc");
+ expect(getThreadSession("parent-2", "key-1")).toBeNull();
+ });
+
+ it("syncThreadActivity sets the count and advances lastReplyAt", () => {
+ registerThreadSession("parent-1", "key-1", "thread-abc");
+ syncThreadActivity("parent-1", "key-1", 3, 1_000);
+ expect(readThreadMap("parent-1")["key-1"]).toEqual({
+ sessionId: "thread-abc",
+ replyCount: 3,
+ lastReplyAt: 1_000,
+ });
+ });
+
+ it("lastReplyAt only advances — a rehydrated 0 never erases a real time", () => {
+ registerThreadSession("parent-1", "key-1", "thread-abc");
+ syncThreadActivity("parent-1", "key-1", 2, 5_000);
+ syncThreadActivity("parent-1", "key-1", 2, 0);
+ expect(readThreadMap("parent-1")["key-1"].lastReplyAt).toBe(5_000);
+ // The count still follows the authoritative reply list downward or upward.
+ syncThreadActivity("parent-1", "key-1", 4, 1_000);
+ expect(readThreadMap("parent-1")["key-1"]).toEqual({
+ sessionId: "thread-abc",
+ replyCount: 4,
+ lastReplyAt: 5_000,
+ });
+ });
+
+ it("syncThreadActivity is a no-op for a key that was never registered", () => {
+ syncThreadActivity("parent-1", "ghost", 3, 1_000);
+ expect(readThreadMap("parent-1")).toEqual({});
+ });
+
+ it("tolerates garbage in localStorage", () => {
+ window.localStorage.setItem("mecatl-studio.threads.parent-1", "{not json");
+ expect(readThreadMap("parent-1")).toEqual({});
+ window.localStorage.setItem(
+ "mecatl-studio.threads.parent-2",
+ JSON.stringify({
+ good: { sessionId: "s", replyCount: 1, lastReplyAt: 2 },
+ bad: { replyCount: 9 },
+ }),
+ );
+ expect(readThreadMap("parent-2")).toEqual({
+ good: { sessionId: "s", replyCount: 1, lastReplyAt: 2 },
+ });
+ });
+});
+
+describe("thread session registry", () => {
+ beforeEach(() => {
+ vi.stubGlobal("localStorage", memoryStorage());
+ });
+
+ it("knows nothing before any thread was registered", () => {
+ expect(isThreadSession("session-1")).toBe(false);
+ });
+
+ it("registers thread session ids across parents into one flat set", () => {
+ registerThreadSession("parent-1", "key-1", "thread-a");
+ registerThreadSession("parent-2", "key-9", "thread-b");
+ expect(isThreadSession("thread-a")).toBe(true);
+ expect(isThreadSession("thread-b")).toBe(true);
+ expect(isThreadSession("parent-1")).toBe(false);
+ expect(isThreadSession("some-ordinary-chat")).toBe(false);
+ });
+
+ it("registering the same thread twice keeps one entry", () => {
+ registerThreadSession("parent-1", "key-1", "thread-a");
+ registerThreadSession("parent-1", "key-2", "thread-a");
+ expect(
+ JSON.parse(
+ window.localStorage.getItem("mecatl-studio.thread-sessions") ?? "[]",
+ ),
+ ).toEqual(["thread-a"]);
+ });
+
+ it("membership, not the 'Thread: ' title, decides — a user's own chat named that way is not a thread", () => {
+ // Nothing registered for this id: even a session titled "Thread: …"
+ // must answer false, so the sidebar never hides a user's own chat.
+ expect(isThreadSession("chat-the-user-titled-thread")).toBe(false);
+ });
+
+ it("also recognizes legacy threads recorded only in a per-parent map", () => {
+ // A thread minted before the flat registry existed: present in the
+ // parent's map, absent from the registry key.
+ window.localStorage.setItem(
+ "mecatl-studio.threads.parent-legacy",
+ JSON.stringify({
+ "key-1": { sessionId: "thread-legacy", replyCount: 2, lastReplyAt: 5 },
+ }),
+ );
+ expect(isThreadSession("thread-legacy")).toBe(true);
+ });
+
+ it("tolerates garbage in the registry key", () => {
+ window.localStorage.setItem("mecatl-studio.thread-sessions", "{not json");
+ expect(isThreadSession("thread-a")).toBe(false);
+ window.localStorage.setItem(
+ "mecatl-studio.thread-sessions",
+ JSON.stringify(["ok", 7, "", null]),
+ );
+ expect(isThreadSession("ok")).toBe(true);
+ expect(isThreadSession("")).toBe(false);
+ // A registration on top of the garbage entries keeps only valid ids.
+ registerThreadSession("parent-1", "key-1", "thread-new");
+ expect(
+ JSON.parse(
+ window.localStorage.getItem("mecatl-studio.thread-sessions") ?? "[]",
+ ),
+ ).toEqual(["ok", "thread-new"]);
+ });
+});
+
+describe("stripRootQuote", () => {
+ it("removes the root's own quote from a reply, keeping the words", () => {
+ const root = "the original message";
+ const composed = composeThreadPrompt(root, "my question");
+ const [stripped] = stripRootQuote(
+ [{ role: "user", content: composed }],
+ root,
+ );
+ expect(stripped.content).toBe("my question");
+ });
+ it("keeps quotes of other text and non-user replies", () => {
+ const replies = [
+ { role: "user", content: "> some other selection\n\nthoughts?" },
+ { role: "assistant", content: "> not touched" },
+ ];
+ expect(stripRootQuote(replies, "the original message")).toEqual(replies);
+ });
+});
diff --git a/studio/src/lib/thread-map.ts b/studio/src/lib/thread-map.ts
new file mode 100644
index 0000000000..d2bfe96f27
--- /dev/null
+++ b/studio/src/lib/thread-map.ts
@@ -0,0 +1,387 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { AgentMessage } from "@/features/agent";
+
+/**
+ * Browser-local map of message threads. The daemon has no thread concept — a
+ * thread is an ordinary session seeded from the parent conversation
+ * (source_session_id carryover) — so which daemon session backs the thread
+ * branched off a given message is a client-side view association, stored in
+ * localStorage per PARENT session (same tier as the profile preferences).
+ * Each entry also carries enough activity summary (reply count, last reply
+ * time) to draw the Slack-style reply indicator without opening the thread.
+ */
+
+interface ThreadSummary {
+ /** The daemon session backing the thread. */
+ sessionId: string;
+ replyCount: number;
+ /** Epoch ms of the newest reply; 0 until a timestamped one lands. */
+ lastReplyAt: number;
+}
+
+/** Message key → thread summary, for one parent session. */
+export type ThreadMap = Record;
+
+const KEY_PREFIX = "mecatl-studio.threads.";
+/**
+ * Flat registry of EVERY thread-backing session id, across all parents. The
+ * per-parent maps above answer "which session backs this message's thread";
+ * this set answers the sessions layer's cheaper question — "is this session
+ * a thread at all?" — so the chat list, keyboard order, and global search can
+ * hide thread sessions without walking every parent's map.
+ */
+const SESSIONS_KEY = "mecatl-studio.thread-sessions";
+const THREADS_CHANGED_EVENT = "mecatl-studio:threads-changed";
+
+const storageKey = (parentSessionId: string) =>
+ `${KEY_PREFIX}${parentSessionId}`;
+
+// ── Pure helpers ─────────────────────────────────────────────────────────────
+
+/**
+ * Stable key for a transcript message. Live-streamed messages and rehydrated
+ * transcript messages get DIFFERENT ids (timestamp-minted vs positional), so
+ * a thread keyed on the raw message id would detach from its message on
+ * reload; role plus a content hash survives both renderings. Two byte-equal
+ * messages in one chat share a thread — an accepted, unlikely collision.
+ */
+export function threadKeyForMessage(
+ message: Pick,
+): string {
+ // djb2 over UTF-16 code units, kept in uint32.
+ let hash = 5381;
+ for (let i = 0; i < message.content.length; i += 1) {
+ hash = ((hash << 5) + hash + message.content.charCodeAt(i)) >>> 0;
+ }
+ return `${message.role}:${hash.toString(36)}:${message.content.length}`;
+}
+
+const TITLE_SNIPPET_MAX = 40;
+
+/** Sidebar title for a thread session: "Thread: " + a single-line root snippet. */
+export function threadTitleFromRoot(rootContent: string): string {
+ const line = rootContent.replace(/\s+/g, " ").trim();
+ const snippet =
+ line.length > TITLE_SNIPPET_MAX
+ ? `${line.slice(0, TITLE_SNIPPET_MAX).trimEnd()}…`
+ : line;
+ return `Thread: ${snippet || "(empty message)"}`;
+}
+
+const QUOTE_CLAMP = 500;
+
+/**
+ * The root message as a "> " quote block: clamped so a huge root cannot
+ * balloon the thread prompt, newlines carried as "> " continuation lines.
+ */
+function quoteRoot(rootContent: string): string {
+ const clamped =
+ rootContent.length > QUOTE_CLAMP
+ ? `${rootContent.slice(0, QUOTE_CLAMP)}…`
+ : rootContent;
+ return clamped
+ .split("\n")
+ .map((line) => (line ? `> ${line}` : ">"))
+ .join("\n");
+}
+
+/**
+ * The FIRST message sent into a thread: the quoted root message (so the model
+ * sees exactly what the thread is scoped to — rendered honestly as part of
+ * the message, not injected invisibly) followed by the user's own text.
+ */
+export function composeThreadPrompt(
+ rootContent: string,
+ userText: string,
+): string {
+ return `${quoteRoot(rootContent)}\n\n${userText}`;
+}
+
+/**
+ * A thread session's transcript starts with the parent history it was seeded
+ * from; the thread's own exchange begins at the first user message that opens
+ * with the root quote block (the deterministic composeThreadPrompt prefix).
+ * Returns the messages from that boundary — empty when no reply landed yet.
+ */
+/**
+ * Display transform for thread replies: the root-quote block
+ * composeThreadPrompt prepends is REDUNDANT inside the panel (the root
+ * message is pinned right above), so a user reply that opens with the
+ * root's own quote renders without it. Quotes of OTHER text (add-to-thread
+ * selections) are meaningful and stay.
+ */
+export function stripRootQuote(
+ replies: T[],
+ rootContent: string,
+): T[] {
+ const rootQuote = quoteRoot(rootContent);
+ return replies.map((reply) => {
+ if (reply.role !== "user") return reply;
+ if (!reply.content.startsWith(rootQuote)) return reply;
+ const rest = reply.content.slice(rootQuote.length).replace(/^\n+/, "");
+ return rest ? { ...reply, content: rest } : reply;
+ });
+}
+
+export function sliceThreadReplies(
+ messages: AgentMessage[],
+ rootContent: string,
+): AgentMessage[] {
+ const boundary = `${quoteRoot(rootContent)}\n\n`;
+ const index = messages.findIndex(
+ (message) =>
+ message.role === "user" && message.content.startsWith(boundary),
+ );
+ return index === -1 ? [] : messages.slice(index);
+}
+
+// ── Storage ──────────────────────────────────────────────────────────────────
+
+function readStorage(key: string): string | null {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+}
+
+function writeStorage(key: string, value: string) {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(key, value);
+ } catch {
+ // Storage disabled or full — the thread association just doesn't persist.
+ }
+}
+
+/** Reads (and defensively re-validates) one parent session's thread map. */
+export function readThreadMap(parentSessionId: string): ThreadMap {
+ const raw = readStorage(storageKey(parentSessionId));
+ if (!raw) return {};
+ try {
+ const parsed = JSON.parse(raw) as unknown;
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return {};
+ }
+ const map: ThreadMap = {};
+ for (const [key, value] of Object.entries(
+ parsed as Record,
+ )) {
+ const entry = value as Partial | null;
+ if (!entry || typeof entry.sessionId !== "string" || !entry.sessionId) {
+ continue;
+ }
+ map[key] = {
+ sessionId: entry.sessionId,
+ replyCount:
+ typeof entry.replyCount === "number" && entry.replyCount > 0
+ ? Math.floor(entry.replyCount)
+ : 0,
+ lastReplyAt:
+ typeof entry.lastReplyAt === "number" && entry.lastReplyAt > 0
+ ? entry.lastReplyAt
+ : 0,
+ };
+ }
+ return map;
+ } catch {
+ return {};
+ }
+}
+
+function writeThreadMap(parentSessionId: string, map: ThreadMap) {
+ writeStorage(storageKey(parentSessionId), JSON.stringify(map));
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(
+ new CustomEvent(THREADS_CHANGED_EVENT, {
+ detail: parentSessionId,
+ }),
+ );
+ }
+}
+
+/** The daemon session backing this message's thread, if one was ever minted. */
+export function getThreadSession(
+ parentSessionId: string,
+ messageKey: string,
+): string | null {
+ return readThreadMap(parentSessionId)[messageKey]?.sessionId ?? null;
+}
+
+/** Reads (and defensively re-validates) the flat thread-session-id registry. */
+function readThreadSessionIds(): Set {
+ const ids = new Set();
+ const raw = readStorage(SESSIONS_KEY);
+ if (raw) {
+ try {
+ const parsed = JSON.parse(raw) as unknown;
+ if (Array.isArray(parsed)) {
+ for (const id of parsed) {
+ if (typeof id === "string" && id !== "") ids.add(id);
+ }
+ }
+ } catch {
+ // Corrupt registry: fall through to the per-parent sweep below.
+ }
+ }
+ // Threads minted before the flat registry existed are recorded only in the
+ // per-parent maps; sweep those keys too so they hide without a migration.
+ if (typeof window !== "undefined") {
+ try {
+ for (let i = 0; i < window.localStorage.length; i += 1) {
+ const key = window.localStorage.key(i);
+ if (!key?.startsWith(KEY_PREFIX)) continue;
+ const map = readThreadMap(key.slice(KEY_PREFIX.length));
+ for (const entry of Object.values(map)) ids.add(entry.sessionId);
+ }
+ } catch {
+ // Storage disabled: whatever the registry read yielded stands.
+ }
+ }
+ return ids;
+}
+
+/**
+ * True when this session id was minted to back a message thread. Membership
+ * in the registry — never the "Thread: " title — is the test, so a chat the
+ * user happened to name "Thread: …" themselves is never hidden.
+ */
+export function isThreadSession(sessionId: string): boolean {
+ return readThreadSessionIds().has(sessionId);
+}
+
+/** Records a freshly minted thread session for a root message. */
+export function registerThreadSession(
+ parentSessionId: string,
+ messageKey: string,
+ threadSessionId: string,
+) {
+ const map = readThreadMap(parentSessionId);
+ map[messageKey] = {
+ sessionId: threadSessionId,
+ replyCount: 0,
+ lastReplyAt: 0,
+ };
+ // Mirror the id into the flat registry BEFORE the map write: the map write
+ // fires the change event listeners, which must already see the new id.
+ const ids = readThreadSessionIds();
+ if (!ids.has(threadSessionId)) {
+ ids.add(threadSessionId);
+ writeStorage(SESSIONS_KEY, JSON.stringify([...ids]));
+ }
+ writeThreadMap(parentSessionId, map);
+}
+
+/**
+ * Detaches a thread from its root message — the "convert to a full chat"
+ * path: the id leaves the flat registry (so the sidebar shows it) and the
+ * per-parent record goes (so the reply indicator does too). The session
+ * itself is untouched; it was always a real daemon session.
+ */
+function unregisterThreadSession(
+ parentSessionId: string,
+ threadSessionId: string,
+) {
+ const ids = readThreadSessionIds();
+ if (ids.delete(threadSessionId)) {
+ writeStorage(SESSIONS_KEY, JSON.stringify([...ids]));
+ }
+ const map = readThreadMap(parentSessionId);
+ let changed = false;
+ for (const [key, record] of Object.entries(map)) {
+ if (record.sessionId === threadSessionId) {
+ delete map[key];
+ changed = true;
+ }
+ }
+ if (changed || ids.size >= 0) writeThreadMap(parentSessionId, map);
+}
+
+/**
+ * Mirrors the thread's observed activity into the map. Set semantics for the
+ * count — the caller derives it from the authoritative reply list, so it
+ * self-heals on rehydration — while lastReplyAt only ever advances, because
+ * rehydrated transcripts carry no timestamps and a 0 must not erase a real
+ * one. A no-op for unknown keys and for writes that would change nothing.
+ */
+export function syncThreadActivity(
+ parentSessionId: string,
+ messageKey: string,
+ replyCount: number,
+ lastReplyAt: number,
+) {
+ const map = readThreadMap(parentSessionId);
+ const entry = map[messageKey];
+ if (!entry) return;
+ const nextLast = Math.max(entry.lastReplyAt, lastReplyAt);
+ if (entry.replyCount === replyCount && entry.lastReplyAt === nextLast) {
+ return;
+ }
+ map[messageKey] = { ...entry, replyCount, lastReplyAt: nextLast };
+ writeThreadMap(parentSessionId, map);
+}
+
+/**
+ * Live view of one parent session's thread map, for the transcript's reply
+ * indicators. Hydrates after mount (the server renders none) and follows
+ * writes from this tab (the change event) and other tabs (the storage event).
+ */
+function useThreadMap(parentSessionId: string): ThreadMap {
+ const [map, setMap] = useState({});
+ useEffect(() => {
+ if (!parentSessionId) {
+ setMap({});
+ return;
+ }
+ const refresh = () => setMap(readThreadMap(parentSessionId));
+ refresh();
+ const onChanged = (event: Event) => {
+ if ((event as CustomEvent).detail === parentSessionId) refresh();
+ };
+ const onStorage = (event: StorageEvent) => {
+ if (event.key === null || event.key === storageKey(parentSessionId)) {
+ refresh();
+ }
+ };
+ window.addEventListener(THREADS_CHANGED_EVENT, onChanged);
+ window.addEventListener("storage", onStorage);
+ return () => {
+ window.removeEventListener(THREADS_CHANGED_EVENT, onChanged);
+ window.removeEventListener("storage", onStorage);
+ };
+ }, [parentSessionId]);
+ return map;
+}
+
+/**
+ * Live view of the flat thread-session-id registry, for the sessions layer's
+ * presentation seam (hide thread sessions from the chat list and search).
+ * Hydrates after mount (SSR renders an empty set) and follows registrations
+ * from this tab (the change event) and other tabs (the storage event).
+ */
+export function useThreadSessionIds(): ReadonlySet {
+ const [ids, setIds] = useState>(new Set());
+ useEffect(() => {
+ const refresh = () => setIds(readThreadSessionIds());
+ refresh();
+ const onStorage = (event: StorageEvent) => {
+ if (
+ event.key === null ||
+ event.key === SESSIONS_KEY ||
+ event.key.startsWith(KEY_PREFIX) // legacy per-parent-map threads
+ ) {
+ refresh();
+ }
+ };
+ window.addEventListener(THREADS_CHANGED_EVENT, refresh);
+ window.addEventListener("storage", onStorage);
+ return () => {
+ window.removeEventListener(THREADS_CHANGED_EVENT, refresh);
+ window.removeEventListener("storage", onStorage);
+ };
+ }, []);
+ return ids;
+}