From 0717f50895c2cee8e9a152de7c323b42ff65d3b1 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 24 Jul 2026 00:56:32 -0500 Subject: [PATCH] Consolidate familiar studio to five tabs: merge Look + lifecycle into Identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Familiars had eight studio tabs; three carried overlapping or relocated concerns. The strip is now Identity | Brain | Memory | Projects | Vault: - Identity absorbs the Look sections (avatar, icon, backdrop, accent) by hosting FamiliarStudioLookTab inline, followed by a new lifecycle section. - New familiar-lifecycle-section.tsx keeps ONLY archive/unarchive and the undo-safe remove flow (confirm copy, UndoToast, Recently removed shelf, tombstone-backed restore) for the selected familiar. Roster reorder, reset-all-overrides, and the Open Appearance link retired with the tab. - Journal studio tab removed — the journal lives in Grimoire (Memories → Journal); journal-entries.tsx drops its now-orphaned `standalone` fork. - Context: FamiliarStudioTab union/STUDIO_TABS shrink; listView state removed; openFamiliarStudioListView now just opens Settings → Familiars (stale persisted tab values fall back to Identity via the existing guard). - Deep links retargeted: Familiars-view "Remove familiar…" → identity tab; settings search rows for Look/Lifecycle/Journal folded into Identity. - Tests renamed/retargeted to pin the new intent (journal-redirect.test.ts, familiar-lifecycle-section.test.ts); route-seam pins kept verbatim. Verified: app suite 891 files green, typecheck, pnpm lint, design-token ratchets, check-tests-wired. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/run-tests.mjs | 4 +- src/components/delete-undo-surfaces.test.ts | 10 +- ....ts => familiar-lifecycle-section.test.ts} | 59 +- src/components/familiar-lifecycle-section.tsx | 285 +++++++++ .../familiar-studio-identity-tab.tsx | 18 +- src/components/familiar-studio-inline.test.ts | 11 +- src/components/familiar-studio-inline.tsx | 21 +- .../familiar-studio-journal-tab.tsx | 33 -- .../familiar-studio-lifecycle-tab.tsx | 543 ------------------ src/components/familiar-switcher.test.ts | 2 +- src/components/familiars-view-sections.tsx | 6 +- src/components/familiars-view.test.ts | 10 +- ...l-tab.test.ts => journal-redirect.test.ts} | 81 +-- src/components/journal/journal-entries.tsx | 30 +- .../settings-action-buttons.test.ts | 3 +- src/components/settings-search.test.ts | 10 +- src/components/settings-sections.ts | 5 +- src/components/workspace.tsx | 3 +- src/lib/familiar-studio-context.tsx | 24 +- src/lib/slash-commands.ts | 2 +- src/lib/surface-warmup-registry.test.ts | 2 +- src/styles/globals/shell-responsive.css | 14 +- src/styles/journal.css | 7 - 23 files changed, 410 insertions(+), 773 deletions(-) rename src/components/{familiar-studio-lifecycle-tab.test.ts => familiar-lifecycle-section.test.ts} (68%) create mode 100644 src/components/familiar-lifecycle-section.tsx delete mode 100644 src/components/familiar-studio-journal-tab.tsx delete mode 100644 src/components/familiar-studio-lifecycle-tab.tsx rename src/components/{familiar-studio-journal-tab.test.ts => journal-redirect.test.ts} (61%) diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index b5545720e..35e2dc43c 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -644,9 +644,9 @@ export const SUITES = { "src/components/familiar-glyph-picker-panel.test.ts", "src/components/familiar-glyph-loading.test.ts", "src/components/familiar-studio-brain-tab.test.ts", - "src/components/familiar-studio-journal-tab.test.ts", + "src/components/journal-redirect.test.ts", "src/components/familiar-studio-identity-tab.test.ts", - "src/components/familiar-studio-lifecycle-tab.test.ts", + "src/components/familiar-lifecycle-section.test.ts", "src/components/github-task-field.test.ts", "src/components/asana-task-field.test.ts", "src/lib/task-asana.test.ts", diff --git a/src/components/delete-undo-surfaces.test.ts b/src/components/delete-undo-surfaces.test.ts index 89a1c9bd6..e6afdb2d0 100644 --- a/src/components/delete-undo-surfaces.test.ts +++ b/src/components/delete-undo-surfaces.test.ts @@ -10,7 +10,7 @@ for (const rel of [ "./vault-panel.tsx", "./automations-view.tsx", "./journal/journal-entries.tsx", - "./familiar-studio-lifecycle-tab.tsx", + "./familiar-lifecycle-section.tsx", ]) { const src = read(rel); assert.match(src, /import \{ useUndoDelete \} from "@\/lib\/use-undo-delete"/, `${rel} imports useUndoDelete`); @@ -41,11 +41,11 @@ for (const rel of [ assert.match(src, /day\?\.date !== deletePending\?\.item/, "journal treats the pending day as empty during the undo window"); } -// Familiar lifecycle: a familiar pending removal hides from BOTH the active and -// archived lists during the undo window (the toast is its only handle). +// Familiar lifecycle: a familiar pending removal loses its archive/remove +// controls during the undo window (the toast is its only handle). { - const src = read("./familiar-studio-lifecycle-tab.tsx"); - assert.match(src, /f\.id === pendingRemoveId/, "lifecycle tab hides the pending familiar row during the undo window"); + const src = read("./familiar-lifecycle-section.tsx"); + assert.match(src, /familiar\.id === pendingRemoveId/, "lifecycle section hides the pending familiar's controls during the undo window"); } console.log("delete-undo-surfaces.test.ts OK"); diff --git a/src/components/familiar-studio-lifecycle-tab.test.ts b/src/components/familiar-lifecycle-section.test.ts similarity index 68% rename from src/components/familiar-studio-lifecycle-tab.test.ts rename to src/components/familiar-lifecycle-section.test.ts index 4398dabad..c85b392a8 100644 --- a/src/components/familiar-studio-lifecycle-tab.test.ts +++ b/src/components/familiar-lifecycle-section.test.ts @@ -3,38 +3,22 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; const source = readFileSync( - new URL("./familiar-studio-lifecycle-tab.tsx", import.meta.url), + new URL("./familiar-lifecycle-section.tsx", import.meta.url), "utf8", ); -assert.match(source, /export function FamiliarStudioLifecycleTab/); +assert.match(source, /export function FamiliarLifecycleSection/); assert.match(source, /archiveFamiliar/); assert.match(source, /unarchiveFamiliar/); -assert.match(source, /clearAllFamiliarOverrides/); -assert.match(source, /clearGlyphOverride/); -assert.match(source, /clearFamiliarImage/); -// The roster (Active/Archived reorder + archive) renders here unconditionally — -// it's the manager the standalone "Manage familiars" page used to host, now part -// of Settings → Familiars (no more listView gate). -assert.match(source, /

Active<\/h3>/, "the Lifecycle tab shows the active roster"); -assert.match(source, /Archived/, "and the archived roster"); -assert.match(source, /setFamiliarOrder/, "reordering the roster persists familiar order"); -assert.match(source, /canMoveUp/, "Rows expose canMoveUp prop for disabled-edge state"); -assert.match(source, /canMoveDown/, "Rows expose canMoveDown prop for disabled-edge state"); -assert.match(source, /ph:arrow-up-bold/, "Move-up icon is wired"); -assert.match(source, /ph:arrow-down-bold/, "Move-down icon is wired"); +// The section is scoped to the SELECTED familiar (it lives at the bottom of the +// Identity tab) — the roster manager (reorder/list) retired with the Lifecycle +// tab, so no roster lists or ordering machinery may creep back in here. +assert.doesNotMatch(source, /setFamiliarOrder|DndContext|SortableContext|useSortable/, "no roster reordering in the lifecycle section"); +assert.match(source, /isArchived \? \(/, "archive/unarchive toggles off the selected familiar's archived state"); -// The active roster is also drag-reorderable (dnd-kit), alongside the arrows. -assert.match(source, /DndContext/, "active roster is wrapped in a DndContext"); -assert.match(source, /SortableContext/, "active rows are sortable"); -assert.match(source, /useSortable/, "each active row hooks the sortable transform"); -assert.match(source, /ph:dots-six-vertical/, "each active row exposes a drag grip"); -assert.match(source, /arrayMove\(ids, oldIndex, newIndex\)/, "drag reorder moves the id then persists via setFamiliarOrder"); - -// The roster order here is distinct from the avatar-strip pin order in -// Appearance — the hint cross-links so users find both (2026-07-06). -assert.match(source, /avatar strip(?:'|')s pinned order/, "lifecycle hint disambiguates roster order from pin order"); -assert.match(source, /window\.location\.hash = "appearance"/, "lifecycle hint links to Appearance"); +// The roster order hint moved out with the reorder UI; archive-vs-remove +// semantics still explained in-product. +assert.match(source, /Archive hides a familiar/, "archive-vs-remove semantics are explained in-product"); // ── Dual-track lifecycle (cave-ykwk): Remove = undo-safe detach ───────────── // Archive stays the reversible hide; Remove detaches a mistaken binding. These @@ -51,16 +35,16 @@ assert.match( "remove commits as DELETE /api/familiars/[id]", ); -// Every row (active AND archived) exposes Remove as a distinct action beside -// Archive/Unarchive, with an inline confirm strip that spells out detach -// semantics: what is cleared vs. what survives, plus the active-session warning. +// Remove is a distinct action beside Archive/Unarchive, with an inline confirm +// strip that spells out detach semantics: what is cleared vs. what survives, +// plus the active-session warning. assert.match(source, /aria-label=\{`Remove \$\{familiar\.display_name\}`\}/); assert.match(source, /aria-label=\{`Archive \$\{familiar\.display_name\}`\}/); +assert.match(source, /aria-label=\{`Unarchive \$\{familiar\.display_name\}`\}/); assert.match(source, /roster entry and agent binding/, "confirm copy explains what is cleared"); assert.match(source, /stay on\s+your disk/, "confirm copy explains what survives"); assert.match(source, /active_sessions/, "confirm warns using the daemon session count"); assert.match(source, /keep\s+running until they finish/); -assert.match(source, /Archive hides a familiar/, "archive-vs-remove semantics are explained in-product"); // Restore path: Recently removed shelf + POST restore + announcer feedback, // then a roster re-fetch threaded from Settings. @@ -81,6 +65,19 @@ assert.match(source, /onRosterChanged\?\.\(\)/); assert.doesNotMatch(removeFlow, /archiveFamiliar|unarchiveFamiliar/, "remove leaves the archive store alone"); } +// The section renders from the Identity tab — the only lifecycle home now. +{ + const identityTab = readFileSync( + new URL("./familiar-studio-identity-tab.tsx", import.meta.url), + "utf8", + ); + assert.match( + identityTab, + //, + "the Identity tab hosts the lifecycle section", + ); +} + // ── Route seams ────────────────────────────────────────────────────────────── { const path = await import("node:path"); @@ -126,4 +123,4 @@ assert.match(source, /onRosterChanged\?\.\(\)/); assert.match(removedRoute, /status: 409/); } -console.log("familiar-studio-lifecycle-tab.test.ts: ok"); +console.log("familiar-lifecycle-section.test.ts: ok"); diff --git a/src/components/familiar-lifecycle-section.tsx b/src/components/familiar-lifecycle-section.tsx new file mode 100644 index 000000000..092a6d662 --- /dev/null +++ b/src/components/familiar-lifecycle-section.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { UndoToast } from "@/components/ui/undo-toast"; +import { useAnnouncer } from "@/components/ui/live-region"; +import { + archiveFamiliar, + unarchiveFamiliar, + useArchivedFamiliars, +} from "@/lib/cave-familiar-archive"; +import { relativeTime } from "@/lib/relative-time"; +import { useUndoDelete } from "@/lib/use-undo-delete"; +import type { ResolvedFamiliar } from "@/lib/familiar-resolve"; + +type Props = { + familiar: ResolvedFamiliar; + /** Re-fetch the roster after a remove/restore lands server-side. */ + onRosterChanged?: () => void; +}; + +type RemovedFamiliarSummary = { id: string; displayName: string; removedAt: string }; + +/** + * Archive + remove controls for the selected familiar, plus the shared + * "Recently removed" restore shelf. Distilled from the retired Lifecycle tab: + * roster reordering moved out with the tab, so archive and remove — the only + * lifecycle verbs left — now live at the bottom of the Identity tab. + */ +export function FamiliarLifecycleSection({ familiar, onRosterChanged }: Props) { + const archived = useArchivedFamiliars(); + const { announce } = useAnnouncer(); + const [confirmRemove, setConfirmRemove] = useState(false); + const [removedEntries, setRemovedEntries] = useState([]); + const [restoringId, setRestoringId] = useState(null); + // Ids whose DELETE has committed but whose roster refresh hasn't landed yet — + // keeps the controls from flashing back between commit and re-fetch. + const [removedLocally, setRemovedLocally] = useState>(new Set()); + const { + pending: pendingRemove, + scheduleDelete, + undo: undoRemove, + commit: commitRemove, + } = useUndoDelete(); + + const isArchived = familiar.id in archived; + // A familiar pending removal loses its archive/remove controls during the + // undo window — the UndoToast is its only handle until the delete commits or + // is undone (same optimistic pattern as board/vault/journal). + const pendingRemoveId = pendingRemove?.item.id ?? null; + const hidden = familiar.id === pendingRemoveId || removedLocally.has(familiar.id); + + useEffect(() => { + setConfirmRemove(false); + }, [familiar.id]); + + const removedCtlRef = useRef(null); + const loadRemoved = useCallback(async () => { + removedCtlRef.current?.abort(); + const ctl = new AbortController(); + removedCtlRef.current = ctl; + try { + const res = await fetch("/api/familiars/removed", { cache: "no-store", signal: ctl.signal }); + const json = await res.json().catch(() => null); + if (ctl.signal.aborted) return; + if (json?.ok) setRemovedEntries((json.removed ?? []) as RemovedFamiliarSummary[]); + } catch { + /* transient (or aborted) — keep the last list */ + } + }, []); + + useEffect(() => { + void loadRemoved(); + return () => removedCtlRef.current?.abort(); + }, [loadRemoved]); + + // Remove ≠ Archive: it detaches the familiar server-side (roster entry + + // agent binding), while chats, memory, and workspace files stay on disk. + // The DELETE is deferred through useUndoDelete, so Undo/⌘Z during the toast + // window means nothing was ever sent. + function performRemove(f: ResolvedFamiliar) { + setConfirmRemove(false); + scheduleDelete(f, f.display_name, async () => { + setRemovedLocally((prev) => new Set(prev).add(f.id)); + try { + const res = await fetch(`/api/familiars/${encodeURIComponent(f.id)}`, { method: "DELETE" }); + const json = await res.json().catch(() => null); + if (!res.ok || json?.ok === false) { + throw new Error(typeof json?.error === "string" ? json.error : `remove failed (${res.status})`); + } + window.dispatchEvent(new Event("cave:familiars-refresh")); + announce(`Removed ${f.display_name}. Restore it from Recently removed.`); + } catch (err) { + setRemovedLocally((prev) => { + const next = new Set(prev); + next.delete(f.id); + return next; + }); + announce( + `Could not remove ${f.display_name}: ${err instanceof Error ? err.message : "unknown error"}`, + "assertive", + ); + } finally { + void loadRemoved(); + onRosterChanged?.(); + } + }); + } + + async function restoreRemoved(entry: RemovedFamiliarSummary) { + setRestoringId(entry.id); + try { + const res = await fetch("/api/familiars/removed", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: entry.id }), + }); + const json = await res.json().catch(() => null); + if (!res.ok || json?.ok === false) { + throw new Error(typeof json?.error === "string" ? json.error : `restore failed (${res.status})`); + } + setRemovedLocally((prev) => { + const next = new Set(prev); + next.delete(entry.id); + return next; + }); + window.dispatchEvent(new Event("cave:familiars-refresh")); + announce(`Restored ${entry.displayName}.`); + onRosterChanged?.(); + } catch (err) { + announce( + `Could not restore ${entry.displayName}: ${err instanceof Error ? err.message : "unknown error"}`, + "assertive", + ); + } finally { + setRestoringId(null); + void loadRemoved(); + } + } + + return ( +
+
+

Lifecycle

+

+ Archive hides a familiar from switchers but keeps it bound — unarchive anytime. Remove + detaches it from your Cave; chats, memory, and workspace files stay on + your disk, and a removal can be undone from Recently removed. +

+
+ {!hidden ? ( +
+ {isArchived ? ( + + ) : ( + + )} + {!confirmRemove ? ( + + ) : null} +
+ ) : null} + {confirmRemove && !hidden ? ( + performRemove(familiar)} + onCancel={() => setConfirmRemove(false)} + /> + ) : null} + + {removedEntries.length > 0 ? ( +
+

Recently removed

+

+ Removed familiars keep their chats, memory, and files on disk. Restore re-registers one + exactly as it was — kept for 30 days. +

+ {removedEntries.map((entry) => ( +
+ {entry.displayName} + + removed {relativeTime(entry.removedAt)} + + +
+ ))} +
+ ) : null} + + {pendingRemove ? ( + Removed {pendingRemove.label}} + undoAriaLabel={`Undo removing ${pendingRemove.label}`} + onUndo={undoRemove} + onDismiss={commitRemove} + /> + ) : null} +
+ ); +} + +// The destructive half of the remove flow: an inline confirm strip that spells +// out detach semantics (what is cleared vs. what survives) before anything is +// scheduled — required in-product copy for the safety constraints of removal. +function RemoveConfirm({ + familiar, + onConfirm, + onCancel, +}: { + familiar: ResolvedFamiliar; + onConfirm: () => void; + onCancel: () => void; +}) { + const sessions = familiar.active_sessions ?? 0; + return ( +
+

