feat: resolve all 22 items in Features and Bugs.txt and complete UI polish
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user