init: arcadia-admin — admin webapp for arcadia-core, cloned from vibespace

Initial commit. Spun up via the docs/STARTER.md recipe: cp from vibespace,
reset git, rename package, set brand to "Arcadia Admin" with Shield icon
in app/lib/identity.ts.

Inherits the full Crema sibling-lib wiring including @crema/arcadia-client
(typed HTTP + Phoenix Channels realtime against arcadia-core) and
@crema/arcadia-auth-ui (login/signup/password-reset/2FA forms). The /login
route already renders <LoginForm>; <ArcadiaProvider> in app/root.tsx reads
VITE_ARCADIA_URL (default localhost:4000) and VITE_ARCADIA_TENANT (default
"default").

CLAUDE.md and README rewritten to frame this as the admin app for
arcadia-core. docs/STARTER.md removed — arcadia-admin is a leaf consumer,
not a downstream starter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-04-29 21:28:39 +10:00
commit f8cbf142b5
108 changed files with 23740 additions and 0 deletions

222
app/lib/threads.ts Normal file
View File

@@ -0,0 +1,222 @@
// Conversation threads — multiple named chats, each with its own history,
// active agent, and pinned message indices. Persisted in localStorage.
import { useEffect, useSyncExternalStore } from "react"
export type ThreadMessage = {
role: "user" | "assistant"
content: string
/** Persona that authored this assistant message (omitted for user msgs). */
agentId?: string
}
export type Thread = {
id: string
title: string
agentId: string
messages: ThreadMessage[]
pinned: number[] // indices into messages[]
createdAt: number
updatedAt: number
}
const THREADS_KEY = "crema.assistant.threads"
const ACTIVE_KEY = "crema.assistant.activeThreadId"
const SNAPSHOT_KEY_PREFIX = "crema.assistant.thread.snapshot."
const CHANGE_EVENT = "crema:threads-change"
const MAX_BYTES = 800_000
export function newThreadId(): string {
return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
}
function isThread(v: unknown): v is Thread {
if (!v || typeof v !== "object") return false
const t = v as Thread
return (
typeof t.id === "string" &&
typeof t.title === "string" &&
typeof t.agentId === "string" &&
Array.isArray(t.messages) &&
Array.isArray(t.pinned) &&
typeof t.createdAt === "number" &&
typeof t.updatedAt === "number"
)
}
function readFromStorage(): Thread[] {
if (typeof window === "undefined") return []
try {
const raw = localStorage.getItem(THREADS_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(isThread)
} catch {
return []
}
}
function writeToStorage(threads: Thread[]) {
if (typeof window === "undefined") return
let serialized = JSON.stringify(threads)
// Trim oldest threads if quota gets tight.
let trimmed = threads
while (serialized.length > MAX_BYTES && trimmed.length > 1) {
trimmed = trimmed.slice(0, -1)
serialized = JSON.stringify(trimmed)
}
try {
localStorage.setItem(THREADS_KEY, serialized)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
/* quota — bail */
}
}
export function loadThreads(): Thread[] {
return readFromStorage()
}
export function saveThreads(threads: Thread[]) {
writeToStorage(threads)
}
export function loadActiveThreadId(): string | null {
if (typeof window === "undefined") return null
return localStorage.getItem(ACTIVE_KEY)
}
export function saveActiveThreadId(id: string | null) {
if (typeof window === "undefined") return
if (id) localStorage.setItem(ACTIVE_KEY, id)
else localStorage.removeItem(ACTIVE_KEY)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
let cached: Thread[] | null = null
function subscribe(cb: () => void): () => void {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === THREADS_KEY || e.key === ACTIVE_KEY) onChange()
})
return () => {
window.removeEventListener(CHANGE_EVENT, onChange)
}
}
function getSnapshot(): Thread[] {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): Thread[] {
return []
}
export function useThreads(): Thread[] {
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return value
}
export function ensureThread(
threads: Thread[],
fallbackAgentId: string,
): { threads: Thread[]; activeId: string } {
const stored = loadActiveThreadId()
if (stored && threads.some((t) => t.id === stored))
return { threads, activeId: stored }
if (threads.length > 0) {
saveActiveThreadId(threads[0].id)
return { threads, activeId: threads[0].id }
}
const id = newThreadId()
const now = Date.now()
const fresh: Thread = {
id,
title: "New conversation",
agentId: fallbackAgentId,
messages: [],
pinned: [],
createdAt: now,
updatedAt: now,
}
saveActiveThreadId(id)
saveThreads([fresh, ...threads])
return { threads: [fresh, ...threads], activeId: id }
}
export function updateThread(id: string, patch: Partial<Thread>) {
const threads = readFromStorage()
const next = threads.map((t) =>
t.id === id ? { ...t, ...patch, updatedAt: Date.now() } : t,
)
writeToStorage(next)
}
export function createThread(agentId: string, title = "New conversation"): Thread {
const threads = readFromStorage()
const id = newThreadId()
const now = Date.now()
const fresh: Thread = {
id,
title,
agentId,
messages: [],
pinned: [],
createdAt: now,
updatedAt: now,
}
writeToStorage([fresh, ...threads])
saveActiveThreadId(id)
return fresh
}
export function deleteThread(id: string) {
const threads = readFromStorage()
const next = threads.filter((t) => t.id !== id)
writeToStorage(next)
if (loadActiveThreadId() === id) {
saveActiveThreadId(next[0]?.id ?? null)
}
}
export function snapshotThread(id: string) {
const threads = readFromStorage()
const t = threads.find((x) => x.id === id)
if (!t) return
try {
localStorage.setItem(SNAPSHOT_KEY_PREFIX + id, JSON.stringify(t))
} catch {
/* quota */
}
}
export function loadThreadSnapshot(id: string): Thread | null {
if (typeof window === "undefined") return null
try {
const raw = localStorage.getItem(SNAPSHOT_KEY_PREFIX + id)
if (!raw) return null
const parsed = JSON.parse(raw)
return isThread(parsed) ? parsed : null
} catch {
return null
}
}
export function clearThreadSnapshot(id: string) {
if (typeof window === "undefined") return
localStorage.removeItem(SNAPSHOT_KEY_PREFIX + id)
}
export function deriveTitleFromFirstMessage(text: string): string {
const trimmed = text.trim().split(/\s+/).slice(0, 8).join(" ")
return trimmed.length > 60 ? trimmed.slice(0, 57) + "…" : trimmed || "New conversation"
}