Remove {familiar.display_name}?

+

+ This detaches {familiar.display_name} from your Cave — its roster entry and agent binding + are cleared. The agent itself, past chats, and memory files stay on + your disk, and you can restore it from Recently removed. +

+ {sessions > 0 ? ( +

+ {familiar.display_name} has {sessions} active session{sessions === 1 ? "" : "s"} — they keep + running until they finish. +

+ ) : null} +
+ + +
+
+ ); +} diff --git a/src/components/familiar-studio-identity-tab.tsx b/src/components/familiar-studio-identity-tab.tsx index 653b4202e..7230cfc4c 100644 --- a/src/components/familiar-studio-identity-tab.tsx +++ b/src/components/familiar-studio-identity-tab.tsx @@ -10,12 +10,18 @@ import { } from "@/lib/cave-familiar-overrides"; import { FAMILIAR_TYPES, resolveFamiliarType } from "@/lib/familiar-types"; import { Icon } from "@/lib/icon"; +import { FamiliarStudioLookTab } from "@/components/familiar-studio-look-tab"; +import { FamiliarLifecycleSection } from "@/components/familiar-lifecycle-section"; import type { ResolvedFamiliar } from "@/lib/familiar-resolve"; type Props = { familiar: ResolvedFamiliar; /** Underlying daemon values shown as ghosted placeholders when no override is set. */ rawDaemonValues: Partial; + /** Full resolved roster — the appearance controls diff accent colors across familiars. */ + allFamiliars: ResolvedFamiliar[]; + /** Re-fetch the roster after the lifecycle section removes/restores a familiar. */ + onRosterChanged?: () => void; }; const FIELDS: Array<{ @@ -29,10 +35,18 @@ const FIELDS: Array<{ { key: "description", label: "Description", textarea: true }, ]; -export function FamiliarStudioIdentityTab({ familiar, rawDaemonValues }: Props) { +export function FamiliarStudioIdentityTab({ + familiar, + rawDaemonValues, + allFamiliars, + onRosterChanged, +}: Props) { const overrides = useFamiliarOverrides(); const current = overrides[familiar.id] ?? {}; + // One continuous page: who the familiar is (type + identity fields), how it + // looks (the merged Look sections — avatar, icon, backdrop, accent), then the + // lifecycle verbs (archive / remove) last, in the classic danger-zone slot. return (
@@ -48,6 +62,8 @@ export function FamiliarStudioIdentityTab({ familiar, rawDaemonValues }: Props) onReset={() => clearFamiliarOverrideField(familiar.id, f.key)} /> ))} + +
); } diff --git a/src/components/familiar-studio-inline.test.ts b/src/components/familiar-studio-inline.test.ts index b8dc57ddf..351d43881 100644 --- a/src/components/familiar-studio-inline.test.ts +++ b/src/components/familiar-studio-inline.test.ts @@ -96,10 +96,17 @@ assert.doesNotMatch(source, /familiar-studio__scrim/, "Inline panel must not ren assert.doesNotMatch(source, /familiar-studio__drawer/, "Inline panel must not render the fixed drawer root"); // Familiar-specific studio tabs are wired with the same prop shapes the drawer uses. -for (const tab of ["Identity", "Look", "Brain", "Lifecycle", "Memory"]) { +for (const tab of ["Identity", "Brain", "Memory"]) { assert.match(source, new RegExp("FamiliarStudio" + tab + "Tab"), "Wires the " + tab + " tab body"); } -assert.match(source, //, "Look tab gets all resolved familiars for group colors"); +// Look and Lifecycle merged into Identity: the tab strip is exactly these five. +for (const gone of ["look", "lifecycle", "journal"]) { + assert.doesNotMatch(source, new RegExp('id: "' + gone + '"'), "No standalone " + gone + " tab remains"); +} +assert.match(source, /id: "identity", label: "Identity"/, "Identity leads the tab strip"); +const identityTab = readFileSync(new URL("./familiar-studio-identity-tab.tsx", import.meta.url), "utf8"); +assert.match(identityTab, //, "Identity hosts the Look sections with all resolved familiars for group colors"); +assert.match(source, /allFamiliars=\{resolved\}/, "Identity tab gets the resolved roster for the appearance controls"); assert.match(source, //, "Memory tab gets the raw roster"); assert.match(source, /VaultPanel/, "Wires the Vault settings panel inside familiar settings"); assert.match(source, /id: "vault", label: "Vault"/, "Exposes Vault as a familiar settings tab"); diff --git a/src/components/familiar-studio-inline.tsx b/src/components/familiar-studio-inline.tsx index c6654b8ea..c1131a98f 100644 --- a/src/components/familiar-studio-inline.tsx +++ b/src/components/familiar-studio-inline.tsx @@ -7,12 +7,9 @@ import { useFamiliarStudio, BRAIN_STUDIO_FAMILIAR_KEY, type FamiliarStudioTab } import { useDaemonSyncStatus } from "@/lib/daemon-sync-status"; import type { ResolvedFamiliar } from "@/lib/familiar-resolve"; import { FamiliarStudioIdentityTab } from "./familiar-studio-identity-tab"; -import { FamiliarStudioLookTab } from "./familiar-studio-look-tab"; import { FamiliarStudioBrainTab } from "./familiar-studio-brain-tab"; -import { FamiliarStudioLifecycleTab } from "./familiar-studio-lifecycle-tab"; import { FamiliarStudioMemoryTab } from "./familiar-studio-memory-tab"; import { FamiliarStudioProjectsTab } from "./familiar-studio-projects-tab"; -import { FamiliarStudioJournalTab } from "./familiar-studio-journal-tab"; import { SettingsFamiliarPicker } from "./settings-familiar-picker"; import { VaultPanel } from "./vault-panel"; import type { Familiar } from "@/lib/types"; @@ -24,17 +21,14 @@ type Props = { resolved: ResolvedFamiliar[]; /** Opens the summoning circle from the familiar picker's fixed footer. */ onSummon?: () => void; - /** Re-fetch the roster after the Lifecycle tab removes/restores a familiar. */ + /** Re-fetch the roster after the Identity tab's lifecycle section removes/restores a familiar. */ onRosterChanged?: () => void; }; const TABS: Array<{ id: FamiliarStudioTab; label: string; icon: IconName }> = [ { id: "identity", label: "Identity", icon: "ph:user" }, - { id: "look", label: "Look", icon: "ph:paint-brush" }, { id: "brain", label: "Brain", icon: "ph:brain" }, - { id: "lifecycle", label: "Lifecycle", icon: "ph:arrows-clockwise" }, { id: "memory", label: "Memory", icon: "ph:archive" }, - { id: "journal", label: "Journal", icon: "ph:book-open" }, { id: "projects", label: "Projects", icon: "ph:folder" }, { id: "vault", label: "Vault", icon: "ph:vault" }, ]; @@ -133,24 +127,15 @@ export function FamiliarStudioInlinePanel({ familiars, resolved, onSummon, onRos pronouns: familiars.find((f) => f.id === familiar.id)?.pronouns, description: familiars.find((f) => f.id === familiar.id)?.description, }} - /> - ) : null} - {activeTab === "look" ? ( - - ) : null} - {activeTab === "brain" ? : null} - {activeTab === "lifecycle" ? ( - ) : null} + {activeTab === "brain" ? : null} {activeTab === "memory" ? ( ) : null} {activeTab === "projects" ? : null} - {activeTab === "journal" ? : null} {activeTab === "vault" ? : null} diff --git a/src/components/familiar-studio-journal-tab.tsx b/src/components/familiar-studio-journal-tab.tsx deleted file mode 100644 index d4cf6e636..000000000 --- a/src/components/familiar-studio-journal-tab.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import "@/styles/journal.css"; -import { JournalEntries } from "@/components/journal/journal-entries"; -import type { ResolvedFamiliar } from "@/lib/familiar-resolve"; -import type { Familiar } from "@/lib/types"; - -/** - * Familiar Studio → Journal tab: the daily-reflection reader/editor scoped to - * the familiar being edited. Reuses the full JournalEntries surface (day rail, - * generate, edit/delete with undo) with the multiselect scope pinned to this - * one familiar — the Journal's former top-level page redirects here. - */ -export function FamiliarStudioJournalTab({ - familiar, - allFamiliars, -}: { - familiar: ResolvedFamiliar; - allFamiliars: Familiar[]; -}) { - const scope = useMemo(() => new Set([familiar.id]), [familiar.id]); - return ( -
- -
- ); -} diff --git a/src/components/familiar-studio-lifecycle-tab.tsx b/src/components/familiar-studio-lifecycle-tab.tsx deleted file mode 100644 index 2649abadb..000000000 --- a/src/components/familiar-studio-lifecycle-tab.tsx +++ /dev/null @@ -1,543 +0,0 @@ -"use client"; - -import { Fragment, useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; -import { - DndContext, - PointerSensor, - KeyboardSensor, - useSensor, - useSensors, - closestCenter, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - useSortable, - arrayMove, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { Icon } from "@/lib/icon"; -import { FamiliarAvatar } from "@/components/familiar-avatar"; -import { Button } from "@/components/ui/button"; -import { IconButton } from "@/components/ui/icon-button"; -import { UndoToast } from "@/components/ui/undo-toast"; -import { useAnnouncer } from "@/components/ui/live-region"; -import { - archiveFamiliar, - unarchiveFamiliar, - useArchivedFamiliars, -} from "@/lib/cave-familiar-archive"; -import { clearAllFamiliarOverrides } from "@/lib/cave-familiar-overrides"; -import { clearGlyphOverride } from "@/lib/cave-glyph-overrides"; -import { clearFamiliarImage } from "@/lib/cave-familiar-images"; -import { setFamiliarOrder } from "@/lib/cave-familiar-order"; -import { relativeTime } from "@/lib/relative-time"; -import { useFamiliarStudio } from "@/lib/familiar-studio-context"; -import { useUndoDelete } from "@/lib/use-undo-delete"; -import type { ResolvedFamiliar } from "@/lib/familiar-resolve"; - -type Props = { - familiar: ResolvedFamiliar | null; - allResolved: ResolvedFamiliar[]; - /** Re-fetch the roster after a remove/restore lands server-side. */ - onRosterChanged?: () => void; -}; - -type RemovedFamiliarSummary = { id: string; displayName: string; removedAt: string }; - -export function FamiliarStudioLifecycleTab({ familiar, allResolved, onRosterChanged }: Props) { - const archived = useArchivedFamiliars(); - const { openFamiliarStudio } = useFamiliarStudio(); - const { announce } = useAnnouncer(); - const [confirmReset, setConfirmReset] = useState(false); - const [confirmRemove, setConfirmRemove] = useState(null); - const [removedEntries, setRemovedEntries] = useState([]); - const [restoringId, setRestoringId] = useState(null); - // Ids whose DELETE has committed but whose roster refresh hasn't landed yet — - // keeps the row from flashing back between commit and re-fetch. - const [removedLocally, setRemovedLocally] = useState>(new Set()); - const { - pending: pendingRemove, - scheduleDelete, - undo: undoRemove, - commit: commitRemove, - } = useUndoDelete(); - - // The full roster (active + archived) with reorder + archive — this is the - // manager that used to live in the standalone "Manage familiars" page. It now - // renders here so Settings → Familiars is the single source of truth, with the - // selected familiar's per-familiar controls (reset) below it. - // - // A familiar pending removal hides from BOTH lists during the undo window — - // the UndoToast is its only handle until the delete commits or is undone - // (same optimistic pattern as board/vault/journal). - const pendingRemoveId = pendingRemove?.item.id ?? null; - const hidden = (f: ResolvedFamiliar) => f.id === pendingRemoveId || removedLocally.has(f.id); - const active = allResolved.filter((f) => !(f.id in archived) && !hidden(f)); - const archivedList = allResolved.filter((f) => f.id in archived && !hidden(f)); - - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), - ); - - // Rebuild the full roster order from a reordered active list, keeping archived - // familiars in their existing slots — the same order model the up/down arrows - // persist through. - function reorderTo(activeIds: string[]) { - let ai = 0; - const fullIds = allResolved.map((f) => (f.id in archived ? f.id : activeIds[ai++])); - setFamiliarOrder(fullIds); - } - - function handleDragEnd(event: DragEndEvent) { - const { active: dragged, over } = event; - if (!over || dragged.id === over.id) return; - const ids = active.map((f) => f.id); - const oldIndex = ids.indexOf(String(dragged.id)); - const newIndex = ids.indexOf(String(over.id)); - if (oldIndex < 0 || newIndex < 0) return; - reorderTo(arrayMove(ids, oldIndex, newIndex)); - } - - function move(id: string, direction: "up" | "down") { - const ids = allResolved.map((f) => f.id); - const idx = ids.indexOf(id); - if (idx < 0) return; - const swapIdx = direction === "up" ? idx - 1 : idx + 1; - if (swapIdx < 0 || swapIdx >= ids.length) return; - // Only swap within the active group — refuse to move past an archived neighbor. - const swapId = ids[swapIdx]; - if (swapId in archived) return; - [ids[idx], ids[swapIdx]] = [ids[swapIdx], ids[idx]]; - setFamiliarOrder(ids); - } - - function resetAll() { - if (!familiar) return; - if (!confirmReset) { - setConfirmReset(true); - return; - } - clearAllFamiliarOverrides(familiar.id); - clearGlyphOverride(familiar.id); - void clearFamiliarImage(familiar.id); - void fetch("/api/config", { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ familiars: { [familiar.id]: null } }), - }).catch(() => undefined); - setConfirmReset(false); - } - - const removedCtlRef = useRef(null); - const loadRemoved = useCallback(async () => { - removedCtlRef.current?.abort(); - const ctl = new AbortController(); - removedCtlRef.current = ctl; - try { - const res = await fetch("/api/familiars/removed", { cache: "no-store", signal: ctl.signal }); - const json = await res.json().catch(() => null); - if (ctl.signal.aborted) return; - if (json?.ok) setRemovedEntries((json.removed ?? []) as RemovedFamiliarSummary[]); - } catch { - /* transient (or aborted) — keep the last list */ - } - }, []); - - useEffect(() => { - void loadRemoved(); - return () => removedCtlRef.current?.abort(); - }, [loadRemoved]); - - // Remove ≠ Archive: it detaches the familiar server-side (roster entry + - // agent binding), while chats, memory, and workspace files stay on disk. - // The DELETE is deferred through useUndoDelete, so Undo/⌘Z during the toast - // window means nothing was ever sent. - function performRemove(f: ResolvedFamiliar) { - setConfirmRemove(null); - scheduleDelete(f, f.display_name, async () => { - setRemovedLocally((prev) => new Set(prev).add(f.id)); - try { - const res = await fetch(`/api/familiars/${encodeURIComponent(f.id)}`, { method: "DELETE" }); - const json = await res.json().catch(() => null); - if (!res.ok || json?.ok === false) { - throw new Error(typeof json?.error === "string" ? json.error : `remove failed (${res.status})`); - } - window.dispatchEvent(new Event("cave:familiars-refresh")); - announce(`Removed ${f.display_name}. Restore it from Recently removed.`); - } catch (err) { - setRemovedLocally((prev) => { - const next = new Set(prev); - next.delete(f.id); - return next; - }); - announce( - `Could not remove ${f.display_name}: ${err instanceof Error ? err.message : "unknown error"}`, - "assertive", - ); - } finally { - void loadRemoved(); - onRosterChanged?.(); - } - }); - } - - async function restoreRemoved(entry: RemovedFamiliarSummary) { - setRestoringId(entry.id); - try { - const res = await fetch("/api/familiars/removed", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ id: entry.id }), - }); - const json = await res.json().catch(() => null); - if (!res.ok || json?.ok === false) { - throw new Error(typeof json?.error === "string" ? json.error : `restore failed (${res.status})`); - } - setRemovedLocally((prev) => { - const next = new Set(prev); - next.delete(entry.id); - return next; - }); - window.dispatchEvent(new Event("cave:familiars-refresh")); - announce(`Restored ${entry.displayName}.`); - onRosterChanged?.(); - } catch (err) { - announce( - `Could not restore ${entry.displayName}: ${err instanceof Error ? err.message : "unknown error"}`, - "assertive", - ); - } finally { - setRestoringId(null); - void loadRemoved(); - } - } - - return ( -
-

- Archive hides a familiar from switchers but keeps it bound — unarchive anytime. Remove - detaches it from your Cave; chats, memory, and workspace files stay on disk, and a removal - can be undone from Recently removed. -

-
-

Active

-

- Sets the roster order across the app. The avatar strip's pinned order is separate. -

- - - f.id)} strategy={verticalListSortingStrategy}> - {active.map((f, i) => ( - - 0} - canMoveDown={i < active.length - 1} - onSelect={() => openFamiliarStudio(f.id, "identity")} - onArchive={() => archiveFamiliar(f.id)} - onUnarchive={() => unarchiveFamiliar(f.id)} - onRemove={() => setConfirmRemove(f)} - onMoveUp={() => move(f.id, "up")} - onMoveDown={() => move(f.id, "down")} - /> - {confirmRemove?.id === f.id ? ( - performRemove(f)} - onCancel={() => setConfirmRemove(null)} - /> - ) : null} - - ))} - - -
- {archivedList.length > 0 ? ( -
-

Archived

-

- Hidden from switchers, still bound to their runtimes and memory. -

- {archivedList.map((f) => ( - - openFamiliarStudio(f.id, "identity")} - onArchive={() => archiveFamiliar(f.id)} - onUnarchive={() => unarchiveFamiliar(f.id)} - onRemove={() => setConfirmRemove(f)} - onMoveUp={() => { /* no-op */ }} - onMoveDown={() => { /* no-op */ }} - /> - {confirmRemove?.id === f.id ? ( - performRemove(f)} - onCancel={() => setConfirmRemove(null)} - /> - ) : null} - - ))} -
- ) : null} - - {removedEntries.length > 0 ? ( -
-

Recently removed

-

- Removed familiars keep their chats, memory, and files on disk. Restore re-registers one - exactly as it was — kept for 30 days. -

- {removedEntries.map((entry) => ( -
- {entry.displayName} - - removed {relativeTime(entry.removedAt)} - - -
- ))} -
- ) : null} - - {familiar ? ( -
-

Reset overrides

-

- Clears {familiar.display_name}'s identity / look / brain customizations and - reverts it to its daemon defaults. -

- -
- ) : null} - - {pendingRemove ? ( - Removed {pendingRemove.label}} - undoAriaLabel={`Undo removing ${pendingRemove.label}`} - onUndo={undoRemove} - onDismiss={commitRemove} - /> - ) : null} -
- ); -} - -// The destructive half of the remove flow: an inline confirm strip that spells -// out detach semantics (what is cleared vs. what survives) before anything is -// scheduled — required in-product copy for the safety constraints of removal. -function RemoveConfirm({ - familiar, - onConfirm, - onCancel, -}: { - familiar: ResolvedFamiliar; - onConfirm: () => void; - onCancel: () => void; -}) { - const sessions = familiar.active_sessions ?? 0; - return ( -
-

