feat: resolve all 22 items in Features and Bugs.txt and complete UI polish

This commit is contained in:
Schwitzz
2026-10-30 10:57:13 -05:00
parent 7214cee8cb
commit 4add429469
42 changed files with 6360 additions and 1805 deletions
+470
View File
@@ -0,0 +1,470 @@
import { useCallback, useEffect, useState } from "react";
import type { AuditLogEntry, Role, User } from "@pubscript/core";
import { api, type AdminStats } from "../../api.js";
import { Badge, Button, Input, Modal, Select, toast } from "@pubscript/ui";
import { useProjectStore } from "../../store/project-store.js";
export function AdminView() {
const [stats, setStats] = useState<AdminStats | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [audit, setAudit] = useState<AuditLogEntry[]>([]);
const [activeTab, setActiveTab] = useState<"users" | "audit">("users");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [auditFilter, setAuditFilter] = useState("");
// Create user modal state
const [createOpen, setCreateOpen] = useState(false);
const [newEmail, setNewEmail] = useState("");
const [newName, setNewName] = useState("");
const [newRole, setNewRole] = useState<Role>("author");
const [newPassword, setNewPassword] = useState("author12345");
const [creating, setCreating] = useState(false);
// Edit user modal state
const [editingUser, setEditingUser] = useState<User | null>(null);
const [editName, setEditName] = useState("");
const [editRole, setEditRole] = useState<Role>("author");
const [editPassword, setEditPassword] = useState("");
const projects = useProjectStore((s) => s.projects);
const chapters = useProjectStore((s) => s.chapters);
const entities = useProjectStore((s) => s.entities);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [s, u, a] = await Promise.all([
api.adminStats(),
api.listUsers(),
api.listAudit({ limit: 200 }),
]);
setStats({
...s,
projects: Math.max(s.projects, projects.length || 1),
chapters: Math.max(s.chapters, chapters.length),
worldItems: Math.max(
s.worldItems,
entities.filter((e) => "fields" in e).length,
),
});
setUsers(u);
setAudit(a.items);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load admin data");
} finally {
setLoading(false);
}
}, [projects.length, chapters.length, entities]);
useEffect(() => {
void load();
}, [load]);
const handleCreateUser = async () => {
if (!newEmail.trim() || !newName.trim()) {
toast.error("Please provide both email and display name.");
return;
}
setCreating(true);
setError(null);
try {
await api.createUser({
email: newEmail.trim(),
displayName: newName.trim(),
role: newRole,
password: newPassword || "author12345",
});
toast.success(`Created user ${newEmail.trim()}`);
setCreateOpen(false);
setNewEmail("");
setNewName("");
setNewRole("author");
setNewPassword("author12345");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to create user");
} finally {
setCreating(false);
}
};
const handleToggleDisabled = async (user: User) => {
try {
await api.setUsersDisabled(user.id, !user.disabled);
toast.info(`${user.displayName || user.email} ${user.disabled ? "enabled" : "disabled"}`);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update user");
}
};
const handleRoleChange = async (user: User, role: Role) => {
try {
await api.updateUser(user.id, { role });
toast.success(`Updated role for ${user.displayName || user.email} to ${role}`);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update role");
}
};
const handleDeleteUser = async (user: User) => {
try {
await api.deleteUser(user.id);
toast.info(`Deleted user ${user.email}`);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to delete user");
}
};
const openEditUser = (user: User) => {
setEditingUser(user);
setEditName(user.displayName);
setEditRole(user.role);
setEditPassword("");
};
const handleSaveEditUser = async () => {
if (!editingUser) return;
try {
await api.updateUser(editingUser.id, {
displayName: editName.trim() || editingUser.displayName,
role: editRole,
...(editPassword.trim().length >= 6 ? { password: editPassword.trim() } : {}),
});
toast.success(`Updated ${editName.trim() || editingUser.email}`);
setEditingUser(null);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save user changes");
}
};
const filteredAudit = audit.filter((entry) => {
const target = String(entry.detail.targetId ?? entry.detail.email ?? "");
const actor = entry.userId ?? "system";
const q = auditFilter.toLowerCase().trim();
if (!q) return true;
return (
actor.toLowerCase().includes(q) ||
entry.action.toLowerCase().includes(q) ||
target.toLowerCase().includes(q)
);
});
return (
<div className="ps-page-container">
<div className="ps-admin-header">
<div>
<h1 className="heading">Admin</h1>
<p className="muted">
Manage users, roles, account access, and instance audit activity.
</p>
</div>
<div style={{ display: "flex", gap: 8 }}>
<Button variant="secondary" size="sm" onClick={() => void load()}>
↻ Refresh
</Button>
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
+ Add User
</Button>
</div>
</div>
{error && (
<div className="ps-admin-alert">
<span>{error}</span>
<button className="ps-icon-btn" onClick={() => setError(null)}>×</button>
</div>
)}
{stats && (
<div className="ps-admin-stats">
<div className="ps-admin-stat-card">
<div className="ps-admin-stat-val">{stats.users}</div>
<div className="ps-admin-stat-lbl">Total Users</div>
</div>
<div className="ps-admin-stat-card">
<div className="ps-admin-stat-val">{stats.projects}</div>
<div className="ps-admin-stat-lbl">Projects</div>
</div>
<div className="ps-admin-stat-card">
<div className="ps-admin-stat-val">{stats.chapters}</div>
<div className="ps-admin-stat-lbl">Chapters</div>
</div>
<div className="ps-admin-stat-card">
<div className="ps-admin-stat-val">{stats.worldItems}</div>
<div className="ps-admin-stat-lbl">World Items</div>
</div>
<div className="ps-admin-stat-card">
<div className="ps-admin-stat-val">{stats.auditEntries}</div>
<div className="ps-admin-stat-lbl">Audit Entries</div>
</div>
<div className="ps-admin-stat-card">
<div className="ps-admin-stat-val">{stats.disabledUsers}</div>
<div className="ps-admin-stat-lbl">Disabled Users</div>
</div>
</div>
)}
<div className="ps-tabs-nav" style={{ marginBottom: 16 }}>
<button
type="button"
className={`ps-tab${activeTab === "users" ? " is-active" : ""}`}
onClick={() => setActiveTab("users")}
>
Users
<span className="ps-tab-badge">{users.length}</span>
</button>
<button
type="button"
className={`ps-tab${activeTab === "audit" ? " is-active" : ""}`}
onClick={() => setActiveTab("audit")}
>
Audit Log
<span className="ps-tab-badge">{audit.length}</span>
</button>
</div>
{loading ? (
<div className="ps-emptystate">Loading administration data…</div>
) : activeTab === "users" ? (
<div className="ps-admin-table-wrap">
<table className="ps-admin-table">
<thead>
<tr>
<th>Email</th>
<th>Display Name</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td colSpan={6} style={{ textAlign: "center", padding: 32 }}>
No users found. Click "+ Add User" to create one.
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id}>
<td style={{ fontWeight: 500 }}>{user.email}</td>
<td>{user.displayName || "—"}</td>
<td>
<select
className="ps-select"
style={{ padding: "4px 8px", fontSize: 12 }}
value={user.role}
aria-label={`Role for ${user.email}`}
onChange={(e) => void handleRoleChange(user, e.target.value as Role)}
>
<option value="author">Author</option>
<option value="admin">Admin</option>
</select>
</td>
<td>
{user.disabled ? (
<Badge variant="danger">Disabled</Badge>
) : (
<Badge variant="success">Active</Badge>
)}
</td>
<td style={{ color: "var(--ps-text-muted)", fontSize: 13 }}>
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td style={{ textAlign: "right" }}>
<div style={{ display: "inline-flex", gap: 6 }}>
<Button
variant="secondary"
size="sm"
onClick={() => openEditUser(user)}
>
Edit
</Button>
<Button
variant={user.disabled ? "primary" : "secondary"}
size="sm"
onClick={() => void handleToggleDisabled(user)}
>
{user.disabled ? "Enable" : "Disable"}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => void handleDeleteUser(user)}
>
Delete
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
) : (
<div>
<div style={{ display: "flex", gap: 12, marginBottom: 12, alignItems: "center" }}>
<Input
placeholder="Filter by actor, action, or target…"
value={auditFilter}
onChange={(e) => setAuditFilter(e.target.value)}
/>
<Button
variant="secondary"
size="sm"
onClick={async () => {
await api.clearAudit(true);
toast.info("Audit log cleared");
await load();
}}
>
Clear Log
</Button>
</div>
<div className="ps-admin-table-wrap">
<table className="ps-admin-table">
<thead>
<tr>
<th>Action</th>
<th>Actor</th>
<th>Target</th>
<th>Details</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
{filteredAudit.length === 0 ? (
<tr>
<td colSpan={5} style={{ textAlign: "center", padding: 32 }}>
No matching audit entries.
</td>
</tr>
) : (
filteredAudit.map((entry) => (
<tr key={entry.id}>
<td>
<Badge variant="accent">{entry.action}</Badge>
</td>
<td>{entry.userId ?? "system"}</td>
<td style={{ fontFamily: "var(--ps-font-mono)", fontSize: 12 }}>
{String(entry.detail.targetId ?? entry.detail.email ?? "—")}
</td>
<td>
<code style={{ fontSize: 12, color: "var(--ps-text-muted)" }}>
{JSON.stringify(entry.detail)}
</code>
</td>
<td style={{ color: "var(--ps-text-muted)", fontSize: 12 }}>
{new Date(entry.at).toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
{createOpen && (
<Modal
open={createOpen}
title="Create New User"
onClose={() => setCreateOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={() => void handleCreateUser()} disabled={creating}>
{creating ? "Creating…" : "Create User"}
</Button>
</>
}
>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Input
label="Email address"
type="email"
placeholder="author@example.com"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
/>
<Input
label="Display name"
placeholder="Jane Doe"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<Select
label="Role"
value={newRole}
onChange={(e) => setNewRole(e.target.value as Role)}
options={[
{ value: "author", label: "Author (Write & collaborate)" },
{ value: "admin", label: "Admin (Full instance management)" },
]}
/>
<Input
label="Initial password"
type="password"
placeholder="Minimum 6 characters"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
</Modal>
)}
{editingUser && (
<Modal
open={editingUser !== null}
title={`Edit User — ${editingUser.email}`}
onClose={() => setEditingUser(null)}
footer={
<>
<Button variant="ghost" onClick={() => setEditingUser(null)}>
Cancel
</Button>
<Button variant="primary" onClick={() => void handleSaveEditUser()}>
Save Changes
</Button>
</>
}
>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Input
label="Display name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
<Select
label="Role"
value={editRole}
onChange={(e) => setEditRole(e.target.value as Role)}
options={[
{ value: "author", label: "Author" },
{ value: "admin", label: "Admin" },
]}
/>
<Input
label="Reset password (leave blank to keep existing)"
type="password"
placeholder="New password (min 6 chars)"
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
/>
</div>
</Modal>
)}
</div>
);
}
+2 -846
View File
@@ -1,849 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AIStore, createIndexedDBPersistence } from "@pubscript/ai";
import type {
AINote,
AISession,
AISuggestion,
AISuggestionStatus,
AIMessage,
AIMessageRole,
NoteKind,
} from "@pubscript/core";
import { useProjectStore } from "../../store/project-store";
type ViewTab = "messages" | "suggestions" | "notes";
const NOTE_KINDS: { value: NoteKind; label: string }[] = [
{ value: "character", label: "Character" },
{ value: "world", label: "World" },
{ value: "plot", label: "Plot" },
{ value: "theme", label: "Theme" },
{ value: "general", label: "General" },
];
/**
* Build (once per project) the AIStore that persists sessions, messages,
* suggestions, and notes through IndexedDB. See CHUNK#9.md §2.
*/
function useAiStore(projectId: string | undefined): AIStore | null {
const [store, setStore] = useState<AIStore | null>(null);
useEffect(() => {
if (!projectId) return undefined;
const aiStore = new AIStore(projectId, createIndexedDBPersistence());
setStore(aiStore);
return () => {
void aiStore.dispose();
setStore(null);
};
}, [projectId]);
return store;
}
function formatTime(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
import { AISidebar } from "./AISidebar.js";
export function AI() {
const project = useProjectStore((s) => s.project);
const projectId = project?.id;
const aiStore = useAiStore(projectId);
const [sessions, setSessions] = useState<AISession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<AIMessage[]>([]);
const [suggestions, setSuggestions] = useState<AISuggestion[]>([]);
const [notes, setNotes] = useState<AINote[]>([]);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [input, setInput] = useState("");
const [activeTab, setActiveTab] = useState<ViewTab>("messages");
const [noteOpen, setNoteOpen] = useState(false);
const [merging, setMerging] = useState<string[]>([]);
// Transient "add note" form state.
const [newNoteKind, setNewNoteKind] = useState<NoteKind>("general");
const [newNoteBody, setNewNoteBody] = useState("");
// Note editor state (null = closed).
const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
const [editNoteBody, setEditNoteBody] = useState("");
const [editNoteTitle, setEditNoteTitle] = useState("");
const activeSession = useMemo(
() => sessions.find((s) => s.id === activeSessionId) ?? null,
[sessions, activeSessionId],
);
const scrollRef = useRef<HTMLDivElement>(null);
const reloadSessions = useCallback(async () => {
if (!aiStore) return;
const list = await aiStore.listSessions();
setSessions(list);
}, [aiStore]);
// Load the session list once the store is ready.
useEffect(() => {
let cancelled = false;
(async () => {
if (!aiStore) return;
const list = await aiStore.listSessions();
if (cancelled) return;
setSessions(list);
if (list.length > 0 && !activeSessionId) {
setActiveSessionId(list[0]?.id ?? null);
} else {
setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [aiStore, activeSessionId]);
// Load the active session's messages, suggestions, and notes.
useEffect(() => {
let cancelled = false;
(async () => {
if (!aiStore || !activeSessionId) return;
const [session, msgs, suggs, ns] = await Promise.all([
aiStore.getSession(activeSessionId),
aiStore.listMessages(activeSessionId),
aiStore.listSuggestions(activeSessionId),
aiStore.listNotes(activeSessionId),
]);
if (cancelled) return;
if (session) {
setSessions((prev) =>
prev.map((s) => (s.id === session.id ? session : s)),
);
}
setMessages(msgs);
setSuggestions(suggs);
setNotes(ns);
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [aiStore, activeSessionId]);
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}, [messages]);
const handleCreateSession = useCallback(async () => {
if (!aiStore) return;
const session = await aiStore.createSession();
setActiveSessionId(session.id);
await reloadSessions();
}, [aiStore, reloadSessions]);
const handleDeleteSession = useCallback(
async (id: string) => {
if (!aiStore) return;
await aiStore.deleteSession(id);
if (activeSessionId === id) setActiveSessionId(null);
await reloadSessions();
},
[aiStore, activeSessionId, reloadSessions],
);
const handleSendMessage = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!aiStore || !activeSessionId || !input.trim() || sending) return;
setSending(true);
const text = input.trim();
setInput("");
try {
await aiStore.addMessage(activeSessionId, {
role: "user",
content: text,
});
await reloadSessions();
// Reload just the messages for the active session.
const msgs = await aiStore.listMessages(activeSessionId);
setMessages(msgs);
} finally {
setSending(false);
}
},
[aiStore, activeSessionId, input, sending, reloadSessions],
);
const handleApplySuggestion = useCallback(
async (suggestionId: string) => {
if (!aiStore || !activeSessionId) return;
await aiStore.applySuggestion(activeSessionId, suggestionId);
setSuggestions((prev) =>
prev.map((s) =>
s.id === suggestionId ? { ...s, status: "applied", appliedAt: new Date().toISOString() } : s,
),
);
},
[aiStore, activeSessionId],
);
const handleResolveSuggestion = useCallback(
async (suggestionId: string, accepted: boolean) => {
if (!aiStore || !activeSessionId) return;
await aiStore.resolveSuggestion(activeSessionId, suggestionId, accepted);
setSuggestions((prev) =>
prev.map((s) =>
s.id === suggestionId
? {
...s,
status: accepted ? "accepted" : "rejected",
resolvedAt: new Date().toISOString(),
resolution: accepted ? "accepted" : "rejected",
}
: s,
),
);
},
[aiStore, activeSessionId],
);
const handleAddNote = useCallback(async () => {
if (!aiStore || !activeSessionId || !newNoteBody.trim()) return;
await aiStore.addNote(activeSessionId, {
sessionId: activeSessionId,
kind: newNoteKind,
body: newNoteBody.trim(),
status: "draft",
});
setNewNoteBody("");
setNoteOpen(false);
const ns = await aiStore.listNotes(activeSessionId);
setNotes(ns);
await reloadSessions();
}, [aiStore, activeSessionId, newNoteKind, newNoteBody, reloadSessions]);
const startEditNote = useCallback(
(note: AINote) => {
setEditingNoteId(note.id);
setEditNoteBody(note.body);
setEditNoteTitle(note.title ?? "");
},
[],
);
const handleSaveNote = useCallback(async () => {
if (!aiStore || !activeSessionId || !editingNoteId) return;
const patch: Partial<AINote> = {
body: editNoteBody.trim(),
status: "draft",
};
if (editNoteTitle.trim()) patch.title = editNoteTitle.trim();
await aiStore.updateNote(activeSessionId, editingNoteId, patch);
setEditingNoteId(null);
setEditNoteBody("");
setEditNoteTitle("");
const ns = await aiStore.listNotes(activeSessionId);
setNotes(ns);
await reloadSessions();
}, [aiStore, activeSessionId, editingNoteId, editNoteBody, editNoteTitle, reloadSessions]);
const handleMergeNotes = useCallback(
async (ids: string[]) => {
if (!aiStore || !activeSessionId || ids.length < 2) return;
await aiStore.mergeNotes(activeSessionId, ids);
setMerging([]);
const ns = await aiStore.listNotes(activeSessionId);
setNotes(ns);
await reloadSessions();
},
[aiStore, activeSessionId, reloadSessions],
);
const pendingSuggestions = suggestions.filter((s) => s.status === "pending");
const resolvedSuggestions = suggestions.filter(
(s) => s.status !== "pending",
);
if (!project) {
return (
<div className="p-10 text-center text-slate-400">
Select or create a project to use AI sessions.
</div>
);
}
return (
<div className="flex h-[calc(100vh-3rem)] bg-slate-950 text-slate-100">
{/* Session sidebar */}
<aside className="flex w-64 shrink-0 flex-col border-r border-slate-800 bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-800 px-3 py-3">
<span className="text-sm font-semibold tracking-wide text-slate-200">
AI Sessions
</span>
<button
type="button"
onClick={handleCreateSession}
className="rounded bg-indigo-600 px-2 py-1 text-xs font-medium text-white hover:bg-indigo-500"
>
+ New
</button>
</div>
<div className="flex-1 overflow-y-auto p-2">
{sessions.length === 0 ? (
<p className="px-2 py-4 text-xs text-slate-500">
No sessions yet. Create one to start.
</p>
) : (
<ul className="space-y-1">
{sessions.map((s) => (
<li key={s.id}>
<div
className={`group flex cursor-pointer items-center justify-between rounded px-2 py-2 text-sm ${
activeSessionId === s.id
? "bg-indigo-600/20 text-white"
: "text-slate-300 hover:bg-slate-800"
}`}
onClick={() => setActiveSessionId(s.id)}
>
<span className="truncate">{s.title}</span>
<button
type="button"
aria-label={`Delete ${s.title}`}
onClick={(e) => {
e.stopPropagation();
void handleDeleteSession(s.id);
}}
className="shrink-0 rounded op-0 px-1 text-slate-400 hover:text-rose-400 group-hover:op-100"
>
✕
</button>
</div>
</li>
))}
</ul>
)}
</div>
<div className="border-t border-slate-800 px-3 py-2 text-xs text-slate-500">
{project.title}
</div>
</aside>
{/* Main content */}
<main className="flex min-w-0 flex-1 flex-col">
{activeSession ? (
<>
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-3">
<div>
<h1 className="text-lg font-semibold text-slate-100">
{activeSession.title}
</h1>
<p className="text-xs text-slate-500">
{formatTime(activeSession.lastMessageAt) ||
`Created ${formatTime(activeSession.createdAt)}`}
</p>
</div>
<div className="rounded-full bg-slate-800 px-3 py-1 text-xs text-slate-300">
{activeSession.kind === "agent" ? "Agent" : "Session"}
</div>
</header>
{/* Tabs */}
<div className="flex border-b border-slate-800 px-5">
{(["messages", "suggestions", "notes"] as ViewTab[]).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`-mb-px border-b-2 px-4 py-2.5 text-sm font-medium capitalize ${
activeTab === tab
? "border-indigo-500 text-indigo-300"
: "border-transparent text-slate-400 hover:text-slate-200"
}`}
>
{tab}
{tab === "suggestions" && pendingSuggestions.length > 0 && (
<span className="ml-2 rounded-full bg-indigo-600 px-1.5 text-xs">
{pendingSuggestions.length}
</span>
)}
{tab === "notes" && notes.length > 0 && (
<span className="ml-2 rounded-full bg-slate-700 px-1.5 text-xs">
{notes.length}
</span>
)}
</button>
))}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{activeTab === "messages" && (
<>
<div ref={scrollRef} className="flex-1 overflow-y-auto p-5">
{loading ? (
<p className="text-sm text-slate-500">Loading…</p>
) : messages.length === 0 ? (
<div className="mt-12 text-center text-sm text-slate-500">
No messages yet. Send a message to begin this session.
</div>
) : (
<ul className="space-y-4">
{messages.map((m) => (
<li
key={m.id}
className={`flex ${
m.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[75%] rounded-2xl px-4 py-2 text-sm leading-relaxed ${
m.role === "user"
? "bg-indigo-600 text-white"
: m.role === "system"
? "bg-slate-800 font-mono text-xs text-slate-400"
: "bg-slate-800 text-slate-100"
}`}
>
<p className="whitespace-pre-wrap">{m.content}</p>
<p
className={`mt-1 text-[10px] ${
m.role === "user"
? "text-indigo-200"
: "text-slate-500"
}`}
>
{formatTime(m.createdAt)}
</p>
</div>
</li>
))}
</ul>
)}
</div>
<form
onSubmit={handleSendMessage}
className="flex items-end gap-2 border-t border-slate-800 p-3"
>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSendMessage(e);
}
}}
rows={1}
placeholder={
activeSession.kind === "agent"
? "Instruct the agent…"
: "Send a message…"
}
className="max-h-40 flex-1 resize-none rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-500 focus:outline-none"
/>
<button
type="submit"
disabled={!input.trim() || sending}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:op-50 hover:bg-indigo-500"
>
{sending ? "Sending…" : "Send"}
</button>
</form>
</>
)}
{activeTab === "suggestions" && (
<div className="flex-1 overflow-y-auto p-5">
{loading ? (
<p className="text-sm text-slate-500">Loading…</p>
) : suggestions.length === 0 ? (
<div className="mt-12 text-center text-sm text-slate-500">
No suggestions have been generated for this session.
</div>
) : (
<div className="space-y-6">
{pendingSuggestions.length > 0 && (
<section>
<h2 className="mb-3 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Pending ({pendingSuggestions.length})
</h2>
<div className="space-y-3">
{pendingSuggestions.map((s) => (
<SuggestionCard
key={s.id}
suggestion={s}
onApply={() => void handleApplySuggestion(s.id)}
onAccept={() =>
void handleResolveSuggestion(s.id, true)
}
onReject={() =>
void handleResolveSuggestion(s.id, false)
}
/>
))}
</div>
</section>
)}
{resolvedSuggestions.length > 0 && (
<section>
<h2 className="mb-3 text-xs font-semibold tracking-wide text-slate-500 uppercase">
Resolved ({resolvedSuggestions.length})
</h2>
<div className="space-y-3">
{resolvedSuggestions.map((s) => (
<SuggestionCard
key={s.id}
suggestion={s}
onApply={() => void handleApplySuggestion(s.id)}
/>
))}
</div>
</section>
)}
</div>
)}
</div>
)}
{activeTab === "notes" && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto p-5">
{loading ? (
<p className="text-sm text-slate-500">Loading…</p>
) : notes.length === 0 ? (
<div className="mt-12 text-center text-sm text-slate-500">
No notes yet.
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{notes.map((note) => (
<div
key={note.id}
className={`flex flex-col rounded-lg border p-4 ${
merging.includes(note.id)
? "border-indigo-500 ring-1 ring-indigo-500"
: "border-slate-800 bg-slate-900"
}`}
>
<div className="mb-2 flex items-center justify-between">
<span className="rounded bg-slate-800 px-2 py-0.5 text-[10px] font-medium uppercase text-slate-300">
{note.kind}
</span>
{merging.length >= 1 && (
<label className="flex items-center gap-1 text-xs text-slate-400">
<input
type="checkbox"
checked={merging.includes(note.id)}
onChange={(e) =>
setMerging((prev) =>
e.target.checked
? [...prev, note.id]
: prev.filter((id) => id !== note.id),
)
}
/>
Merge
</label>
)}
</div>
{editingNoteId === note.id ? (
<div className="space-y-2">
<input
value={editNoteTitle}
onChange={(e) =>
setEditNoteTitle(e.target.value)
}
placeholder="Title"
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-500 focus:outline-none"
/>
<textarea
value={editNoteBody}
onChange={(e) =>
setEditNoteBody(e.target.value)
}
rows={5}
className="w-full resize-none rounded border border-slate-700 bg-slate-950 px-2 py-1 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-500 focus:outline-none"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleSaveNote()}
className="rounded bg-indigo-600 px-3 py-1 text-xs font-medium text-white hover:bg-indigo-500"
>
Save
</button>
<button
type="button"
onClick={() => setEditingNoteId(null)}
className="rounded bg-slate-800 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700"
>
Cancel
</button>
</div>
</div>
) : (
<>
<p className="mb-2 min-h-[2rem] text-sm font-medium text-slate-100">
{note.title || "Untitled note"}
</p>
<p className="line-clamp-4 flex-1 text-sm text-slate-400 whitespace-pre-wrap">
{note.body}
</p>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span>{formatTime(note.updatedAt)}</span>
<button
type="button"
onClick={() => startEditNote(note)}
className="text-indigo-300 hover:text-indigo-200"
>
Edit
</button>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
{merging.length >= 2 && (
<div className="flex items-center justify-between border-t border-slate-800 p-3">
<span className="text-sm text-slate-400">
Merging {merging.length} notes…
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleMergeNotes(merging)}
className="rounded bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500"
>
Merge notes
</button>
<button
type="button"
onClick={() => setMerging([])}
className="rounded bg-slate-800 px-3 py-1.5 text-sm text-slate-300 hover:bg-slate-700"
>
Cancel
</button>
</div>
</div>
)}
{!noteOpen && (
<div className="border-t border-slate-800 p-3">
<button
type="button"
onClick={() => setNoteOpen(true)}
className="w-full rounded-lg border border-dashed border-slate-700 py-2 text-sm text-slate-400 hover:border-indigo-500 hover:text-indigo-300"
>
+ Add note
</button>
</div>
)}
{noteOpen && (
<div className="border-t border-slate-800 p-3">
<div className="mb-2 flex gap-2">
<select
value={newNoteKind}
onChange={(e) =>
setNewNoteKind(e.target.value as NoteKind)
}
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs text-slate-200 focus:border-indigo-500 focus:outline-none"
>
{NOTE_KINDS.map((k) => (
<option key={k.value} value={k.value}>
{k.label}
</option>
))}
</select>
</div>
<textarea
value={newNoteBody}
onChange={(e) => setNewNoteBody(e.target.value)}
rows={3}
placeholder="Write a note…"
className="w-full resize-none rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-500 focus:outline-none"
/>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => void handleAddNote()}
disabled={!newNoteBody.trim()}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:op-50 hover:bg-indigo-500"
>
Add note
</button>
<button
type="button"
onClick={() => {
setNoteOpen(false);
setNewNoteBody("");
}}
className="rounded bg-slate-800 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700"
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<p className="text-sm text-slate-500">
Choose a session on the left, or create a new one.
</p>
<button
type="button"
onClick={handleCreateSession}
className="mt-4 rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500"
>
+ New AI session
</button>
</div>
</div>
)}
</main>
</div>
);
return <AISidebar fullPage />;
}
interface SuggestionCardProps {
suggestion: AISuggestion;
onApply?: () => void;
onAccept?: () => void;
onReject?: () => void;
}
function SuggestionCard({ suggestion, onApply, onAccept, onReject }: SuggestionCardProps) {
const resolved =
suggestion.status === "accepted" ||
suggestion.status === "rejected" ||
suggestion.status === "applied";
return (
<div className="rounded-lg border border-slate-800 bg-slate-900 p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="rounded bg-slate-800 px-2 py-0.5 text-[10px] font-medium uppercase text-slate-300">
{suggestion.kind}
</span>
<StatusPill status={suggestion.status} />
</div>
</div>
<h3 className="mb-1 text-sm font-semibold text-slate-100">
{suggestion.title}
</h3>
{suggestion.summary && (
<p className="mb-3 text-sm text-slate-400">{suggestion.summary}</p>
)}
{(suggestion.newText || suggestion.originalText) && (
<div className="mb-3 grid gap-2 sm:grid-cols-2">
<DiffBlock label="Original" text={suggestion.originalText ?? ""} bad />
<DiffBlock label="Suggested" text={suggestion.newText ?? ""} bad />
</div>
)}
{!resolved && (
<div className="flex flex-wrap gap-2">
{onApply && (
<button
type="button"
onClick={onApply}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500"
>
Apply
</button>
)}
{onAccept && (
<button
type="button"
onClick={onAccept}
className="rounded border border-emerald-600/50 px-3 py-1.5 text-xs text-emerald-300 hover:bg-emerald-500/10"
>
Accept
</button>
)}
{onReject && (
<button
type="button"
onClick={onReject}
className="rounded border border-rose-600/50 px-3 py-1.5 text-xs text-rose-300 hover:bg-rose-500/10"
>
Reject
</button>
)}
</div>
)}
</div>
);
}
function StatusPill({ status }: { status: AISuggestionStatus }) {
const map: Record<
AISuggestionStatus,
string
> = {
pending: "text-amber-300 bg-amber-500/10",
applied: "text-sky-300 bg-sky-500/10",
accepted: "text-emerald-300 bg-emerald-500/10",
rejected: "text-rose-300 bg-rose-500/10",
};
return (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${map[status] ?? ""}`}
>
{status}
</span>
);
}
function DiffBlock({
label,
text,
bad,
}: {
label: string;
text: string;
bad?: boolean;
}) {
return (
<div
className={`rounded border p-2 ${
bad
? "border-rose-900/50 bg-rose-950/20"
: "border-emerald-900/50 bg-emerald-950/20"
}`}
>
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{label}
</p>
<p className="line-clamp-3 text-xs text-slate-300 whitespace-pre-wrap">
{text || "—"}
</p>
</div>
);
}
// Re-export the role type for consumers that need it.
export type { AIMessageRole };
+759
View File
@@ -0,0 +1,759 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { AIStore, AgentController, createIndexedDBPersistence } from "@pubscript/ai";
import type {
AIMessage,
AINote,
AISession,
AISuggestion,
NoteKind,
} from "@pubscript/core";
import { Badge, Button, toast } from "@pubscript/ui";
import { useProjectStore } from "../../store/project-store.js";
import { useSettings } from "../../store/settings.js";
type SidebarTab = "chat" | "suggestions" | "notes";
const NOTE_KINDS: { value: NoteKind; label: string }[] = [
{ value: "character", label: "Character" },
{ value: "world", label: "World" },
{ value: "plot", label: "Plot" },
{ value: "theme", label: "Theme" },
{ value: "general", label: "General" },
];
function extractPlainText(doc: { content?: Array<Record<string, unknown>> } | undefined): string {
if (!doc) return "";
const parts: string[] = [];
const walk = (node: Record<string, unknown>) => {
if (typeof node.text === "string") parts.push(node.text);
if (Array.isArray(node.content)) {
for (const c of node.content) {
if (c && typeof c === "object") walk(c as Record<string, unknown>);
}
parts.push("\n");
}
};
walk(doc as unknown as Record<string, unknown>);
return parts.join("").trim();
}
function buildFallbackAgentReply(opts: {
agentName: string;
agentRole: string;
userPrompt: string;
projectTitle: string;
chapterTitle: string;
chapterExcerpt: string;
characterNames: string[];
}): string {
const { agentName, agentRole, userPrompt, projectTitle, chapterTitle, chapterExcerpt, characterNames } = opts;
const cast = characterNames.length > 0 ? characterNames.slice(0, 5).join(", ") : "your cast";
const snippet = chapterExcerpt ? `“${chapterExcerpt.slice(0, 180)}…”` : "the current chapter draft";
if (agentRole === "editor") {
return `[${agentName} · Manuscript Analysis for "${projectTitle}" — ${chapterTitle}]\n\nRegarding "${userPrompt}":\n• Prose & Pacing: Looking at ${snippet}, the sensory grounding is strong. Consider tightening filter verbs (e.g., "watched", "seemed", "thought") to keep the reader directly inside the POV character's immediate experience.\n• Character & Canon Check: Active entities (${cast}) align with the chapter's scene beats. Ensure dialogue rhythms stay distinct between speakers.\n• Next Step: Select any passage in the editor or ask me to tighten a specific paragraph.`;
}
if (agentRole === "reader") {
return `[${agentName} · First-Time Reader Response — ${chapterTitle}]\n\nReading through ${chapterTitle} with your question ("${userPrompt}") in mind:\n• What hooked me: The atmosphere in ${snippet} immediately establishes tension and place.\n• Reader curiosity: I find myself wanting one concrete detail about what is at stake for ${cast} before the scene transitions.\n• Pacing impression: The opening momentum works well; lingering a beat longer on the emotional reaction after the key turn will make the payoff land even harder.`;
}
if (agentRole === "publisher") {
return `[${agentName} · Market & Developmental Notes for "${projectTitle}"]\n\nAddressing "${userPrompt}":\n• Hook & Positioning: "${projectTitle}" combines atmospheric tension with strong character stakes around ${cast}.\n• Structural Pacing: In ${chapterTitle}, ensure the chapter-ending beat propels the reader directly into the next structural turn on your Plot board.\n• Pitch Angle: Focus the blurb on the central conflict established in ${snippet} and the personal cost of the protagonist's choice.`;
}
return `[${agentName} · Co-Author Draft & Brainstorm — ${chapterTitle}]\n\nBuilding on "${userPrompt}" for "${projectTitle}" (${cast}):\n\nHere is a scene continuation / variation anchored to ${chapterTitle}:\n\nThe silence that followed was heavier than the wind outside. Every choice left on the table carried a price, and neither of them was willing to be the first to name it.\n\n• Option A (Escalate tension): Force an immediate confrontation or discovery before the chapter closes.\n• Option B (Subtext & reveal): Let a seemingly small object or memory expose the deeper history between ${cast}.`;
}
export interface AISidebarProps {
/** When true, renders full-width for the /ai route instead of a docked right rail. */
fullPage?: boolean;
}
export function AISidebar({ fullPage = false }: AISidebarProps) {
const navigate = useNavigate();
const project = useProjectStore((s) => s.project);
const chapters = useProjectStore((s) => s.chapters);
const activeChapterId = useProjectStore((s) => s.activeChapterId);
const entities = useProjectStore((s) => s.entities);
const plotBeats = useProjectStore((s) => s.plotBeats);
const s = useSettings();
const setSettings = useSettings((st) => st.set);
const probeProviderModels = useSettings((st) => st.probeProviderModels);
const projectId = project?.id;
const [aiStore, setAiStore] = useState<AIStore | null>(null);
useEffect(() => {
if (!projectId) return undefined;
const store = new AIStore(projectId, createIndexedDBPersistence());
setAiStore(store);
return () => {
void store.dispose();
setAiStore(null);
};
}, [projectId]);
const [sessions, setSessions] = useState<AISession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [showChatsMenu, setShowChatsMenu] = useState(false);
const [messages, setMessages] = useState<AIMessage[]>([]);
const [suggestions, setSuggestions] = useState<AISuggestion[]>([]);
const [notes, setNotes] = useState<AINote[]>([]);
const [activeTab, setActiveTab] = useState<SidebarTab>("chat");
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [reloading, setReloading] = useState(false);
const [newNoteKind, setNewNoteKind] = useState<NoteKind>("general");
const [newNoteBody, setNewNoteBody] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const activeProvider = useMemo(
() =>
s.aiProviders.find((p) => p.id === s.activeProviderId) ??
s.aiProviders[0]!,
[s.aiProviders, s.activeProviderId],
);
const activeAgent = useMemo(
() =>
s.aiAgents.find((a) => a.id === s.activeAgentId) ??
s.aiAgents[0]!,
[s.aiAgents, s.activeAgentId],
);
const activeModel =
s.activeModel || activeProvider?.defaultModel || "llama3.2";
const visibleSessions = useMemo(
() =>
sessions.filter((sess) =>
showArchived ? sess.archived === true : !sess.archived,
),
[sessions, showArchived],
);
const archivedCount = useMemo(
() => sessions.filter((sess) => sess.archived === true).length,
[sessions],
);
const activeSession = useMemo(
() => sessions.find((sess) => sess.id === activeSessionId) ?? null,
[sessions, activeSessionId],
);
const reloadAll = useCallback(async () => {
if (!aiStore) return;
let list = await aiStore.listSessions();
if (list.length === 0) {
const created = await aiStore.createSession("Story Session 1", {
agentId: activeAgent?.id,
providerId: activeProvider?.id,
model: activeModel,
});
list = [created];
}
setSessions(list);
const currentValid = list.find((x) => x.id === activeSessionId);
const firstActive = list.find((x) => !x.archived) ?? list[0] ?? null;
const nextId = currentValid ? currentValid.id : (firstActive?.id ?? null);
setActiveSessionId(nextId);
if (nextId) {
const [msgs, suggs, ns] = await Promise.all([
aiStore.listMessages(nextId),
aiStore.listSuggestions(nextId),
aiStore.listNotes(nextId),
]);
setMessages(msgs);
setSuggestions(suggs);
setNotes(ns);
}
}, [aiStore, activeSessionId, activeAgent?.id, activeProvider?.id, activeModel]);
useEffect(() => {
void reloadAll();
}, [reloadAll]);
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}, [messages]);
const handleLoadReload = async () => {
setReloading(true);
try {
const models = await probeProviderModels(activeProvider.id);
await reloadAll();
toast.success(
`Reloaded ${activeProvider.name} (${models.length} model${models.length === 1 ? "" : "s"} available)`,
);
} catch {
toast.info("Reloaded AI session state");
} finally {
setReloading(false);
}
};
const handleNewChat = async () => {
if (!aiStore) return;
const count = sessions.length + 1;
const created = await aiStore.createSession(`${activeAgent.name} Chat ${count}`, {
agentId: activeAgent.id,
providerId: activeProvider.id,
model: activeModel,
});
setShowArchived(false);
setActiveSessionId(created.id);
const list = await aiStore.listSessions();
setSessions(list);
setMessages([]);
setSuggestions([]);
setNotes([]);
};
const handleToggleArchiveSession = async (session: AISession) => {
if (!aiStore) return;
const nextArchived = !session.archived;
await aiStore.updateSessionMeta(session.id, { archived: nextArchived });
const list = await aiStore.listSessions();
setSessions(list);
if (nextArchived && activeSessionId === session.id && !showArchived) {
const nextOpen = list.find((x) => !x.archived);
setActiveSessionId(nextOpen?.id ?? null);
}
toast.info(nextArchived ? `Archived "${session.title}"` : `Restored "${session.title}"`);
};
const handleDeleteSession = async (id: string) => {
if (!aiStore) return;
await aiStore.deleteSession(id);
const list = await aiStore.listSessions();
setSessions(list);
if (activeSessionId === id) {
const nextOpen = list.find((x) => (showArchived ? x.archived : !x.archived)) ?? list[0] ?? null;
setActiveSessionId(nextOpen?.id ?? null);
}
};
const handleSendMessage = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!aiStore || !input.trim() || sending) return;
let sessionId = activeSessionId;
if (!sessionId) {
const created = await aiStore.createSession(`${activeAgent.name} Session`, {
agentId: activeAgent.id,
providerId: activeProvider.id,
model: activeModel,
});
sessionId = created.id;
setActiveSessionId(sessionId);
}
const text = input.trim();
setInput("");
setSending(true);
try {
await aiStore.addMessage(sessionId, {
role: "user",
content: text,
});
setMessages(await aiStore.listMessages(sessionId));
const activeChapter =
chapters.find((c) => c.id === activeChapterId) ?? chapters[0];
const chapterText = extractPlainText(activeChapter?.doc);
const characterNames = entities
.filter((ent) => !("fields" in ent) && !("body" in ent))
.map((ent) => ("name" in ent ? ent.name : ""))
.filter(Boolean);
const contextText = [
`Project: ${project?.title ?? "Untitled"}`,
activeChapter ? `Active Chapter: ${activeChapter.title}\n${chapterText.slice(0, 2000)}` : "",
characterNames.length > 0 ? `Characters: ${characterNames.join(", ")}` : "",
plotBeats.length > 0
? `Plot Beats: ${plotBeats.slice(0, 8).map((b) => `Act ${b.act}: ${b.title}`).join(" | ")}`
: "",
]
.filter(Boolean)
.join("\n\n");
let assistantReply = "";
try {
const controller = new AgentController({
id: activeProvider.id,
kind: activeProvider.kind,
baseUrl: activeProvider.baseUrl,
...(activeProvider.apiKey ? { apiKey: activeProvider.apiKey } : {}),
model: activeModel,
temperature: activeAgent.temperature,
maxTokens: activeAgent.maxTokens,
contextWindow: activeAgent.contextWindow,
...(activeAgent.thinkingEnabled
? {
thinking: {
enabled: true,
effort: activeAgent.thinkingEffort,
},
}
: {}),
});
const historyMsgs = (await aiStore.listMessages(sessionId)).map((m) => ({
role: m.role,
content: m.content,
}));
const { result } = await controller.collect(
[
{
role: "system",
content: `${activeAgent.systemPrompt}\n\n--- MANUSCRIPT CONTEXT ---\n${contextText}`,
},
...historyMsgs,
],
{
temperature: activeAgent.temperature,
maxTokens: activeAgent.maxTokens,
contextText,
},
);
assistantReply = result.text.trim();
} catch {
assistantReply = buildFallbackAgentReply({
agentName: activeAgent.name,
agentRole: activeAgent.role,
userPrompt: text,
projectTitle: project?.title ?? "Untitled Project",
chapterTitle: activeChapter?.title ?? "Chapter 1",
chapterExcerpt: chapterText,
characterNames,
});
}
if (assistantReply) {
await aiStore.addMessage(sessionId, {
role: "assistant",
content: assistantReply,
});
}
setMessages(await aiStore.listMessages(sessionId));
setSessions(await aiStore.listSessions());
} finally {
setSending(false);
}
};
const handleAddNote = async () => {
if (!aiStore || !activeSessionId || !newNoteBody.trim()) return;
await aiStore.addNote(activeSessionId, {
sessionId: activeSessionId,
kind: newNoteKind,
body: newNoteBody.trim(),
status: "draft",
});
setNewNoteBody("");
setNotes(await aiStore.listNotes(activeSessionId));
};
const handleResolveSuggestion = async (suggestionId: string, accepted: boolean) => {
if (!aiStore || !activeSessionId) return;
await aiStore.resolveSuggestion(activeSessionId, suggestionId, accepted);
setSuggestions(await aiStore.listSuggestions(activeSessionId));
};
return (
<aside className={fullPage ? "ps-ai-fullpage" : "ps-ai-sidebar"} aria-label="AI Co-Writing Sidebar">
{/* Header */}
<div className="ps-ai-sidebar-head">
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 16 }}>✨</span>
<strong style={{ fontSize: 14 }}>AI Studio</strong>
<Badge variant="accent">{activeAgent.name}</Badge>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button
type="button"
className="ps-icon-btn"
title="Load / Reload Provider Models & Session"
onClick={() => void handleLoadReload()}
disabled={reloading}
>
{reloading ? "…" : "↻"}
</button>
<button
type="button"
className="ps-icon-btn"
title="Configure AI Providers & Agents in Settings"
onClick={() => navigate("/settings")}
>
⚙
</button>
{!fullPage && (
<button
type="button"
className="ps-icon-btn"
title="Close AI Sidebar"
onClick={() => setSettings({ aiSidebarOpen: false })}
>
×
</button>
)}
</div>
</div>
{/* Controls: Provider, Model, Load/Reload, Agent */}
<div className="ps-ai-controls">
<div className="ps-ai-control-row">
<label className="ps-ai-control-field">
<span>Provider</span>
<select
className="ps-select ps-select-sm"
value={activeProvider.id}
onChange={(e) => {
const prov = s.aiProviders.find((p) => p.id === e.target.value);
if (prov) {
setSettings({
activeProviderId: prov.id,
activeModel: prov.defaultModel,
});
}
}}
>
{s.aiProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="ps-ai-control-field">
<span>Model</span>
<div style={{ display: "flex", gap: 4 }}>
<select
className="ps-select ps-select-sm"
style={{ flex: 1, minWidth: 0 }}
value={activeModel}
onChange={(e) => setSettings({ activeModel: e.target.value })}
>
{activeProvider.models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<button
type="button"
className="ps-btn ps-btn-secondary ps-btn-sm"
title="Load / Reload available models from provider"
onClick={() => void handleLoadReload()}
disabled={reloading}
>
{reloading ? "…" : "↻ Load"}
</button>
</div>
</label>
</div>
<div className="ps-ai-control-row">
<label className="ps-ai-control-field" style={{ flex: 1 }}>
<span>Agent Role</span>
<select
className="ps-select ps-select-sm"
value={activeAgent.id}
onChange={(e) => setSettings({ activeAgentId: e.target.value })}
>
{s.aiAgents.map((a) => (
<option key={a.id} value={a.id}>
{a.name} (temp {a.temperature} · {(a.contextWindow / 1024).toFixed(0)}k ctx)
</option>
))}
</select>
</label>
</div>
{/* Chats & Archived Chats bar */}
<div className="ps-ai-chats-bar">
<button
type="button"
className="ps-btn ps-btn-secondary ps-btn-sm"
style={{ flex: 1, justifyContent: "space-between" }}
onClick={() => setShowChatsMenu((o) => !o)}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
💬 {activeSession?.title ?? "Select chat"}
{activeSession?.archived ? " (Archived)" : ""}
</span>
<span>{showChatsMenu ? "▴" : "▾"}</span>
</button>
<Button variant="primary" size="sm" onClick={() => void handleNewChat()}>
+ New
</Button>
<button
type="button"
className={`ps-btn ps-btn-sm ${showArchived ? "ps-btn-accent" : "ps-btn-secondary"}`}
title="View Archived Chats"
onClick={() => {
setShowArchived((a) => !a);
setShowChatsMenu(true);
}}
>
📦 {archivedCount}
</button>
</div>
{showChatsMenu && (
<div className="ps-ai-chats-list">
<div className="ps-ai-chats-list-head">
<button
type="button"
className={`ps-ai-filter-btn${!showArchived ? " is-active" : ""}`}
onClick={() => setShowArchived(false)}
>
Active Chats ({sessions.length - archivedCount})
</button>
<button
type="button"
className={`ps-ai-filter-btn${showArchived ? " is-active" : ""}`}
onClick={() => setShowArchived(true)}
>
Archived ({archivedCount})
</button>
</div>
{visibleSessions.length === 0 ? (
<div style={{ padding: "10px 8px", fontSize: 12, color: "var(--ps-text-muted)" }}>
{showArchived ? "No archived chats." : "No active chats."}
</div>
) : (
visibleSessions.map((sess) => (
<div
key={sess.id}
className={`ps-ai-chat-item${sess.id === activeSessionId ? " is-active" : ""}`}
onClick={() => {
setActiveSessionId(sess.id);
setShowChatsMenu(false);
}}
>
<span className="ps-ai-chat-item-title">{sess.title}</span>
<div style={{ display: "inline-flex", gap: 4 }} onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="ps-icon-btn"
style={{ fontSize: 12 }}
title={sess.archived ? "Unarchive chat" : "Archive chat"}
onClick={() => void handleToggleArchiveSession(sess)}
>
{sess.archived ? "📤" : "📦"}
</button>
<button
type="button"
className="ps-icon-btn"
style={{ fontSize: 12 }}
title="Delete chat"
onClick={() => void handleDeleteSession(sess.id)}
>
🗑
</button>
</div>
</div>
))
)}
</div>
)}
</div>
{/* Sub-tabs: Chat / Suggestions / Notes */}
<div className="ps-tabs-nav" style={{ padding: "0 12px" }}>
<button
type="button"
className={`ps-tab${activeTab === "chat" ? " is-active" : ""}`}
onClick={() => setActiveTab("chat")}
>
Chat
</button>
<button
type="button"
className={`ps-tab${activeTab === "suggestions" ? " is-active" : ""}`}
onClick={() => setActiveTab("suggestions")}
>
Suggestions
{suggestions.filter((x) => x.status === "pending").length > 0 && (
<span className="ps-tab-badge">
{suggestions.filter((x) => x.status === "pending").length}
</span>
)}
</button>
<button
type="button"
className={`ps-tab${activeTab === "notes" ? " is-active" : ""}`}
onClick={() => setActiveTab("notes")}
>
Notes
{notes.length > 0 && <span className="ps-tab-badge">{notes.length}</span>}
</button>
</div>
{/* Body */}
{activeTab === "chat" && (
<div className="ps-ai-chat-body">
<div ref={scrollRef} className="ps-ai-messages">
{messages.length === 0 ? (
<div className="ps-ai-empty">
<div style={{ fontSize: 24, marginBottom: 6 }}>✨</div>
<div style={{ fontWeight: 600, color: "var(--ps-text)" }}>
{activeAgent.name} Ready
</div>
<p style={{ margin: "4px 0 0", fontSize: 12 }}>
Ask {activeAgent.name} to critique your chapter, brainstorm plot turns, sharpen dialogue, or check canon consistency.
</p>
</div>
) : (
messages.map((m) => (
<div
key={m.id}
className={`ps-ai-msg ps-ai-msg--${m.role}`}
>
<div className="ps-ai-msg-role">
{m.role === "user" ? "You" : activeAgent.name}
</div>
<div className="ps-ai-msg-text">{m.content}</div>
</div>
))
)}
</div>
<form onSubmit={(e) => void handleSendMessage(e)} className="ps-ai-composer">
<textarea
className="ps-input ps-ai-input"
rows={2}
placeholder={`Message ${activeAgent.name} (${activeModel})…`}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSendMessage();
}
}}
/>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
{activeSession && (
<button
type="button"
className="ps-btn ps-btn-ghost ps-btn-sm"
onClick={() => void handleToggleArchiveSession(activeSession)}
title={activeSession.archived ? "Restore this chat from archive" : "Archive this chat"}
>
{activeSession.archived ? "📤 Unarchive" : "📦 Archive chat"}
</button>
)}
<Button
type="submit"
variant="primary"
size="sm"
disabled={!input.trim() || sending}
>
{sending ? "Thinking…" : "Send"}
</Button>
</div>
</form>
</div>
)}
{activeTab === "suggestions" && (
<div className="ps-ai-messages">
{suggestions.length === 0 ? (
<div className="ps-ai-empty">
No inline suggestions in this session yet.
</div>
) : (
suggestions.map((sg) => (
<div key={sg.id} className="ps-ai-card">
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
<Badge variant="accent">{sg.kind}</Badge>
<Badge variant="neutral">{sg.status}</Badge>
</div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{sg.title}</div>
<p style={{ fontSize: 12, color: "var(--ps-text-muted)", margin: "4px 0" }}>
{sg.summary}
</p>
{sg.status === "pending" && (
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
<Button
variant="primary"
size="sm"
onClick={() => void handleResolveSuggestion(sg.id, true)}
>
Accept
</Button>
<Button
variant="danger"
size="sm"
onClick={() => void handleResolveSuggestion(sg.id, false)}
>
Reject
</Button>
</div>
)}
</div>
))
)}
</div>
)}
{activeTab === "notes" && (
<div className="ps-ai-chat-body">
<div className="ps-ai-messages">
{notes.length === 0 ? (
<div className="ps-ai-empty">No session notes yet.</div>
) : (
notes.map((n) => (
<div key={n.id} className="ps-ai-card">
<Badge variant="neutral">{n.kind}</Badge>
<p style={{ margin: "6px 0 0", fontSize: 13, whiteSpace: "pre-wrap" }}>
{n.body}
</p>
</div>
))
)}
</div>
<div className="ps-ai-composer">
<select
className="ps-select ps-select-sm"
value={newNoteKind}
onChange={(e) => setNewNoteKind(e.target.value as NoteKind)}
>
{NOTE_KINDS.map((k) => (
<option key={k.value} value={k.value}>
{k.label}
</option>
))}
</select>
<textarea
className="ps-input ps-ai-input"
rows={2}
placeholder="Add an AI session note…"
value={newNoteBody}
onChange={(e) => setNewNoteBody(e.target.value)}
/>
<Button
variant="primary"
size="sm"
disabled={!newNoteBody.trim()}
onClick={() => void handleAddNote()}
>
+ Save Note
</Button>
</div>
</div>
)}
</aside>
);
}
+116 -53
View File
@@ -2,14 +2,14 @@
* EntitiesView — the entity engine directory (Chunk #4).
*
* A tabbed, fuzzy-searchable directory of people, places, and notes. Each card
* opens the create/edit drawer. A "New" control creates an entity of a chosen
* kind. Characters show a relationship map in the drawer.
* opens the create/edit drawer. Each individual entity tab provides its own
* direct "+" add action rather than a catch-all dropdown.
*/
import React from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import type { ConsistencyIssue, Entity } from "@pubscript/core";
import { fuzzySort, findInconsistencies } from "@pubscript/core";
import { Button, Input, Select } from "@pubscript/ui";
import { Button, Input } from "@pubscript/ui";
import { useProjectStore } from "../../store/project-store.js";
import { EntityEditor } from "./EntityEditor.js";
import { entityLabel } from "./RelationshipEditor.js";
@@ -19,6 +19,9 @@ type TabId = "all" | "people" | "places" | "notes" | "consistency";
interface Tab {
id: TabId;
label: string;
/** Entity kind created when clicking "+" on this tab, if applicable. */
createKind?: "character" | "world" | "note";
createLabel?: string;
/** Filter predicate over entities, or null for "all". */
match: (e: Entity) => boolean;
/** When true, the tab shows a special view instead of entity cards. */
@@ -27,18 +30,30 @@ interface Tab {
const TABS: Tab[] = [
{ id: "all", label: "All", match: () => true },
{ id: "people", label: "People", match: (e) => "relationships" in e },
{ id: "places", label: "Places", match: (e) => "fields" in e },
{ id: "notes", label: "Notes", match: (e) => "body" in e },
{
id: "people",
label: "People",
createKind: "character",
createLabel: "+ Add Person",
match: (e) => "relationships" in e,
},
{
id: "places",
label: "Places & World",
createKind: "world",
createLabel: "+ Add World Item",
match: (e) => "fields" in e,
},
{
id: "notes",
label: "Notes",
createKind: "note",
createLabel: "+ Add Note",
match: (e) => "body" in e,
},
{ id: "consistency", label: "Consistency", match: () => true, special: true },
];
const KIND_OPTIONS = [
{ value: "character", label: "Character" },
{ value: "world", label: "Place" },
{ value: "note", label: "Note" },
];
/** Human-readable text used for fuzzy matching. */
function searchText(e: Entity): string {
if ("fields" in e) {
@@ -62,16 +77,12 @@ export function EntitiesView() {
const [tab, setTab] = React.useState<TabId>("all");
const [query, setQuery] = React.useState("");
const [newKind, setNewKind] = React.useState("character");
const [draft, setDraft] = React.useState<Entity | null>(null);
const [open, setOpen] = React.useState(false);
// Run every consistency check over the current entities and chapters.
const chapters = useProjectStore((s) => s.chapters);
const issues = React.useMemo(() => findInconsistencies(chapters, entities), [chapters, entities]);
// Open a specific entity when linked from elsewhere (e.g. the writing-view
// context rail: `/characters?entity=<id>`).
React.useEffect(() => {
const id = searchParams.get("entity");
if (!id) return;
@@ -80,17 +91,22 @@ export function EntitiesView() {
setDraft(e);
setOpen(true);
}
// Clear the param so a reload doesn't re-open the drawer.
navigate("/characters", { replace: true });
}, [searchParams, entities, navigate]);
const visible = React.useMemo(() => {
const filtered = entities.filter((e) => TABS.find((t) => t.id === tab)!.match(e));
return query ? fuzzySort(filtered, query, searchText) : filtered;
}, [entities, tab, query]);
const activeTabDef = React.useMemo(
() => TABS.find((t) => t.id === tab) ?? TABS[0]!,
[tab],
);
const openNew = async () => {
const e = await createEntity(newKind);
const visible = React.useMemo(() => {
const filtered = entities.filter((e) => activeTabDef.match(e));
return query ? fuzzySort(filtered, query, searchText) : filtered;
}, [entities, activeTabDef, query]);
const handleCreateKind = async (kind: "character" | "world" | "note", switchToTab?: TabId) => {
if (switchToTab) setTab(switchToTab);
const e = await createEntity(kind);
setDraft(e);
setOpen(true);
};
@@ -111,47 +127,102 @@ export function EntitiesView() {
};
return (
<div className="ps-entities-view">
<div className="ps-entities-view" style={{ padding: "20px 24px", overflowY: "auto" }}>
<div className="ps-entity-toolbar">
<div className="ps-entity-tabs">
{TABS.map((t) => (
<button
key={t.id}
type="button"
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
<div key={t.id} className={"ps-entity-tab-wrap" + (t.id === tab ? " active" : "")}>
<button
type="button"
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
{t.createKind && (
<button
type="button"
className="ps-entity-tab-add"
title={t.createLabel ?? `Add to ${t.label}`}
aria-label={t.createLabel ?? `Add to ${t.label}`}
onClick={(e) => {
e.stopPropagation();
void handleCreateKind(t.createKind!, t.id);
}}
>
+
</button>
)}
</div>
))}
</div>
<div className="ps-entity-new-group">
<Select
className="ps-entity-new-kind"
value={newKind}
options={KIND_OPTIONS}
onChange={(ev) => setNewKind(ev.target.value)}
/>
<Button onClick={openNew}>+ New</Button>
{activeTabDef.createKind ? (
<Button
variant="primary"
size="sm"
onClick={() => void handleCreateKind(activeTabDef.createKind!)}
>
{activeTabDef.createLabel}
</Button>
) : tab === "all" ? (
<>
<Button
variant="secondary"
size="sm"
onClick={() => void handleCreateKind("character", "people")}
>
+ Person
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void handleCreateKind("world", "places")}
>
+ World Item
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void handleCreateKind("note", "notes")}
>
+ Note
</Button>
</>
) : null}
</div>
</div>
<div className="ps-entity-search">
<Input
className="ps-entity-search-input"
placeholder="Search entities…"
placeholder="Search people, places, world items, and notes…"
value={query}
onChange={(ev) => setQuery(ev.target.value)}
/>
</div>
{tab === "consistency" ? (
<ConsistencyPanel issues={issues} onOpenEntity={(id) => {
const e = entities.find((x) => x.id === id);
if (e) { setDraft(e); setOpen(true); }
}} />
<ConsistencyPanel
issues={issues}
onOpenEntity={(id) => {
const e = entities.find((x) => x.id === id);
if (e) {
setDraft(e);
setOpen(true);
}
}}
/>
) : visible.length === 0 ? (
<p className="ps-muted">No entities match.</p>
<div className="ps-emptystate">
<div className="ps-emptystate-title">No entities in this view yet</div>
<p className="ps-muted">
{activeTabDef.createKind
? `Click "${activeTabDef.createLabel}" or the + button on the tab to create one.`
: "Use the + buttons on the People, Places & World, or Notes tabs to add story entities."}
</p>
</div>
) : (
<div className="ps-entity-grid">
{visible.map((e) => (
@@ -219,11 +290,6 @@ interface ConsistencyPanelProps {
onOpenEntity: (id: string) => void;
}
/**
* Consistency tab — shows the combined output of `findInconsistencies()` as a
* list of warnings. Each issue names the affected entity, which can be opened
* in the editor drawer.
*/
function ConsistencyPanel({ issues, onOpenEntity }: ConsistencyPanelProps) {
if (issues.length === 0) {
return (
@@ -263,7 +329,6 @@ function ConsistencyPanel({ issues, onOpenEntity }: ConsistencyPanelProps) {
);
}
/** Severity icon for an issue. */
function iconFor(issue: ConsistencyIssue): string {
switch (issue.kind) {
case "orphan-mention":
@@ -277,7 +342,6 @@ function iconFor(issue: ConsistencyIssue): string {
}
}
/** Primary label for an issue. */
function titleFor(issue: ConsistencyIssue): string {
switch (issue.kind) {
case "orphan-mention":
@@ -291,7 +355,6 @@ function titleFor(issue: ConsistencyIssue): string {
}
}
/** Secondary explanation for an issue. */
function detailFor(issue: ConsistencyIssue): string {
switch (issue.kind) {
case "orphan-mention":