Remove {familiar.display_name}?

-

- This detaches {familiar.display_name} from your Cave — its roster entry and agent binding - are cleared. The agent itself, past chats, and memory files stay on - your disk, and you can restore it from Recently removed. -

- {sessions > 0 ? ( -

- {familiar.display_name} has {sessions} active session{sessions === 1 ? "" : "s"} — they keep - running until they finish. -

- ) : null} -
- - -
-
- ); -} - -// Active rows are draggable; the sortable wrapper feeds the drag handle + -// transform down into the shared FamiliarRow. -function SortableFamiliarRow(props: { - familiar: ResolvedFamiliar; - canMoveUp: boolean; - canMoveDown: boolean; - onSelect: () => void; - onArchive: () => void; - onUnarchive: () => void; - onRemove: () => void; - onMoveUp: () => void; - onMoveDown: () => void; -}) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: props.familiar.id, - }); - const style: CSSProperties = { - transform: CSS.Translate.toString(transform), - transition, - }; - return ( - - ); -} - -function FamiliarRow({ - familiar, - isArchived, - canMoveUp, - canMoveDown, - onSelect, - onArchive, - onUnarchive, - onRemove, - onMoveUp, - onMoveDown, - dragRef, - dragStyle, - isDragging, - dragHandle, -}: { - familiar: ResolvedFamiliar; - isArchived: boolean; - canMoveUp: boolean; - canMoveDown: boolean; - onSelect: () => void; - onArchive: () => void; - onUnarchive: () => void; - onRemove: () => void; - onMoveUp: () => void; - onMoveDown: () => void; - dragRef?: (node: HTMLElement | null) => void; - dragStyle?: CSSProperties; - isDragging?: boolean; - dragHandle?: Record; -}) { - return ( -
- {dragHandle ? ( - - ) : null} - - {!isArchived ? ( - <> - - - - ) : null} - {isArchived ? ( - - ) : ( - - )} - -
- ); -} diff --git a/src/components/familiar-switcher.test.ts b/src/components/familiar-switcher.test.ts index f5e9d7520..20e2cd20c 100644 --- a/src/components/familiar-switcher.test.ts +++ b/src/components/familiar-switcher.test.ts @@ -116,7 +116,7 @@ assert.match( /className="familiar-switcher__summon focus-ring"[\s\S]{0,200}ph:magic-wand-fill[\s\S]{0,100}Summon familiar/, "the footer leads with a full-width Summon familiar button wearing the circle's wand", ); -assert.match(source, /openFamiliarStudioListView\(\)/, "Manage opens the Studio list view"); +assert.match(source, /openFamiliarStudioListView\(\)/, "Manage opens the familiars manager (Settings → Familiars)"); assert.match(source, /setReordering\(true\)/, "Reorder enables drag mode"); assert.match(source, /setFamiliarOrder\(arrayMove\(/, "reorder persists the new familiar order"); diff --git a/src/components/familiars-view-sections.tsx b/src/components/familiars-view-sections.tsx index 97a02037a..8db295e7a 100644 --- a/src/components/familiars-view-sections.tsx +++ b/src/components/familiars-view-sections.tsx @@ -395,9 +395,9 @@ type AgentDetailPanelProps = { }; // Per-familiar overflow menu on the detail panel header. Remove routes to the -// Studio lifecycle tab rather than confirming here — the destructive flow +// Studio identity tab rather than confirming here — the destructive flow // (confirm copy, undo toast, tombstone coupling) lives only in -// familiar-studio-lifecycle-tab.tsx, and this menu is the discoverable +// familiar-lifecycle-section.tsx, and this menu is the discoverable // entry point the Familiars surface lacked. function FamiliarPanelMenu({ familiar }: { familiar: ResolvedFamiliar }) { const { openFamiliarStudio } = useFamiliarStudio(); @@ -441,7 +441,7 @@ function FamiliarPanelMenu({ familiar }: { familiar: ResolvedFamiliar }) { danger onSelect={() => { setOpen(false); - openFamiliarStudio(familiar.id, "lifecycle"); + openFamiliarStudio(familiar.id, "identity"); }} > Remove familiar… diff --git a/src/components/familiars-view.test.ts b/src/components/familiars-view.test.ts index 3752c4c0b..4260bd5c8 100644 --- a/src/components/familiars-view.test.ts +++ b/src/components/familiars-view.test.ts @@ -280,8 +280,8 @@ console.log("familiars-view: all assertions passed"); // The detail panel header carries a per-familiar overflow menu — the // discoverable entry points for Edit-in-Studio and Remove. Remove must ROUTE -// to the Studio lifecycle tab (the canonical confirm + undo + tombstone flow), -// never confirm or DELETE from this surface. +// to the Studio Identity tab (its lifecycle section owns the canonical +// confirm + undo + tombstone flow), never confirm or DELETE from this surface. assert.match( source, /aria-label=\{`\$\{familiar\.display_name\} options`\}[\s\S]{0,600}openFamiliarStudio\(familiar\.id, "identity"\)/, @@ -289,13 +289,13 @@ assert.match( ); assert.match( source, - /danger[\s\S]{0,200}openFamiliarStudio\(familiar\.id, "lifecycle"\)[\s\S]{0,120}Remove familiar/, - "Remove familiar routes to the Studio lifecycle tab where the canonical confirm lives", + /danger[\s\S]{0,200}openFamiliarStudio\(familiar\.id, "identity"\)[\s\S]{0,120}Remove familiar/, + "Remove familiar routes to the Studio Identity tab where the canonical confirm lives", ); assert.doesNotMatch( source, /fetch\([^)]*\/api\/familiars\/[^)]*\{\s*method:\s*"DELETE"/, - "FamiliarsView never performs the destructive DELETE itself — that stays in the lifecycle tab", + "FamiliarsView never performs the destructive DELETE itself — that stays in the lifecycle section", ); // Sessions tab: each row keeps its open-in-chat primary action AND gains a diff --git a/src/components/familiar-studio-journal-tab.test.ts b/src/components/journal-redirect.test.ts similarity index 61% rename from src/components/familiar-studio-journal-tab.test.ts rename to src/components/journal-redirect.test.ts index 2d7096285..a89bd6435 100644 --- a/src/components/familiar-studio-journal-tab.test.ts +++ b/src/components/journal-redirect.test.ts @@ -1,7 +1,9 @@ // @ts-nocheck -// Journal lives in the Familiar Studio (Settings → Familiars → Journal). -// Source-scan invariants for the tab wiring and the redirect from the old -// top-level Journal surface. +// The Journal lives in the Grimoire (Memories → Journal tab). Both prior homes +// — the top-level Journal page and the Settings → Familiars studio tab — are +// retired. These pins hold the redirect seams (mode remap, palette +// reachability, restore path) and the invariant that neither retired surface +// creeps back. import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; @@ -9,17 +11,12 @@ const read = (rel) => readFileSync(new URL(rel, import.meta.url), "utf8"); const ctx = read("../lib/familiar-studio-context.tsx"); -// ── Studio context knows the journal tab ───────────────────────────────────── -assert.match(ctx, /"journal"/, "FamiliarStudioTab union includes journal"); -assert.match( - ctx, - /STUDIO_TABS: readonly FamiliarStudioTab\[\][\s\S]*?"journal"/, - "the canonical tab list includes journal", -); +// ── Studio context: journal is no longer a studio tab ──────────────────────── +assert.doesNotMatch(ctx, /"journal"/, "FamiliarStudioTab union no longer includes journal"); assert.match( ctx, /\(STUDIO_TABS as readonly string\[\]\)\.includes\(stored \?\? ""\)/, - "the persisted-tab restore guard checks against STUDIO_TABS", + "the persisted-tab restore guard checks against STUDIO_TABS (a stale stored \"journal\" falls back to the default tab)", ); // One shared redirect helper: workspace surfaces and the redirecting provider // both route through it, so the tab/familiar handoff keys can't drift. @@ -34,42 +31,13 @@ assert.match( "the redirecting provider reuses the helper", ); -const wrapper = read("./familiar-studio-journal-tab.tsx"); const inline = read("./familiar-studio-inline.tsx"); const sections = read("./settings-sections.ts"); -const css = read("../styles/journal.css"); - -// ── Wrapper: reuse JournalEntries pinned to the studio's familiar ──────────── -assert.match(wrapper, /import "@\/styles\/journal\.css"/, "wrapper carries the journal styles"); -assert.match(wrapper, / new Set\(\[familiar\.id\]\), \[familiar\.id\]\)/, - "the multiselect scope is pinned to the one familiar being edited", -); -assert.match(wrapper, /activeFamiliarId=\{familiar\.id\}/, "generation targets the studio familiar"); - -// ── Inline panel: the tab is registered and rendered ───────────────────────── -assert.match( - inline, - /\{ id: "journal", label: "Journal", icon: "ph:book-open" \}/, - "the studio tab bar includes Journal", -); -assert.match( - inline, - /activeTab === "journal" \? : null/, - "the journal tab body renders the wrapper", -); -// ── Settings search reaches the tab ────────────────────────────────────────── -assert.match(sections, /familiarTab: "journal"/, "the journal studio tab is indexed for settings search"); - -// ── Studio host gives the master-detail journal a bounded height ───────────── -assert.match( - css, - /\.familiar-studio-journal \.journal-list \{[\s\S]*?height:/, - "journal-list gets an explicit height inside the studio body", -); +// ── Inline panel + settings search: no journal tab wiring remains ──────────── +assert.doesNotMatch(inline, /"journal"/, "the studio tab bar no longer includes Journal"); +assert.doesNotMatch(inline, /FamiliarStudioJournalTab/, "the journal tab wrapper is gone"); +assert.doesNotMatch(sections, /familiarTab: "journal"/, "settings search no longer indexes a journal studio tab"); const entriesSrc = read("./journal/journal-entries.tsx"); @@ -85,14 +53,18 @@ assert.match( "hasEntry honors the familiar scope", ); +// The Settings host is gone, and its `standalone` fork went with it — the +// Grimoire (workspace) is the only journal host, so the workspace event bus is +// always available. +assert.doesNotMatch(entriesSrc, /standalone/, "journal-entries no longer carries the settings-host fork"); + const ws = read("./workspace.tsx"); const sidebar = read("./sidebar-minimal.tsx"); const pageDrag = read("../lib/page-drag.ts"); const slash = read("../lib/slash-commands.ts"); // ── Workspace: "journal" is a redirect-only mode (like groupchat) ──────────── -// It now opens the Grimoire surface on its Journal tab; per-familiar journals -// still live in Settings → Familiars → Journal via FamiliarStudioJournalTab. +// It opens the Grimoire surface on its Journal tab. assert.match( ws, /if \(next === "journal"\) \{[\s\S]{0,400}?setGrimoireView\("journal"\);\s*\n\s*setModeRaw\("grimoire"\)/, @@ -113,7 +85,7 @@ assert.doesNotMatch(sidebar, /generated sketches/, "sidebar description no longe assert.match(pageDrag, /NON_SPLITTABLE = new Set\(\["terminal", "journal"\]\)/, "journal is excluded from drag-to-split"); // ── Slash palette copy matches the new home ─────────────────────────────────── -assert.match(slash, /name: "\/journal"[^}]*Settings/, "/journal description points at Settings"); +assert.match(slash, /name: "\/journal"/, "the /journal slash command survives the redirect"); assert.doesNotMatch(slash, /Journal's Canvas tab/, "/canvas no longer advertises the Canvas page"); const artifactViewer = read("./chat-artifact-viewer.tsx"); @@ -140,22 +112,9 @@ const ghReview = read("./gh-review-actions.tsx"); assert.doesNotMatch(ghReview, /cave:canvas:layer|mode: "canvas"/, "PR review export no longer jumps to the retired Canvas page"); assert.match(ghReview, /openArtifactHtml\(artifact\.code\)/, "exported review artifacts open directly in a browser tab"); -// ── Settings host: dead workspace events are avoided ───────────────────────── -assert.match(wrapper, /standalone/, "the studio tab renders JournalEntries in standalone mode"); -assert.match( - entriesSrc, - /AUTOMATIONS\.filter\(\(a\) => a\.action !== "run"\)/, - "standalone mode hides the chat-handoff automation", -); -assert.match( - entriesSrc, - /window\.location\.assign\(`\/\?mode=\$\{encodeURIComponent\(notice\.action!\.mode\)\}`\)/, - "standalone toast actions deep-link back into the workspace", -); - // ── One-entry-per-day store: no silent cross-familiar overwrite ────────────── assert.match(entriesSrc, /const outOfScopeBy =/, "derives the out-of-scope author"); assert.match(entriesSrc, /if \(outOfScopeBy\) return;/, "generate refuses to overwrite an out-of-scope entry"); assert.match(entriesSrc, /written by \$\{outOfScopeBy\}/, "the empty state names the actual author instead of inviting an overwrite"); -console.log("familiar-studio-journal-tab.test.ts: ok"); +console.log("journal-redirect.test.ts: ok"); diff --git a/src/components/journal/journal-entries.tsx b/src/components/journal/journal-entries.tsx index 0890b70b7..f087f20ac 100644 --- a/src/components/journal/journal-entries.tsx +++ b/src/components/journal/journal-entries.tsx @@ -56,17 +56,11 @@ function NextPaths({ familiarId, onNotice, onError, - standalone, }: { suggestions: string[]; familiarId: string | null; onNotice: NoticeFn; onError: (text: string) => void; - /** When true (Settings host), filter out actions that require the workspace - * event bus. "Run now" dispatches cave:agents-new-chat which has no listener - * on /settings, so it is hidden; "Add task" and "Remind me" are plain fetches - * and remain available. */ - standalone?: boolean; }) { // The recommended (first) step is expanded by default so the automate // affordances are discoverable without a hunt. @@ -145,7 +139,7 @@ function NextPaths({ [familiarId, flashDone, onNotice, onError], ); - const actions = standalone ? AUTOMATIONS.filter((a) => a.action !== "run") : AUTOMATIONS; + const actions = AUTOMATIONS; if (suggestions.length === 0) return null; return (
@@ -208,13 +202,11 @@ function JournalReflection({ familiarId, onNotice, onError, - standalone, }: { text: string; familiarId: string | null; onNotice: NoticeFn; onError: (text: string) => void; - standalone?: boolean; }) { // Memoize so `suggestions` keeps a stable identity across parent re-renders // (e.g. the notice toast updating) — otherwise NextPaths' open-step effect @@ -223,7 +215,7 @@ function JournalReflection({ return ( <> - + ); } @@ -232,18 +224,12 @@ export function JournalEntries({ familiars, activeFamiliarId, scopeFamiliarIds, - standalone, }: { familiars: Familiar[]; activeFamiliarId: string | null; /** Multiselect scope (empty = All) — the reflections list filters to days * whose `reflectedBy` is in this set. */ scopeFamiliarIds?: ReadonlySet; - /** True when rendered outside the Workspace (Settings → Familiars studio tab). - * The workspace event bus has no listeners there: "Run now" is hidden (its - * chat handoff can't happen) and toast actions become real navigations via - * the `?mode=` deep link. */ - standalone?: boolean; }) { useDateTimePrefs(); // subscribe: re-render when the date/time density pref changes // One clock read per render — reused for `today`, list labels, and the detail @@ -747,7 +733,6 @@ export function JournalEntries({ familiarId={selectedFamiliarId} onNotice={showNotice} onError={setError} - standalone={standalone} /> )}
@@ -801,14 +786,9 @@ export function JournalEntries({ type="button" className="journal-notice__act" onClick={() => { - if (standalone) { - // No workspace on /settings — deep-link back into it. - window.location.assign(`/?mode=${encodeURIComponent(notice.action!.mode)}`); - } else { - window.dispatchEvent( - new CustomEvent("cave:navigate-mode", { detail: { mode: notice.action!.mode } }), - ); - } + window.dispatchEvent( + new CustomEvent("cave:navigate-mode", { detail: { mode: notice.action!.mode } }), + ); setNotice(null); }} > diff --git a/src/components/settings-action-buttons.test.ts b/src/components/settings-action-buttons.test.ts index 01e473f0b..7b54849aa 100644 --- a/src/components/settings-action-buttons.test.ts +++ b/src/components/settings-action-buttons.test.ts @@ -11,7 +11,7 @@ const studioSources = [ "familiar-studio-brain-tab.tsx", "familiar-studio-contract-tab.tsx", "familiar-studio-identity-tab.tsx", - "familiar-studio-lifecycle-tab.tsx", + "familiar-lifecycle-section.tsx", "familiar-studio-look-tab.tsx", "familiar-studio-projects-tab.tsx", ].map((fileName) => [fileName, readFileSync(new URL(`./${fileName}`, import.meta.url), "utf8")] as const); @@ -95,7 +95,6 @@ assert.match( for (const selector of [ ".familiar-studio-picker__trigger", ".familiar-studio-picker__option", - ".familiar-studio-lifecycle__row", ]) { const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); assert.match( diff --git a/src/components/settings-search.test.ts b/src/components/settings-search.test.ts index c20cc2db3..72307aca9 100644 --- a/src/components/settings-search.test.ts +++ b/src/components/settings-search.test.ts @@ -42,13 +42,21 @@ assert.match( /familiarTab\?: FamiliarStudioTab/, "index entries can target a Familiars studio tab", ); -for (const tab of ["identity", "look", "brain", "lifecycle", "memory", "projects", "vault", "journal"]) { +for (const tab of ["identity", "brain", "memory", "projects", "vault"]) { assert.match( sections, new RegExp(`familiarTab: "${tab}"`), `the ${tab} studio tab is indexed for search`, ); } +// Look/Lifecycle/Journal merged or moved out — no rows target retired tabs. +for (const gone of ["look", "lifecycle", "journal"]) { + assert.doesNotMatch( + sections, + new RegExp(`familiarTab: "${gone}"`), + `no search row targets the retired ${gone} tab`, + ); +} // Picking a familiars entry activates the studio tab below the provider // instead of scrolling to a SettingsGroup (the panel has none). assert.match(shell, /if \(entry\.familiarTab\) \{[\s\S]*?setFamiliarsTabTarget\(entry\.familiarTab\)/, "goToSetting branches familiars entries to the tab target"); diff --git a/src/components/settings-sections.ts b/src/components/settings-sections.ts index 17278189a..009d40f2f 100644 --- a/src/components/settings-sections.ts +++ b/src/components/settings-sections.ts @@ -59,12 +59,9 @@ export const SETTINGS_INDEX: SettingsIndexEntry[] = [ { section: "daemon", group: "Connection", keywords: "daemon hub server executor private network tailscale remote multihost multi host" }, { section: "daemon", group: "Info", keywords: "daemon info version socket pid api" }, { section: "familiars", keywords: "familiars agents personas roster" }, - { section: "familiars", group: "Identity", familiarTab: "identity", keywords: "identity name role pronouns description rename" }, - { section: "familiars", group: "Look", familiarTab: "look", keywords: "look avatar image photo upload icon glyph color accent swatch palette" }, + { section: "familiars", group: "Identity", familiarTab: "identity", keywords: "identity name role pronouns description rename look avatar image photo upload icon glyph color accent swatch palette backdrop lifecycle archive unarchive remove delete restore recently removed" }, { section: "familiars", group: "Brain", familiarTab: "brain", keywords: "brain runtime harness model voice system prompt note capabilities" }, - { section: "familiars", group: "Lifecycle", familiarTab: "lifecycle", keywords: "lifecycle archive unarchive reorder roster order reset overrides" }, { section: "familiars", group: "Memory", familiarTab: "memory", keywords: "memory memories daily notes recall" }, - { section: "familiars", group: "Journal", familiarTab: "journal", keywords: "journal daily reflection reflections diary entries generate" }, { section: "familiars", group: "Projects", familiarTab: "projects", keywords: "projects access grants allow deny tool policy guard security audit requests permissions read write level" }, { section: "familiars", group: "Access groups", keywords: "access groups group grants base projects read write level team role shared membership permissions" }, { section: "familiars", group: "Vault", familiarTab: "vault", keywords: "vault secrets env environment keys tokens credentials 1password" }, diff --git a/src/components/workspace.tsx b/src/components/workspace.tsx index 68572c6f9..fcaf49b55 100644 --- a/src/components/workspace.tsx +++ b/src/components/workspace.tsx @@ -351,8 +351,7 @@ export function Workspace() { // Journal is now a tab inside the Grimoire surface. Every entry point // (sidebar row, ⌘K palette, ?mode= deep link, cave:navigate-mode, // dashboard links) funnels through setMode, so opening Grimoire on its - // Journal tab here covers them all. (Per-familiar journals still live in - // Settings → Familiars → Journal.) + // Journal tab here covers them all. setGrimoireView("journal"); setModeRaw("grimoire"); return; diff --git a/src/lib/familiar-studio-context.tsx b/src/lib/familiar-studio-context.tsx index 6f1a808c5..79a918d07 100644 --- a/src/lib/familiar-studio-context.tsx +++ b/src/lib/familiar-studio-context.tsx @@ -11,10 +11,10 @@ import { } from "react"; export type FamiliarStudioTab = - | "identity" | "look" | "brain" | "lifecycle" | "memory" | "projects" | "contract" | "vault" | "journal"; + | "identity" | "brain" | "memory" | "projects" | "contract" | "vault"; const STUDIO_TABS: readonly FamiliarStudioTab[] = [ - "identity", "look", "brain", "lifecycle", "memory", "projects", "contract", "vault", "journal", + "identity", "brain", "memory", "projects", "contract", "vault", ]; const TAB_STORAGE_KEY = "cave:familiar-studio-tab:v1"; @@ -33,7 +33,7 @@ export const BRAIN_STUDIO_FAMILIAR_KEY = "cave:brain-studio-familiar:v1"; * Hard-navigate to Settings → Familiars with an optional studio tab and * familiar preselected. This is the single redirect path shared by the * workspace-level provider (`redirectToSettings`) and workspace surfaces that - * retired their own page (e.g. the Journal, now a studio tab). + * retired their own page in favor of the studio. */ export function openFamiliarStudioSettingsTab(tab?: FamiliarStudioTab, familiarId?: string): void { if (typeof window === "undefined") return; @@ -49,10 +49,9 @@ export function openFamiliarStudioSettingsTab(tab?: FamiliarStudioTab, familiarI type Ctx = { /** `null` means closed; a string id means open for a specific familiar. */ activeFamiliarId: string | null; - /** `true` means open in no-familiar list view (Lifecycle tab only). */ - listView: boolean; activeTab: FamiliarStudioTab; openFamiliarStudio: (id: string, tab?: FamiliarStudioTab) => void; + /** Opens the familiars manager (Settings → Familiars) without forcing a tab. */ openFamiliarStudioListView: () => void; closeFamiliarStudio: () => void; setActiveTab: (tab: FamiliarStudioTab) => void; @@ -76,7 +75,6 @@ export function FamiliarStudioProvider({ redirectToSettings?: boolean; }) { const [activeFamiliarId, setActiveFamiliarId] = useState(null); - const [listView, setListView] = useState(false); const [activeTab, setActiveTabState] = useState(DEFAULT_TAB); // Restore last-used tab on mount. @@ -102,38 +100,36 @@ export function FamiliarStudioProvider({ return; } setActiveFamiliarId(id); - setListView(false); if (tab) setActiveTab(tab); }, [setActiveTab, redirectToSettings], ); + // "Manage familiars" entry point: with the roster manager retired, this just + // opens Settings → Familiars (keeping the last-used tab); the inline panel + // auto-selects a familiar. const openFamiliarStudioListView = useCallback(() => { if (redirectToSettings) { - openFamiliarStudioSettingsTab("lifecycle"); + openFamiliarStudioSettingsTab(); return; } setActiveFamiliarId(null); - setListView(true); - setActiveTab("lifecycle"); - }, [setActiveTab, redirectToSettings]); + }, [redirectToSettings]); const closeFamiliarStudio = useCallback(() => { setActiveFamiliarId(null); - setListView(false); }, []); const value = useMemo( () => ({ activeFamiliarId, - listView, activeTab, openFamiliarStudio, openFamiliarStudioListView, closeFamiliarStudio, setActiveTab, }), - [activeFamiliarId, listView, activeTab, openFamiliarStudio, openFamiliarStudioListView, closeFamiliarStudio, setActiveTab], + [activeFamiliarId, activeTab, openFamiliarStudio, openFamiliarStudioListView, closeFamiliarStudio, setActiveTab], ); return {children}; diff --git a/src/lib/slash-commands.ts b/src/lib/slash-commands.ts index 4f460f32b..7fab92765 100644 --- a/src/lib/slash-commands.ts +++ b/src/lib/slash-commands.ts @@ -44,7 +44,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { name: "/sessions", hint: "all sessions", description: "Open all sessions across familiars and runtimes.", section: "view" }, { name: "/attach", hint: "open session", description: "Open a specific daemon session by id.", argPlaceholder: "session-id", section: "view" }, { name: "/tui", hint: "open in Coven Code", description: "Open the current session in the external Coven Code TUI.", section: "view" }, - { name: "/journal", hint: "Journal", description: "Open your familiars' journal (Settings → Familiars → Journal).", section: "view" }, + { name: "/journal", hint: "Journal", description: "Open your familiars' journal (Memories → Journal).", section: "view" }, { name: "/canvas", hint: "sketch a UI", description: "Generate a UI artifact inline in chat.", argPlaceholder: "describe a UI…", section: "view" }, { name: "/board", hint: "Tasks", description: "Open the Tasks kanban and table view.", section: "view" }, { name: "/chats", hint: "Chats", description: "Switch back to the Chats view.", section: "view" }, diff --git a/src/lib/surface-warmup-registry.test.ts b/src/lib/surface-warmup-registry.test.ts index 0b37d9ecf..0b762d080 100644 --- a/src/lib/surface-warmup-registry.test.ts +++ b/src/lib/surface-warmup-registry.test.ts @@ -53,7 +53,7 @@ test("warmup starts after paint and pauses work without mounting inactive surfac test("roster membership changes publish the workspace familiar-refresh event", async () => { for (const sourcePath of [ "../components/familiar-summoning-circle.tsx", - "../components/familiar-studio-lifecycle-tab.tsx", + "../components/familiar-lifecycle-section.tsx", ]) { const code = await readFile(new URL(sourcePath, here), "utf8"); assert.match(code, /window\.dispatchEvent\(new Event\("cave:familiars-refresh"\)\)/, `${sourcePath} invalidates warmed familiar consumers`); diff --git a/src/styles/globals/shell-responsive.css b/src/styles/globals/shell-responsive.css index d0bd008ff..a3899b4bb 100644 --- a/src/styles/globals/shell-responsive.css +++ b/src/styles/globals/shell-responsive.css @@ -998,21 +998,15 @@ } } .familiar-studio-lifecycle { display: flex; flex-direction: column; gap: 18px; } +.familiar-studio-lifecycle__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); } .familiar-studio-lifecycle__section { display: flex; flex-direction: column; gap: 6px; } .familiar-studio-lifecycle__heading { font-size: 11px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em; margin: 0; } .familiar-studio-lifecycle__hint { margin: 4px 0 8px; font-size: var(--text-2xs); line-height: 1.5; color: var(--text-muted); } .familiar-studio-lifecycle__hint { font-size: 12px; color: var(--text-muted); line-height: 1.4; margin: 0; } -.familiar-studio-lifecycle__row { display: flex; align-items: center; gap: 4px; padding: 4px; border-radius: var(--radius-control); } -.familiar-studio-lifecycle__row:hover { background: var(--bg-raised); } -.familiar-studio-lifecycle__row[data-dragging] { background: var(--bg-raised); box-shadow: var(--shadow-popover); opacity: 0.95; position: relative; z-index: 1; } -.familiar-studio-lifecycle__grip { display: grid; place-items: center; width: 20px; height: 24px; flex-shrink: 0; background: transparent; border: none; color: var(--text-faint); cursor: grab; touch-action: none; } -.familiar-studio-lifecycle__grip:hover { color: var(--text-secondary); } -.familiar-studio-lifecycle__row[data-dragging] .familiar-studio-lifecycle__grip { cursor: grabbing; } -.familiar-studio-lifecycle__row-main { display: flex; gap: 8px; align-items: center; flex: 1; background: transparent; border: none; padding: 4px 6px; cursor: pointer; color: var(--text-primary); font-size: 13px; } /* Remove-confirm strip: detach semantics spelled out inline before scheduling the undo-safe delete (danger tint recipe: solid text / 6% fill / 35% border). */ -.familiar-studio-lifecycle__confirm { display: flex; flex-direction: column; gap: 8px; margin: 2px 0 6px 28px; padding: 10px 12px; border: 1px solid color-mix(in oklch, var(--color-danger) 35%, transparent); border-radius: 8px; background: color-mix(in oklch, var(--color-danger) 6%, var(--bg-raised)); } +.familiar-studio-lifecycle__confirm { display: flex; flex-direction: column; gap: 8px; margin: 2px 0 6px; padding: 10px 12px; border: 1px solid color-mix(in oklch, var(--color-danger) 35%, transparent); border-radius: 8px; background: color-mix(in oklch, var(--color-danger) 6%, var(--bg-raised)); } .familiar-studio-lifecycle__confirm-title { margin: 0; font-size: 12px; font-weight: 600; color: var(--text-primary); } .familiar-studio-lifecycle__confirm-copy { margin: 0; font-size: 12px; line-height: 1.5; color: var(--text-secondary); } .familiar-studio-lifecycle__confirm-warn { color: var(--color-warning); } @@ -1075,9 +1069,7 @@ .familiar-studio-look__custom:focus-visible, .familiar-studio-look__avatar-zoom:focus-visible, .familiar-studio-look__upload:focus-visible, -.familiar-studio-brain__capabilities-summary:focus-visible, -.familiar-studio-lifecycle__row-main:focus-visible, -.familiar-studio-lifecycle__grip:focus-visible { +.familiar-studio-brain__capabilities-summary:focus-visible { outline: var(--ring-width) solid var(--ring-focus); outline-offset: 1px; } diff --git a/src/styles/journal.css b/src/styles/journal.css index 2a4691597..b8f37d550 100644 --- a/src/styles/journal.css +++ b/src/styles/journal.css @@ -489,13 +489,6 @@ } .journal-entry__by b { color: var(--accent-presence); } -/* ── Familiar Studio host ───────────────────────────────────────────────────── - Inside Settings → Familiars the journal has no full-page flex parent, so the - master-detail shell gets an explicit bounded height: the day rail and the - detail pane scroll internally instead of growing the settings page. */ -.familiar-studio-journal { display: flex; min-width: 0; } -.familiar-studio-journal .journal-list { height: min(65vh, 720px); } - /* The section labels are real headings now (cave-t1ou) — inherit the label styling instead of UA heading defaults. */ .journal-entry__sec-heading {