saved sessions: replace transient history with persistent DB storage and auto-save

- Add saved_sessions table and CRUD endpoints (save, list, resume, update, delete)
- Save is instant (no LLM summarization), stores exact conversation with tool calls
- Resume loads full message history into chat UI, sends transcript to agent on first message
- Auto-save updates DB after every agent response once a session is saved
- Delete old filesystem-based session/group management (sessions router, useChatSessions, useChatGroups)
- Clean up ChatHeader, SessionList, ChatDetailPanel for saved sessions flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 13:36:25 +00:00
co-authored by Claude Opus 4.6
parent d88fe3cac7
commit 2a8df0def1
39 changed files with 3272 additions and 2257 deletions
@@ -1,16 +1,15 @@
import { Link } from 'react-router';
import { MessageSquare, Trash2 } from 'lucide-react';
import { Widget } from 'widgets/Widget';
import { useChatSessions } from 'state/useChatSessions';
import { useSavedSessions } from 'state/useSavedSessions';
export const ChatHistory = () => {
const { sessions, deleteSession } = useChatSessions();
const { sessions, deleteSavedSession } = useSavedSessions();
return (
<Widget title="Chat History">
<Widget title="Saved Sessions">
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
{sessions.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No sessions yet</p>
<p className="text-xs text-duck-dark/40 py-4 text-center">No saved sessions yet</p>
) : (
<ul className="space-y-0.5">
{sessions.map((session) => (
@@ -18,18 +17,10 @@ export const ChatHistory = () => {
key={session.id}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group"
>
<Link
to={`/chat/${session.id}`}
className="flex items-center gap-2 flex-1 min-w-0"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
<div className="min-w-0 flex-1">
<span className="text-sm text-duck-dark dark:text-foreground truncate block">
{session.title}
<span className="ml-1.5 font-mono text-xs text-duck-dark/25 dark:text-foreground/25">
({session.id.slice(0, 8)})
</span>
</span>
<span className="text-sm text-duck-dark dark:text-foreground truncate block">{session.title}</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate block">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
@@ -38,15 +29,10 @@ export const ChatHistory = () => {
minute: '2-digit',
})}
</span>
{session.model && (
<span className="text-xs text-duck-teal/70 dark:text-duck-teal/60 truncate block">
{session.model.includes('/') ? session.model.replace('/', ' - ') : session.model}
</span>
)}
</div>
</Link>
</div>
<button
onClick={() => deleteSession(session.id)}
onClick={() => deleteSavedSession(session.id)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -5,7 +5,6 @@ import { WorkspaceView } from 'officerdev';
import { useIsMobile } from 'hooks/useIsMobile';
import { useDashboardState } from 'state/useDashboardState';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from 'state/useChatSessions';
import { defaultLayout } from './defaultLayout';
export { ChatHistory as ChatHistoryApp } from './Widget';
@@ -35,7 +34,6 @@ type SessionListPageProps = {
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
const { sessionId } = useParams<{ sessionId: string }>();
const { sessions } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const rawWorkspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
const isMobile = useIsMobile();
@@ -62,8 +60,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
return;
}
if (!sessionId) return;
const session = sessions.find((s) => s.id === sessionId);
setSelected({ id: sessionId, model: session?.model ?? null });
setSelected({ id: sessionId });
}, [sessionId, isNew]);
return (
+2 -2
View File
@@ -1,4 +1,4 @@
import { useChatSessions } from 'state/useChatSessions';
import { useSavedSessions } from 'state/useSavedSessions';
import { usePlans } from 'state/usePlans';
import { useSettings } from 'state/useSettings';
import { usePiModels } from 'state/useModels';
@@ -6,7 +6,7 @@ import { useAccessPolicy } from 'state/useAccessPolicy';
import { useColorModeSync } from './useThemeSync';
export const useInitialData = () => {
const { sessions } = useChatSessions();
const { sessions } = useSavedSessions();
const { plans } = usePlans();
const { settings } = useSettings();
usePiModels();
@@ -0,0 +1,19 @@
CREATE TABLE "saved_sessions" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"provider" text NOT NULL,
"context" text,
"context_id" text,
"title" text NOT NULL,
"summary" text NOT NULL,
"raw_messages" jsonb NOT NULL,
"cwd" text NOT NULL,
"cost" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DROP TABLE "chat_groups" CASCADE;--> statement-breakpoint
DROP TABLE "chat_messages" CASCADE;--> statement-breakpoint
DROP TABLE "chat_sessions" CASCADE;--> statement-breakpoint
ALTER TABLE "saved_sessions" ADD CONSTRAINT "saved_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_saved_sessions_user_created" ON "saved_sessions" USING btree ("user_id","created_at");
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,13 @@
"when": 1772795711164,
"tag": "0000_futuristic_ink",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1772799835768,
"tag": "0001_freezing_carmella_unuscione",
"breakpoints": true
}
]
}
+8
View File
@@ -64,5 +64,13 @@ export {
upsertDefaults,
} from './queries/dashboards';
export {
listSavedSessions,
getSavedSession,
createSavedSession,
updateSavedSessionMessages,
deleteSavedSession,
} from './queries/saved-sessions';
export { db } from './db';
export * as schema from './schema';
@@ -0,0 +1,59 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { savedSessions } from '../schema';
import type { SavedSessionInsert, SavedSessionSelect } from '../types';
export async function listSavedSessions(userId: number): Promise<Omit<SavedSessionSelect, 'rawMessages'>[]> {
const rows = await db
.select({
id: savedSessions.id,
userId: savedSessions.userId,
provider: savedSessions.provider,
context: savedSessions.context,
contextId: savedSessions.contextId,
title: savedSessions.title,
summary: savedSessions.summary,
cwd: savedSessions.cwd,
cost: savedSessions.cost,
createdAt: savedSessions.createdAt,
})
.from(savedSessions)
.where(eq(savedSessions.userId, userId))
.orderBy(desc(savedSessions.createdAt));
return rows;
}
export async function getSavedSession(id: number, userId: number): Promise<SavedSessionSelect | undefined> {
const rows = await db
.select()
.from(savedSessions)
.where(and(eq(savedSessions.id, id), eq(savedSessions.userId, userId)));
return rows[0];
}
export async function createSavedSession(data: SavedSessionInsert): Promise<SavedSessionSelect> {
const rows = await db.insert(savedSessions).values(data).returning();
return rows[0]!;
}
export async function updateSavedSessionMessages(
id: number,
userId: number,
rawMessages: unknown,
cost: unknown,
): Promise<boolean> {
const result = await db
.update(savedSessions)
.set({ rawMessages, cost })
.where(and(eq(savedSessions.id, id), eq(savedSessions.userId, userId)))
.returning({ id: savedSessions.id });
return result.length > 0;
}
export async function deleteSavedSession(id: number, userId: number): Promise<boolean> {
const result = await db
.delete(savedSessions)
.where(and(eq(savedSessions.id, id), eq(savedSessions.userId, userId)))
.returning({ id: savedSessions.id });
return result.length > 0;
}
+20 -54
View File
@@ -1,56 +1,22 @@
import { pgTable, text, integer, boolean, timestamp, jsonb, index, numeric } from 'drizzle-orm/pg-core';
import { pgTable, serial, text, integer, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const chatSessions = pgTable('chat_sessions', {
id: text('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
context: text('context').notNull().default('chat'),
contextId: text('context_id'),
title: text('title').notNull().default('New Chat'),
model: text('model'),
cwd: text('cwd'),
thinking: text('thinking'),
archived: boolean('archived').notNull().default(false),
groupSlug: text('group_slug'),
messageCount: integer('message_count').notNull().default(0),
costInputTokens: integer('cost_input_tokens').notNull().default(0),
costOutputTokens: integer('cost_output_tokens').notNull().default(0),
costTotalUsd: numeric('cost_total_usd', { precision: 12, scale: 6 }).notNull().default('0'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('idx_sessions_user_context').on(table.userId, table.context),
index('idx_sessions_user_created').on(table.userId, table.createdAt),
index('idx_sessions_user_group').on(table.userId, table.groupSlug),
]);
export const chatMessages = pgTable('chat_messages', {
id: text('id').primaryKey(),
sessionId: text('session_id').notNull().references(() => chatSessions.id, { onDelete: 'cascade' }),
role: text('role').notNull(),
text: text('text'),
model: text('model'),
toolName: text('tool_name'),
toolInput: jsonb('tool_input'),
toolCallId: text('tool_call_id'),
output: text('output'),
isError: boolean('is_error').default(false),
costInputTokens: integer('cost_input_tokens'),
costOutputTokens: integer('cost_output_tokens'),
costTotalUsd: numeric('cost_total_usd', { precision: 12, scale: 6 }),
sortOrder: integer('sort_order').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('idx_messages_session_order').on(table.sessionId, table.sortOrder),
]);
export const chatGroups = pgTable('chat_groups', {
slug: text('slug').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('idx_chat_groups_user').on(table.userId),
]);
export const savedSessions = pgTable(
'saved_sessions',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
provider: text('provider').notNull(),
context: text('context'),
contextId: text('context_id'),
title: text('title').notNull(),
summary: text('summary').notNull(),
rawMessages: jsonb('raw_messages').notNull(),
cwd: text('cwd').notNull(),
cost: jsonb('cost').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_saved_sessions_user_created').on(table.userId, table.createdAt)],
);
+3 -12
View File
@@ -34,19 +34,10 @@ export type UserIntegrationInsert = typeof Schema.userIntegrations.$inferInsert;
export type DockConfigSelect = typeof Schema.dockConfigs.$inferSelect;
export type DockConfigInsert = typeof Schema.dockConfigs.$inferInsert;
// ── Chat ──
// ── Saved Sessions ──
export type ChatSessionSelect = typeof Schema.chatSessions.$inferSelect;
export type ChatSessionInsert = typeof Schema.chatSessions.$inferInsert;
export type ChatSession = ChatSessionSelect & {
messages?: ChatMessageSelect[];
};
export type ChatMessageSelect = typeof Schema.chatMessages.$inferSelect;
export type ChatMessageInsert = typeof Schema.chatMessages.$inferInsert;
export type ChatGroupSelect = typeof Schema.chatGroups.$inferSelect;
export type ChatGroupInsert = typeof Schema.chatGroups.$inferInsert;
export type SavedSessionSelect = typeof Schema.savedSessions.$inferSelect;
export type SavedSessionInsert = typeof Schema.savedSessions.$inferInsert;
// ── Dashboards ──
+4 -503
View File
@@ -5,25 +5,17 @@ import { readLocalProviders } from '../server-settings/pi-mono';
import { readSttConfig } from '../server-settings/stt';
import { listPiModels } from './list-models';
import { getHomeDirForRole } from '../../data-path';
import { resolveBaseCwd } from './websocket';
import { logger } from './logger';
/**
* REST API Endpoints — Session management and model info
*/
export const piRestRouter = createRouter();
/**
* GET /api/pi/models
* List available models via `pi --list-models` (with stored API keys + PI_CODING_AGENT_DIR).
* Local provider friendly names are resolved from stored local-providers config.
*/
piRestRouter.get('/pi/models', async (ctx: Context) => {
try {
const models = await listPiModels();
// Build providerNames map
const providerNames: Record<string, string> = { 'claude-code': 'Claude Code' };
const localProviders = await readLocalProviders();
for (const lp of localProviders) {
@@ -38,39 +30,9 @@ piRestRouter.get('/pi/models', async (ctx: Context) => {
}
});
/**
* POST /api/pi/sessions
* List all sessions for the current user.
* Optionally filter by cwd/cwdRoot to show only project-scoped sessions.
*/
piRestRouter.post('/pi/sessions', async (ctx: Context) => {
const user = ctx.get('user');
if (!user) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const body = await ctx.req.json().catch(() => ({}));
const userHome = getHomeDirForRole(user.email, user.role);
const filterCwd = body.cwd ? resolveBaseCwd(user.email, user.role, body.cwd) : null;
const contextFilter = body.context
? { context: body.context as string, contextId: body.contextId as string | undefined }
: undefined;
try {
let sessions = await storage.listUserSessions(userHome, contextFilter);
if (filterCwd) {
sessions = sessions.filter((s) => s.cwd === filterCwd);
}
return ctx.json({ sessions });
} catch (err) {
logger.error('Failed to list sessions', { email: ctx.get('email'), error: String(err) });
return ctx.json({ error: 'Failed to list sessions' }, 500);
}
});
/**
* GET /api/pi/sessions/:sessionId
* Get session detail with full message history
* Get session detail with full message history (used by active chat to load messages)
*/
piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
const user = ctx.get('user');
@@ -86,484 +48,23 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
const userHome = getHomeDirForRole(user.email, user.role);
try {
// Try loading from root first
let meta, messages;
try {
({ meta, messages } = await storage.loadSession(userHome, sessionId));
} catch {
// Not in root, search in groups
const groups = await storage.listGroups(userHome);
let found = false;
for (const group of groups) {
try {
({ meta, messages } = await storage.loadSession(userHome, sessionId, group.slug));
found = true;
break;
} catch {
continue;
}
}
if (!found) {
throw new Error('Session not found');
}
}
return ctx.json({
session: {
...meta,
messages,
},
});
const { meta, messages } = await storage.loadSession(userHome, sessionId);
return ctx.json({ session: { ...meta, messages } });
} catch {
// Session not found - this is expected for new sessions, don't log as error
return ctx.json({ error: 'Session not found' }, 404);
}
});
/**
* PUT /api/pi/sessions/:sessionId/messages
* Client-side message save — no-op, sessions are persisted server-side via WebSocket events
* No-op sessions are persisted server-side via WebSocket events
*/
piRestRouter.put('/pi/sessions/:sessionId/messages', async (ctx: Context) => {
return ctx.json({ success: true });
});
/**
* PATCH /api/pi/sessions/:sessionId
* Update session metadata (e.g., rename)
*/
piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => {
const user = ctx.get('user');
if (!user) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const sessionId = ctx.req.param('sessionId');
if (!sessionId) {
return ctx.json({ error: 'Session ID required' }, 400);
}
const body = await ctx.req.json();
if (!body.title || typeof body.title !== 'string') {
return ctx.json({ error: 'Title is required and must be a string' }, 400);
}
const userHome = getHomeDirForRole(user.email, user.role);
try {
// Find the session (root or in group)
let groupSlug: string | null = null;
try {
const { meta } = await storage.loadSession(userHome, sessionId);
groupSlug = meta.groupSlug || null;
} catch {
// Not in root, search groups
const groups = await storage.listGroups(userHome);
for (const group of groups) {
try {
await storage.loadSession(userHome, sessionId, group.slug);
groupSlug = group.slug;
break;
} catch {
continue;
}
}
}
const updatedMeta = await storage.updateSessionMeta(
userHome,
sessionId,
{
title: body.title,
},
groupSlug,
);
return ctx.json({
success: true,
session: updatedMeta,
});
} catch (err) {
logger.error('Failed to update session', { sessionId, email: ctx.get('email'), error: String(err) });
return ctx.json({ error: 'Failed to update session' }, 500);
}
});
/**
* DELETE /api/pi/sessions/:sessionId
* Delete a session
*/
piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => {
const user = ctx.get('user');
if (!user) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const sessionId = ctx.req.param('sessionId');
if (!sessionId) {
return ctx.json({ error: 'Session ID required' }, 400);
}
const userHome = getHomeDirForRole(user.email, user.role);
try {
// Find the session (root or in group)
let groupSlug: string | null = null;
try {
const { meta } = await storage.loadSession(userHome, sessionId);
groupSlug = meta.groupSlug || null;
} catch {
// Not in root, search groups
const groups = await storage.listGroups(userHome);
for (const group of groups) {
try {
const { meta } = await storage.loadSession(userHome, sessionId, group.slug);
groupSlug = meta.groupSlug || null;
break;
} catch {
continue;
}
}
}
await storage.deleteSession(userHome, sessionId, groupSlug);
// Update group session count if in a group
if (groupSlug) {
try {
const group = await storage.loadGroup(userHome, groupSlug);
group.sessionCount = Math.max(0, group.sessionCount - 1);
group.updatedAt = Date.now();
await storage.saveGroup(userHome, group);
} catch {
// Group might not exist anymore
}
}
return ctx.json({ success: true });
} catch (err) {
logger.error('Failed to delete session', { sessionId, email: ctx.get('email'), error: String(err) });
return ctx.json({ error: 'Failed to delete session' }, 500);
}
});
/**
* DELETE /api/pi/sessions
* Delete all sessions, optionally filtered by context
*/
piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
const user = ctx.get('user');
if (!user) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const body = await ctx.req.json().catch(() => ({}));
const sessionIds = Array.isArray(body.sessionIds) ? (body.sessionIds as string[]) : undefined;
const contextFilter = body.context
? { context: body.context as string, contextId: body.contextId as string | undefined }
: undefined;
const userHome = getHomeDirForRole(user.email, user.role);
try {
const allSessions = await storage.listUserSessions(userHome, contextFilter);
const sessions = sessionIds
? allSessions.filter((s) => sessionIds.includes(s.id))
: allSessions;
let deleted = 0;
for (const session of sessions) {
try {
await storage.deleteSession(userHome, session.id, session.groupSlug);
deleted++;
} catch {
// Skip sessions that fail to delete
}
}
logger.info('Bulk deleted sessions', {
email: user.email,
deleted,
total: sessions.length,
context: contextFilter?.context,
sessionIds: sessionIds?.length,
});
return ctx.json({ success: true, deleted });
} catch (err) {
logger.error('Failed to bulk delete sessions', { email: user.email, error: String(err) });
return ctx.json({ error: 'Failed to delete sessions' }, 500);
}
});
/**
* GET /api/pi/sessions/search
* Search sessions by query
*/
piRestRouter.get('/pi/sessions/search', async (ctx: Context) => {
const user = ctx.get('user');
if (!user) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const query = ctx.req.query('q');
if (!query) {
return ctx.json({ error: 'Query parameter required' }, 400);
}
const userHome = getHomeDirForRole(user.email, user.role);
try {
const results = await storage.searchSessions(userHome, query);
return ctx.json({ results });
} catch (err) {
logger.error('Failed to search sessions', { query, email: ctx.get('email'), error: String(err) });
return ctx.json({ error: 'Failed to search sessions' }, 500);
}
});
/**
* POST /api/pi/groups
* Create a new group
*/
piRestRouter.post('/pi/groups', async (ctx: Context) => {
const user = ctx.get('user');
if (!user) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const body = await ctx.req.json();
if (!body.name || typeof body.name !== 'string') {
return ctx.json({ error: 'Name is required and must be a string' }, 400);
}
if (!body.slug || typeof body.slug !== 'string') {
return ctx.json({ error: 'Slug is required and must be a string' }, 400);
}
const userHome = getHomeDirForRole(user.email, user.role);
try {
// Check if group already exists
const exists = await storage.groupExists(userHome, body.slug);
if (exists) {
return ctx.json({ error: 'Group with this slug already exists' }, 409);
}
const groupMeta = {
name: body.name,
slug: body.slug,
description: body.description || '',
createdAt: Date.now(),
updatedAt: Date.now(),
sessionCount: 0,
};
await storage.saveGroup(userHome, groupMeta);
// Move sessions to group if provided
if (body.sessionIds && Array.isArray(body.sessionIds)) {
for (const sessionId of body.sessionIds) {
try {
// Find session (check both root and other groups)
let sessionMeta;
try {
const { meta } = await storage.loadSession(userHome, sessionId);
sessionMeta = meta;
} catch {
// Session might be in another group - skip
continue;
}
await storage.moveSession(userHome, sessionId, sessionMeta.groupSlug || null, body.slug);
groupMeta.sessionCount++;
} catch (err) {
logger.error('Failed to move session to group', { sessionId, groupSlug: body.slug, error: String(err) });
}
}
// Update group with final session count
await storage.saveGroup(userHome, groupMeta);
}
return ctx.json({
success: true,
group: groupMeta,
});
} catch (err) {
logger.error('Failed to create group', { slug: body.slug, email: ctx.get('email'), error: String(err) });
return ctx.json({ error: 'Failed to create group' }, 500);
}
});
/**
* GET /api/pi/groups
* List all groups
*/
piRestRouter.get('/pi/groups', async (ctx: Context) => {
const user = ctx.get('user');
if (!user) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const userHome = getHomeDirForRole(user.email, user.role);
try {
const groups = await storage.listGroups(userHome);
return ctx.json({ groups });
} catch (err) {
logger.error('Failed to list groups', { email: ctx.get('email'), error: String(err) });
return ctx.json({ error: 'Failed to list groups' }, 500);
}
});
/**
* PATCH /api/pi/groups/:groupSlug
* Update group metadata
*/
piRestRouter.patch('/pi/groups/:groupSlug', async (ctx: Context) => {
const user = ctx.get('user');
if (!user) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const groupSlug = ctx.req.param('groupSlug');
if (!groupSlug) {
return ctx.json({ error: 'Group slug required' }, 400);
}
const body = await ctx.req.json();
const updates: Record<string, string> = {};
if (body.name && typeof body.name === 'string') {
updates.name = body.name;
}
if (body.description !== undefined) {
updates.description = body.description;
}
if (Object.keys(updates).length === 0) {
return ctx.json({ error: 'No valid updates provided' }, 400);
}
const userHome = getHomeDirForRole(user.email, user.role);
try {
const updatedGroup = await storage.updateGroupMeta(userHome, groupSlug, updates);
return ctx.json({
success: true,
group: updatedGroup,
});
} catch (err) {
logger.error('Failed to update group', { groupSlug, email: ctx.get('email'), error: String(err) });
return ctx.json({ error: 'Failed to update group' }, 500);
}
});
/**
* DELETE /api/pi/groups/:groupSlug
* Delete a group (moves sessions to root)
*/
piRestRouter.delete('/pi/groups/:groupSlug', async (ctx: Context) => {
const user = ctx.get('user');
if (!user) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const groupSlug = ctx.req.param('groupSlug');
if (!groupSlug) {
return ctx.json({ error: 'Group slug required' }, 400);
}
const userHome = getHomeDirForRole(user.email, user.role);
try {
await storage.deleteGroup(userHome, groupSlug);
return ctx.json({ success: true });
} catch (err) {
logger.error('Failed to delete group', { groupSlug, email: ctx.get('email'), error: String(err) });
return ctx.json({ error: 'Failed to delete group' }, 500);
}
});
/**
* POST /api/pi/sessions/:sessionId/move
* Move session to/from a group
*/
piRestRouter.post('/pi/sessions/:sessionId/move', async (ctx: Context) => {
const user = ctx.get('user');
if (!user) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const sessionId = ctx.req.param('sessionId');
if (!sessionId) {
return ctx.json({ error: 'Session ID required' }, 400);
}
const body = await ctx.req.json();
const toGroupSlug = body.groupSlug === null ? null : body.groupSlug;
if (toGroupSlug !== null && typeof toGroupSlug !== 'string') {
return ctx.json({ error: 'groupSlug must be a string or null' }, 400);
}
const userHome = getHomeDirForRole(user.email, user.role);
try {
// Find the session in root or any group
let fromGroupSlug: string | null = null;
let sessionFound = false;
// Try root level first
try {
await storage.loadSession(userHome, sessionId);
fromGroupSlug = null;
sessionFound = true;
} catch {
// Not in root, check groups
const groups = await storage.listGroups(userHome);
for (const group of groups) {
try {
await storage.loadSession(userHome, sessionId, group.slug);
fromGroupSlug = group.slug;
sessionFound = true;
break;
} catch {
continue;
}
}
}
if (!sessionFound) {
return ctx.json({ error: 'Session not found' }, 404);
}
// Validate target group exists
if (toGroupSlug !== null) {
const groupExists = await storage.groupExists(userHome, toGroupSlug);
if (!groupExists) {
return ctx.json({ error: 'Target group does not exist' }, 404);
}
}
const updatedMeta = await storage.moveSession(userHome, sessionId, fromGroupSlug, toGroupSlug);
return ctx.json({
success: true,
session: updatedMeta,
});
} catch (err) {
logger.error('Failed to move session', { sessionId, toGroupSlug, email: ctx.get('email'), error: String(err) });
return ctx.json({ error: 'Failed to move session' }, 500);
}
});
/**
* POST /api/pi/stt
* Proxy audio to configured Whisper server for speech-to-text transcription.
* Accepts multipart form data with audio file + whisper params.
*/
piRestRouter.post('/pi/stt', async (ctx: Context) => {
const sttConfig = await readSttConfig();
+21 -20
View File
@@ -1,7 +1,7 @@
export type Message = {
id: string;
timestamp: number;
role: "user" | "assistant" | "tool";
role: 'user' | 'assistant' | 'tool';
text?: string;
model?: string;
cost?: MessageCost;
@@ -45,7 +45,7 @@ export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhi
export type ClientMessage =
| {
type: "chat";
type: 'chat';
prompt: string;
displayText?: string;
sessionId?: string;
@@ -58,20 +58,21 @@ export type ClientMessage =
thinking?: ThinkingLevel;
context?: string;
contextId?: string;
resumeSummary?: string;
}
| {
type: "resume";
type: 'resume';
sessionId: string;
cwd?: string;
cwdRoot?: string;
}
| {
type: "stop";
type: 'stop';
};
export type ServerMessage =
| {
type: "session:init";
type: 'session:init';
sessionId: string;
model: string;
cwd: string;
@@ -79,67 +80,67 @@ export type ServerMessage =
contextId?: string;
}
| {
type: "assistant:text";
type: 'assistant:text';
text: string;
}
| {
type: "assistant:delta";
type: 'assistant:delta';
text: string;
}
| {
type: "tool:start";
type: 'tool:start';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
}
| {
type: "tool:result";
type: 'tool:result';
toolCallId: string;
output: string;
isError: boolean;
}
| {
type: "result";
type: 'result';
sessionId: string;
cost: MessageCost;
}
| {
type: "sync:messages";
type: 'sync:messages';
sessionId: string;
messages: Message[];
isGenerating: boolean;
streamingText: string;
}
| {
type: "error";
type: 'error';
message: string;
errorCode?: string;
}
| {
type: "stopped";
type: 'stopped';
};
export type PiEvent =
| { type: "text"; text: string }
| { type: "delta"; text: string }
| { type: 'text'; text: string }
| { type: 'delta'; text: string }
| {
type: "tool:start";
type: 'tool:start';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
}
| {
type: "tool:result";
type: 'tool:result';
toolCallId: string;
output: string;
isError: boolean;
}
| {
type: "result";
type: 'result';
cost: MessageCost;
}
| { type: "error"; message: string }
| { type: "stopped" };
| { type: 'error'; message: string }
| { type: 'stopped' };
export type UserSession = {
sessionId: string;
+10 -3
View File
@@ -248,11 +248,17 @@ async function handleChat(
thinking?: string;
context?: string;
contextId?: string;
resumeSummary?: string;
},
): Promise<void> {
const { email, username, userId } = ws.data;
const sessionId = msg.sessionId || randomUUID();
// Prepend resume summary to the prompt if present
const prompt = msg.resumeSummary
? `Here is a summary of a previous conversation to continue from:\n\n${msg.resumeSummary}\n\n---\n\nUser's new message: ${msg.prompt}`
: msg.prompt;
// Use provided model, or fall back to user default, or use system default
let model = msg.model;
let modelSource = 'client-provided';
@@ -277,7 +283,7 @@ async function handleChat(
});
if (model.startsWith('claude-code')) {
return handleClaudeCodeChat(ws, sessionId, model, msg);
return handleClaudeCodeChat(ws, sessionId, model, msg, prompt);
}
const homeDir = getHomeDirForRole(email, ws.data.role);
@@ -365,7 +371,7 @@ async function handleChat(
// Send prompt to Pi via sidecar
const requestId = randomUUID();
session.isGenerating = true;
sidecar.sendPiPrompt(sessionId, msg.prompt, requestId);
sidecar.sendPiPrompt(sessionId, prompt, requestId);
}
async function handleClaudeCodeChat(
@@ -382,6 +388,7 @@ async function handleClaudeCodeChat(
cwdRoot?: string;
sandboxed?: boolean;
},
effectivePrompt: string,
): Promise<void> {
const { email, username, userId } = ws.data;
const homeDir = getHomeDirForRole(email, ws.data.role);
@@ -428,7 +435,7 @@ async function handleClaudeCodeChat(
userId,
email,
username,
prompt: msg.prompt,
prompt: effectivePrompt,
sessionKey: sessionId,
cwd,
model,
@@ -0,0 +1,160 @@
import { createRouter } from '../../create-router';
import {
listSavedSessions,
getSavedSession,
createSavedSession,
updateSavedSessionMessages,
deleteSavedSession,
} from 'officerdb';
import * as storage from '../pi/storage';
import { getHomeDirForRole } from '../../data-path';
import type { Message, MessageCost } from '../pi/types';
export const savedSessionsRouter = createRouter();
// POST /saved-sessions — save session instantly (no LLM summary)
savedSessionsRouter.post('/', async (ctx) => {
const user = ctx.get('user');
const { sessionId } = ctx.get('body') as { sessionId: string };
if (!sessionId) {
return ctx.json({ error: 'sessionId is required' }, 400);
}
const homeDir = getHomeDirForRole(user.email, user.role);
let meta;
let messages: Message[];
try {
const loaded = await storage.loadSession(homeDir, sessionId);
meta = loaded.meta;
messages = loaded.messages;
} catch {
return ctx.json({ error: 'Session not found on disk' }, 404);
}
try {
const saved = await createSavedSession({
userId: user.id,
provider: meta.model,
context: meta.context ?? null,
contextId: meta.contextId ?? null,
title: meta.title,
summary: meta.title,
rawMessages: messages,
cwd: meta.cwd,
cost: meta.cost as unknown as Record<string, unknown>,
});
return ctx.json(saved, 201);
} catch (err) {
console.error('Failed to create saved session:', err);
return ctx.json({ error: 'Failed to save session' }, 500);
}
});
// GET /saved-sessions — list (no rawMessages)
savedSessionsRouter.get('/', async (ctx) => {
const user = ctx.get('user');
const rows = await listSavedSessions(user.id);
return ctx.json(rows);
});
// GET /saved-sessions/:id — full record
savedSessionsRouter.get('/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (Number.isNaN(id)) {
return ctx.json({ error: 'Invalid id' }, 400);
}
const session = await getSavedSession(id, user.id);
if (!session) {
return ctx.json({ error: 'Not found' }, 404);
}
return ctx.json(session);
});
// PUT /saved-sessions/:id — update messages from disk (auto-save after each turn)
savedSessionsRouter.put('/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
const { sessionId } = ctx.get('body') as { sessionId: string };
if (Number.isNaN(id)) {
return ctx.json({ error: 'Invalid id' }, 400);
}
if (!sessionId) {
return ctx.json({ error: 'sessionId is required' }, 400);
}
const homeDir = getHomeDirForRole(user.email, user.role);
let meta;
let messages: Message[];
try {
const loaded = await storage.loadSession(homeDir, sessionId);
meta = loaded.meta;
messages = loaded.messages;
} catch {
return ctx.json({ error: 'Session not found on disk' }, 404);
}
const updated = await updateSavedSessionMessages(
id,
user.id,
messages,
meta.cost as unknown as Record<string, unknown>,
);
if (!updated) {
return ctx.json({ error: 'Not found' }, 404);
}
return ctx.json({ ok: true });
});
// DELETE /saved-sessions/:id
savedSessionsRouter.delete('/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (Number.isNaN(id)) {
return ctx.json({ error: 'Invalid id' }, 400);
}
const deleted = await deleteSavedSession(id, user.id);
if (!deleted) {
return ctx.json({ error: 'Not found' }, 404);
}
return ctx.json({ ok: true });
});
// POST /saved-sessions/:id/resume — get full session for resuming
savedSessionsRouter.post('/:id/resume', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (Number.isNaN(id)) {
return ctx.json({ error: 'Invalid id' }, 400);
}
const session = await getSavedSession(id, user.id);
if (!session) {
return ctx.json({ error: 'Not found' }, 404);
}
const cost = session.cost as unknown as MessageCost;
return ctx.json({
context: session.context,
cwd: session.cwd,
model: session.provider,
cost,
rawMessages: session.rawMessages,
});
});
+3 -4
View File
@@ -30,10 +30,9 @@ export const scrapeRouter = createRouter();
scrapeRouter.post('/', async (ctx) => {
const user = ctx.get('user');
const { url, sessionId, provider } = ctx.get('body') as {
const { url, sessionId } = ctx.get('body') as {
url: string;
sessionId?: string;
provider?: 'claude' | 'pi-mono';
};
if (!url) return ctx.json({ error: 'url is required' }, 400);
@@ -140,9 +139,9 @@ scrapeRouter.post('/', async (ctx) => {
let attachmentId: string;
let saveDir: string;
if (sessionId && provider) {
if (sessionId) {
attachmentId = `${slug}.html`;
saveDir = getAttachmentsDir(user.email, provider, sessionId);
saveDir = getAttachmentsDir(user.email, sessionId);
} else {
attachmentId = `${crypto.randomUUID()}.html`;
saveDir = getTmpAttachmentsDir(user.email);
-207
View File
@@ -1,207 +0,0 @@
import { Hono } from 'hono';
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { getClaudeDir, getSessionDir, getArchivedSessionDir, getPiMonoDir, getPiMonoSessionDir } from '@@/data-path';
import type { HonoVariables } from '@@/create-router';
export const sessionsRouter = new Hono<{ Variables: HonoVariables }>();
// --- List all sessions (merged from both providers) ---
sessionsRouter.get('/sessions', async (ctx) => {
const { email } = ctx.get('user');
const [claudeSessions, piMonoSessions] = await Promise.all([fetchClaudeSessions(email), fetchPiMonoSessions(email)]);
const merged = [...claudeSessions, ...piMonoSessions].sort((a, b) => b.createdAt - a.createdAt);
return ctx.json(merged);
});
// --- Messages ---
sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
const { email } = ctx.get('user');
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
if (provider === 'claude') {
const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]);
try {
return ctx.json(await file.json());
} catch {
return ctx.json([]);
}
}
if (provider === 'pi-mono') {
const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]);
try {
return ctx.json(await file.json());
} catch {
return ctx.json([]);
}
}
return ctx.json({ error: 'invalid provider' }, 400);
});
sessionsRouter.put('/sessions/:provider/:id/messages', async (ctx) => {
const { email } = ctx.get('user');
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
if (provider !== 'claude' && provider !== 'pi-mono') return ctx.json({ error: 'invalid provider' }, 400);
const messages = ctx.get('body');
const dir = provider === 'pi-mono' ? getPiMonoSessionDir(email, id) : getSessionDir(email, id);
await Bun.write(join(dir, 'messages.json'), JSON.stringify(messages));
return ctx.json({ ok: true });
});
// --- Rename ---
sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const { email } = ctx.get('user');
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
const body = ctx.get('body') as { title?: string };
if (!body?.title || typeof body.title !== 'string') return ctx.json({ error: 'title required' }, 400);
if (provider === 'claude') {
const dir = getSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
let meta: Record<string, unknown>;
try {
meta = await metaFile.json();
} catch {
return ctx.json({ error: 'corrupted session' }, 500);
}
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true });
}
if (provider === 'pi-mono') {
const dir = getPiMonoSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
let meta: Record<string, unknown>;
try {
meta = await metaFile.json();
} catch {
return ctx.json({ error: 'corrupted session' }, 500);
}
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true });
}
return ctx.json({ error: 'invalid provider' }, 400);
});
// --- Delete ---
sessionsRouter.delete('/sessions/:provider/:id', async (ctx) => {
const { email } = ctx.get('user');
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
if (provider === 'claude') {
const dir = getSessionDir(email, id);
try {
await rm(dir, { recursive: true });
} catch {
// dir may not exist
}
return ctx.json({ ok: true });
}
if (provider === 'pi-mono') {
const dir = getPiMonoSessionDir(email, id);
try {
await rm(dir, { recursive: true });
} catch {
// dir may not exist
}
return ctx.json({ ok: true });
}
return ctx.json({ error: 'invalid provider' }, 400);
});
// --- Archive (Claude only) ---
sessionsRouter.post('/sessions/:provider/:id/archive', async (ctx) => {
const { email } = ctx.get('user');
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
if (provider === 'pi-mono') return ctx.json({ error: 'pi-mono sessions cannot be archived' }, 400);
if (provider !== 'claude') return ctx.json({ error: 'invalid provider' }, 400);
const src = getSessionDir(email, id);
const dest = getArchivedSessionDir(email, id);
await mkdir(join(getClaudeDir(email), 'archived'), { recursive: true });
await rename(src, dest);
return ctx.json({ ok: true });
});
// --- Helpers ---
type SessionMeta = {
id: string;
title: string;
createdAt: number;
provider: 'claude' | 'pi-mono';
model?: string | null;
};
async function fetchClaudeSessions(email: string): Promise<SessionMeta[]> {
const dir = getClaudeDir(email);
try {
const entries = await readdir(dir);
const sessions = await Promise.all(
entries
.filter((name) => name !== 'archived')
.map(async (id) => {
try {
const metaFile = Bun.file(join(dir, id, 'meta.json'));
if (!(await metaFile.exists())) return null;
const meta = await metaFile.json();
return { ...meta, provider: 'claude' as const };
} catch {
return null;
}
}),
);
return sessions.filter((s): s is SessionMeta => s !== null);
} catch {
return [];
}
}
async function fetchPiMonoSessions(email: string): Promise<SessionMeta[]> {
const dir = getPiMonoDir(email);
try {
const entries = await readdir(dir);
const sessions = await Promise.all(
entries.map(async (id) => {
try {
const metaFile = Bun.file(join(dir, id, 'meta.json'));
if (!(await metaFile.exists())) return null;
const meta = await metaFile.json();
return { ...meta, provider: 'pi-mono' as const };
} catch {
return null;
}
}),
);
return sessions.filter((s): s is SessionMeta => s !== null);
} catch {
return [];
}
}
+2 -3
View File
@@ -13,7 +13,6 @@ uploadRouter.post('/', async (ctx) => {
const file = body.file as File | null;
const sessionId = (body.sessionId as string) || null;
const provider = (body.provider as 'claude' | 'pi-mono') || null;
if (!file || !(file instanceof File)) {
return ctx.json({ error: 'file is required' }, 400);
@@ -33,8 +32,8 @@ uploadRouter.post('/', async (ctx) => {
const filename = `${crypto.randomUUID()}.${ext}`;
let saveDir: string;
if (sessionId && provider) {
saveDir = getAttachmentsDir(user.email, provider, sessionId);
if (sessionId) {
saveDir = getAttachmentsDir(user.email, sessionId);
} else {
saveDir = getTmpAttachmentsDir(user.email);
}
+2 -9
View File
@@ -31,12 +31,6 @@ export const getHomeDirForRole = (email: string, role: string | null): string =>
export const getUserPiConfigDir = (email: string) => join(DATA_PATH, email, 'home', '.pi', 'agent');
export const getUserSettingsDir = (email: string) => join(DATA_PATH, email, 'settings');
export const getUserStateDir = (email: string) => join(DATA_PATH, email, 'state');
export const getUserStateFile = (email: string) => join(DATA_PATH, email, 'state', 'state.json');
export const getUserProjectsDir = (email: string) => join(DATA_PATH, email, 'home', 'Projects');
export const getNativeSkillsDir = () => join(SEED_PATH, 'skills');
@@ -69,10 +63,9 @@ export const getGlobalProcessesDir = () => join(DATA_PATH, 'processes');
export const getUserProcessesDir = (email: string) => join(DATA_PATH, email, 'processes');
export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'chat_sessions', 'tmp_attachments');
export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'attachments', 'tmp');
export const getAttachmentsDir = (email: string, provider: 'claude' | 'pi-mono', sessionId: string) =>
join(DATA_PATH, email, 'chat_sessions', provider, sessionId, 'attachments');
export const getAttachmentsDir = (email: string, sessionId: string) => join(DATA_PATH, email, 'attachments', sessionId);
export const getUserEmailDir = (email: string) => join(DATA_PATH, email, 'Gmail', 'emails');
+2 -2
View File
@@ -12,7 +12,6 @@ import { skillsRouter } from './api/skills/skills';
import { tasksRouter } from './api/tasks/tasks';
import { toolsRouter } from './api/tools/tools';
import { processesRouter } from './api/processes/processes';
import { sessionsRouter } from './api/sessions/sessions';
import { scrapeRouter } from './api/scrape/scrape';
import { uploadRouter } from './api/upload/upload';
import { settingsRouter } from './api/settings/settings';
@@ -30,6 +29,7 @@ import { browserRouter } from './api/browser/router';
import { desktopRouter } from './api/desktop/rest';
import { appsRouter, appServeRouter } from './api/apps';
import { bugReportRouter } from './api/bug-report/bug-report';
import { savedSessionsRouter } from './api/saved-sessions/saved-sessions';
import { broadcastPanelRefresh } from './api/terminal/websocket';
import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
@@ -84,7 +84,6 @@ protectedRouter.route('/skills', skillsRouter);
protectedRouter.route('/tasks', tasksRouter);
protectedRouter.route('/tools', toolsRouter);
protectedRouter.route('/processes', processesRouter);
protectedRouter.route('/', sessionsRouter);
protectedRouter.route('/scrape', scrapeRouter);
protectedRouter.route('/upload', uploadRouter);
protectedRouter.route('/user', settingsRouter);
@@ -100,6 +99,7 @@ protectedRouter.route('/channels', channelsRouter);
protectedRouter.route('/browser', browserRouter);
protectedRouter.route('/apps', appsRouter);
protectedRouter.route('/bug-report', bugReportRouter);
protectedRouter.route('/saved-sessions', savedSessionsRouter);
desktopRouter.use(superAdminMiddleware);
protectedRouter.route('/desktop', desktopRouter);
protectedRouter.route('/', piRestRouter);
@@ -1,154 +1,172 @@
import { useEffect, useRef, useState } from 'react';
import { MessageSquare, History, Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { MessageSquare, History, Plus, Save, Check, Trash2, Loader2 } from 'lucide-react';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import { useWorkspace } from '../../components/Workspace/WorkspaceContext';
import { useChatSessions } from 'state/useChatSessions';
import { useSavedSessions, messagesToTranscript, type RawMessage } from 'state/useSavedSessions';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { getProviderDisplayName } from 'state/useModels';
import type { ChatMessage } from './types';
import type { AutoSaveMapping } from '../ChatHistory/ChatDetailPanel';
type ChatSessionSelection = {
export type ChatSessionSelection = {
sessionId: string | null;
model?: string | null;
dashboardId?: string;
resumeSummary?: string;
initialMessages?: ChatMessage[];
};
function formatModel(model: string): string {
if (!model.includes('/')) return model;
const [provider, modelId] = model.split('/') as [string, string];
if (provider.startsWith('officer-local-')) {
return `${getProviderDisplayName(provider)} - ${modelId}`;
}
return model.replace('/', ' - ');
}
export const ChatHeader = () => {
const { dashboardId } = useWorkspace();
const contextFilter = dashboardId === 'email' || dashboardId === 'screens/email'
? { context: 'email' as const }
: dashboardId?.startsWith('proj-layout-')
? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') }
: dashboardId && !dashboardId.startsWith('screens/')
? { context: 'dashboard' as const, contextId: dashboardId }
: undefined;
const { sessions, clearSessions } = useChatSessions(contextFilter);
const [selection, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
const { sessions, saveSession, deleteSavedSession, resumeSession } = useSavedSessions();
const [, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
const [activeSessionId] = usePanelChannel<string | null>('chat:active-session', null);
const [autoSave, setAutoSave] = usePanelChannel<AutoSaveMapping>('chat:auto-save', null);
const [open, setOpen] = useState(false);
const autoResumedRef = useRef(false);
const [isSaving, setIsSaving] = useState(false);
const [isResuming, setIsResuming] = useState<number | null>(null);
// Clear stale selection from a different workspace
useEffect(() => {
if (selection && selection.dashboardId !== dashboardId) {
setSelection(null);
autoResumedRef.current = false;
}
}, [dashboardId, selection]);
const isSaved = !!autoSave && autoSave.sessionId === activeSessionId;
// Auto-resume the latest session for this workspace
useEffect(() => {
if (autoResumedRef.current) return;
if (selection && selection.dashboardId === dashboardId) return;
if (sessions.length > 0) {
const latest = sessions[0]!;
setSelection({ sessionId: latest.id, model: latest.model ?? null, dashboardId });
autoResumedRef.current = true;
}
}, [sessions, selection, dashboardId]);
const selectSession = (sessionId: string | null, model?: string | null) => {
setSelection({ sessionId, model, dashboardId });
const selectSession = (selection: ChatSessionSelection) => {
setSelection(selection);
setOpen(false);
};
const activeInList = activeSessionId ? sessions.some((s) => s.id === activeSessionId) : false;
const handleSave = async () => {
if (!activeSessionId || isSaving) return;
setIsSaving(true);
try {
const result = await saveSession(activeSessionId);
setAutoSave({ sessionId: activeSessionId, savedId: result.id });
} finally {
setIsSaving(false);
}
};
const handleResume = async (id: number, model: string) => {
setIsResuming(id);
try {
const result = await resumeSession(id);
const rawMessages = result.rawMessages ?? [];
const chatMessages: ChatMessage[] = rawMessages.map((m: RawMessage) => {
if (m.role === 'user') return { role: 'user' as const, text: m.text || '' };
if (m.role === 'assistant') return { role: 'assistant' as const, id: m.id, text: m.text || '' };
if (m.role === 'tool') {
return {
role: 'tool' as const,
toolName: m.toolName || '',
toolInput: m.toolInput || {},
toolCallId: m.toolCallId || '',
output: m.output,
isError: m.isError,
};
}
return { role: 'assistant' as const, text: '' };
});
const transcript = messagesToTranscript(rawMessages);
selectSession({
sessionId: null,
model: result.model ?? model,
resumeSummary: transcript,
initialMessages: chatMessages,
});
} catch {
// Failed to resume
} finally {
setIsResuming(null);
}
};
const handleDelete = async (e: React.MouseEvent, id: number) => {
e.stopPropagation();
await deleteSavedSession(id);
};
return (
<>
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium truncate flex-1">Chat</span>
{activeSessionId &&
(isSaving ? (
<span className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0">
<Loader2 className="h-3 w-3 animate-spin" />
Saving
</span>
) : isSaved ? (
<span className="flex items-center gap-1 text-[10px] text-duck-teal/60 shrink-0">
<Check className="h-3 w-3" />
</span>
) : (
<button
type="button"
onClick={handleSave}
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
title="Save session"
>
<Save className="h-3.5 w-3.5" />
</button>
))}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
title="Session history"
title="Saved sessions"
>
<History className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-0 max-h-80 flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="text-xs font-medium">Sessions</span>
<div className="flex items-center gap-2">
{sessions.length > 0 && (
<button
type="button"
onClick={async () => {
await clearSessions();
selectSession(null);
}}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-destructive cursor-pointer"
>
<Trash2 className="h-3 w-3" />
Clear
</button>
)}
<button
type="button"
onClick={() => selectSession(null)}
className="flex items-center gap-1 text-xs text-duck-teal hover:text-duck-teal/80 cursor-pointer"
>
<Plus className="h-3 w-3" />
New
</button>
</div>
<span className="text-xs font-medium">Saved Sessions</span>
<button
type="button"
onClick={() => selectSession({ sessionId: null })}
className="flex items-center gap-1 text-xs text-duck-teal hover:text-duck-teal/80 cursor-pointer"
>
<Plus className="h-3 w-3" />
New
</button>
</div>
<div className="flex-1 overflow-y-auto">
{/* Show active session at top if not yet in fetched list */}
{activeSessionId && !activeInList && (
<button
type="button"
onClick={() => selectSession(activeSessionId)}
className="w-full text-left px-3 py-2 bg-duck-teal/10 border-b border-border/50 cursor-pointer"
>
<div className="text-xs font-medium truncate text-duck-teal">Current session</div>
<div className="text-[10px] text-muted-foreground font-mono">{activeSessionId.slice(0, 8)}</div>
</button>
)}
{sessions.length === 0 && !activeSessionId ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">No sessions yet</div>
{sessions.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">No saved sessions</div>
) : (
sessions.map((session) => {
const isActive = session.id === activeSessionId;
return (
<button
key={session.id}
type="button"
onClick={() => selectSession(session.id, session.model)}
className={`w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer border-b border-border/50 last:border-0 ${isActive ? 'bg-duck-teal/10' : ''}`}
>
<div className={`text-xs font-medium truncate ${isActive ? 'text-duck-teal' : ''}`}>
{session.title}
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span>
{new Date(session.updatedAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
{session.model && (
<>
<span className="text-muted-foreground/40">|</span>
<span className="truncate text-duck-teal/60">{formatModel(session.model)}</span>
</>
sessions.map((session) => (
<button
key={session.id}
type="button"
onClick={() => handleResume(session.id, session.provider)}
disabled={isResuming === session.id}
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer border-b border-border/50 last:border-0 group/item"
>
<div className="flex items-center justify-between">
<div className="text-xs font-medium truncate flex-1">
{isResuming === session.id ? (
<span className="flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
Loading
</span>
) : (
session.title
)}
</div>
</button>
);
})
<button
type="button"
onClick={(e) => handleDelete(e, session.id)}
className="p-0.5 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0 ml-1"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
<div className="text-[10px] text-muted-foreground">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
</button>
))
)}
</div>
</PopoverContent>
@@ -1,13 +1,13 @@
import { MessageSquare } from 'lucide-react';
import { useChatSessions } from 'state/useChatSessions';
import { useSavedSessions } from 'state/useSavedSessions';
export const ChatList = () => {
const { sessions } = useChatSessions();
const { sessions } = useSavedSessions();
if (sessions.length === 0) {
return (
<div className="h-full flex items-center justify-center text-duck-dark/30 dark:text-foreground/30 text-sm">
No sessions yet. Start a new chat!
No saved sessions yet.
</div>
);
}
@@ -15,9 +15,8 @@ export const ChatList = () => {
return (
<div className="space-y-1.5 p-3">
{sessions.map((session) => (
<a
<div
key={session.id}
href={`/chat/${session.id}`}
className="block p-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 hover:bg-duck-teal/5 dark:hover:bg-duck-teal/10 transition-colors group"
>
<div className="flex items-start gap-2 min-w-0">
@@ -34,16 +33,10 @@ export const ChatList = () => {
minute: '2-digit',
})}
</div>
{session.model && (
<div className="text-xs text-duck-teal/70 dark:text-duck-teal/60 truncate">
{session.model.includes('/') ? session.model.replace('/', ' - ') : session.model}
</div>
)}
</div>
</div>
</a>
</div>
))}
</div>
);
};
@@ -1,18 +1,20 @@
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useWorkspace } from '../../components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useSavedSessions } from 'state/useSavedSessions';
import { usePiChat } from '../../hooks/usePiChat';
import { EmbeddableChat } from './EmbeddableChat';
type ChatSessionSelection = {
sessionId: string | null;
model?: string | null;
dashboardId?: string;
};
import type { ChatSessionSelection } from './ChatHeader';
import type { AutoSaveMapping } from '../ChatHistory/ChatDetailPanel';
import type { ChatMessage } from './types';
type ChatPanelInnerProps = {
sessionId?: string;
model?: string;
resumeSummary?: string;
initialMessages?: ChatMessage[];
scoped: boolean;
sandboxed: boolean;
cwdParam?: { root?: string; path: string };
@@ -22,8 +24,43 @@ type ChatPanelInnerProps = {
onTurnComplete?: (hadToolCalls: boolean) => void;
};
const ChatPanelInner = ({ sessionId, model, scoped, sandboxed, cwdParam, promptPrefix, chatContext, setActiveSession, onTurnComplete }: ChatPanelInnerProps) => {
const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped, onTurnComplete, ...chatContext });
const ChatPanelInner = ({
sessionId,
model,
resumeSummary,
initialMessages,
scoped,
sandboxed,
cwdParam,
promptPrefix,
chatContext,
setActiveSession,
onTurnComplete,
}: ChatPanelInnerProps) => {
const { updateSessionMessages } = useSavedSessions();
const [autoSave] = usePanelChannel<AutoSaveMapping>('chat:auto-save', null);
const autoSaveRef = useRef(autoSave);
autoSaveRef.current = autoSave;
const handleTurnComplete = useCallback(
(hadToolCalls: boolean) => {
onTurnComplete?.(hadToolCalls);
const mapping = autoSaveRef.current;
if (mapping) {
updateSessionMessages(mapping.savedId, mapping.sessionId).catch(() => {});
}
},
[onTurnComplete, updateSessionMessages],
);
const chat = usePiChat(sessionId, model, {
replaceUrl: false,
projectScoped: scoped,
onTurnComplete: handleTurnComplete,
resumeSummary,
initialMessages,
...chatContext,
});
useEffect(() => {
setActiveSession(chat.sessionId);
@@ -50,37 +87,45 @@ export const ChatPanelWrapper = () => {
const hostRoot = root === '~' || root === 'officer.dev';
const sandboxed = !hostRoot;
const chatContext = dashboardId === 'email' || dashboardId === 'screens/email'
? { context: 'email' as const }
: dashboardId?.startsWith('proj-layout-')
? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') }
: dashboardId && !dashboardId.startsWith('screens/')
? { context: 'dashboard' as const, contextId: dashboardId }
: {};
const chatContext =
dashboardId === 'email' || dashboardId === 'screens/email'
? { context: 'email' as const }
: dashboardId?.startsWith('proj-layout-')
? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') }
: dashboardId && !dashboardId.startsWith('screens/')
? { context: 'dashboard' as const, contextId: dashboardId }
: {};
const [selection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
const [, setActiveSession] = usePanelChannel<string | null>('chat:active-session', null);
const [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
const [, setFilesRefresh] = usePanelChannel<number>('files:refresh-signal', 0);
const onTurnComplete = useCallback((hadToolCalls: boolean) => {
if (hadToolCalls) {
setPreviewRefresh(Date.now());
setFilesRefresh(Date.now());
}
}, [setPreviewRefresh, setFilesRefresh]);
const onTurnComplete = useCallback(
(hadToolCalls: boolean) => {
if (hadToolCalls) {
setPreviewRefresh(Date.now());
setFilesRefresh(Date.now());
}
},
[setPreviewRefresh, setFilesRefresh],
);
const cwdParam = scoped ? { root, path: cwd } : undefined;
const isCurrentWorkspace = !selection?.dashboardId || selection.dashboardId === dashboardId;
const sessionId = isCurrentWorkspace ? selection?.sessionId ?? undefined : undefined;
const model = isCurrentWorkspace ? selection?.model ?? undefined : undefined;
const sessionId = isCurrentWorkspace ? (selection?.sessionId ?? undefined) : undefined;
const model = isCurrentWorkspace ? (selection?.model ?? undefined) : undefined;
const resumeSummary = isCurrentWorkspace ? selection?.resumeSummary : undefined;
const initialMessages = isCurrentWorkspace ? selection?.initialMessages : undefined;
return (
<ChatPanelInner
key={sessionId ?? 'new'}
key={resumeSummary ? `resume-${Date.now()}` : (sessionId ?? 'new')}
sessionId={sessionId}
model={model}
resumeSummary={resumeSummary}
initialMessages={initialMessages}
scoped={scoped}
sandboxed={sandboxed}
cwdParam={cwdParam}
@@ -17,8 +17,6 @@ export { EmbeddableChat, type UseEmbeddableChatType } from './EmbeddableChat';
export { usePiChat, type UsePiChatType } from '../../hooks/usePiChat';
export { ChatList } from './ChatList';
export { useSlashCommands } from './useSlashCommands';
export { useChatSessions, type UseChatSessionsType } from './useChatSessions';
export { useChatSession, type UseChatSessionType } from './useChatSession';
export { useAttachments, type UseAttachmentsType } from './useAttachments';
export { useAudioRecording, type UseAudioRecordingType } from './useAudioRecording';
@@ -18,29 +18,6 @@ export type ModelOption = {
images?: boolean;
};
export type SessionEntry = {
id: string;
title: string;
model: string;
cwd: string;
createdAt: number;
updatedAt: number;
messageCount: number;
cost: MessageCost;
groupSlug?: string | null;
context?: string;
contextId?: string;
};
export type GroupEntry = {
name: string;
slug: string;
description?: string;
createdAt: number;
updatedAt: number;
sessionCount: number;
};
export type ChatMessage =
| { role: 'user'; text: string; images?: { filename: string; dataUrl: string }[] }
| { role: 'assistant'; id?: string; text: string }
@@ -93,44 +70,3 @@ export type SlashCommand = {
description?: string;
execute: (args: string, sessionId: string) => Promise<{ success: boolean; feedback: string }>;
};
// Legacy type aliases for backward compatibility during migration
// TODO: Remove after Phase 9 cleanup
/** @deprecated Use SessionEntry instead */
export type LegacySessionEntry = {
id: string;
title: string;
createdAt: number;
provider: 'claude' | 'pi-mono';
model?: string | null;
};
/** @deprecated Use ChatMessage with toolCallId instead */
export type LegacyChatMessage =
| { role: 'user'; text: string; images?: { filename: string; dataUrl: string }[] }
| { role: 'assistant'; text: string }
| { role: 'system'; text: string }
| {
role: 'tool';
toolName: string;
toolInput: Record<string, unknown>;
toolUseId: string;
output?: string;
isError?: boolean;
}
| { role: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
| { role: 'error'; text: string };
/** @deprecated Use ServerMessage instead */
export type LegacyServerMessage =
| { type: 'session:init'; sessionId: string; model: string | null }
| { type: 'system:prompt'; text: string }
| { type: 'assistant:text'; text: string }
| { type: 'assistant:partial'; text: string }
| { type: 'tool:use'; toolName: string; toolInput: Record<string, unknown>; toolUseId: string }
| { type: 'tool:result'; toolUseId: string; output: string; isError: boolean }
| { type: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
| { type: 'error'; message: string }
| { type: 'stopped' }
| { type: 'messages:sync'; messages: LegacyChatMessage[]; streamingText: string; isGenerating: boolean };
@@ -1,77 +0,0 @@
import type { SessionEntry, ChatMessage, Message } from './types';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
type SessionWithMessages = {
id: string;
title: string;
model: string;
cwd: string;
groupSlug?: string | null;
createdAt: number;
updatedAt: number;
messageCount: number;
cost: {
inputTokens: number;
outputTokens: number;
totalUSD: number;
};
messages: Message[];
};
type UseChatSessionParams = {
sessionId: string | null | undefined;
};
export function useChatSession({ sessionId }: UseChatSessionParams) {
const client = useClient();
const queryClient = useQueryClient();
const { data: session, isLoading } = useQuery<SessionWithMessages | null>({
queryKey: ['PI_SESSION', sessionId],
enabled: !!sessionId,
queryFn: async () => {
if (!sessionId) return null;
const result = await client.get<{ session: SessionWithMessages }>(`/pi/sessions/${sessionId}`);
return result.session;
},
});
async function saveMessages(messages: ChatMessage[]) {
if (!sessionId) return;
await client.put(`/pi/sessions/${sessionId}/messages`, messages);
}
async function rename(title: string) {
if (!sessionId) return;
await client.patch(`/pi/sessions/${sessionId}`, { title });
queryClient.invalidateQueries({ queryKey: ['PI_SESSION', sessionId] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
}
async function deleteSession() {
if (!sessionId) return;
await client.delete(`/pi/sessions/${sessionId}`);
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
queryClient.removeQueries({ queryKey: ['PI_SESSION', sessionId] });
}
async function moveToGroup(groupSlug: string | null) {
if (!sessionId) return;
await client.post(`/pi/sessions/${sessionId}/move`, { groupSlug });
queryClient.invalidateQueries({ queryKey: ['PI_SESSION', sessionId] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
}
return {
session,
isLoading,
saveMessages,
rename,
delete: deleteSession,
moveToGroup,
};
}
export type UseChatSessionType = ReturnType<typeof useChatSession>;
@@ -1,41 +0,0 @@
import type { SessionEntry } from './types';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery } from '@tanstack/react-query';
type UseChatSessionsParams = {
cwd?: string;
cwdRoot?: string;
};
export function useChatSessions({ cwd, cwdRoot }: UseChatSessionsParams = {}) {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data: sessions = [], isLoading } = useQuery<SessionEntry[]>({
queryKey: ['PI_SESSIONS', cwd, cwdRoot],
enabled: isAuthenticated,
queryFn: async () => {
const body: Record<string, string> = {};
if (cwd) body.cwd = cwd;
if (cwdRoot) body.cwdRoot = cwdRoot;
const result = await client.post<{ sessions: SessionEntry[] }>('/pi/sessions', body);
return result.sessions;
},
});
function searchSessions(query: string) {
const params = new URLSearchParams({ q: query });
if (cwd) params.set('cwd', cwd);
if (cwdRoot) params.set('cwdRoot', cwdRoot);
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?${params}`);
}
return {
sessions,
isLoading,
searchSessions,
};
}
export type UseChatSessionsType = ReturnType<typeof useChatSessions>;
@@ -1,5 +1,3 @@
import { useChatSession } from './useChatSession';
export type SlashCommandResult = { handled: true; feedback: string } | { handled: false };
type UseSlashCommandsParams = {
@@ -7,8 +5,6 @@ type UseSlashCommandsParams = {
};
export function useSlashCommands({ sessionId }: UseSlashCommandsParams) {
const { rename } = useChatSession({ sessionId });
const execute = async (input: string): Promise<SlashCommandResult> => {
const trimmed = input.trim();
if (!trimmed.startsWith('/')) return { handled: false };
@@ -16,32 +12,15 @@ export function useSlashCommands({ sessionId }: UseSlashCommandsParams) {
const spaceIndex = trimmed.indexOf(' ');
const commandName = spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex);
const args = spaceIndex === -1 ? '' : trimmed.slice(spaceIndex + 1).trim();
try {
switch (commandName) {
case 'rename': {
if (!args.trim()) {
return { handled: true, feedback: 'Usage: /rename <new name>' };
}
await rename(args);
return { handled: true, feedback: `Session renamed to "${args}"` };
}
case 'help': {
const helpText = [
'Available commands:',
' /rename <name> - Rename the current session',
' /help - Show this help message',
].join('\n');
return { handled: true, feedback: helpText };
}
default:
return { handled: false };
switch (commandName) {
case 'help': {
const helpText = ['Available commands:', ' /help - Show this help message'].join('\n');
return { handled: true, feedback: helpText };
}
} catch (error) {
return { handled: true, feedback: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` };
default:
return { handled: false };
}
};
@@ -1,18 +1,24 @@
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { Trash2, Home, Monitor } from 'lucide-react';
import { useState, useCallback, useRef } from 'react';
import { useLocation } from 'react-router';
import { Save, Loader2, Check } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useAuth } from 'hooks/useAuth';
import { useChatSessions } from 'state/useChatSessions';
import { getHostHome } from 'state/useModels';
import { useSavedSessions } from 'state/useSavedSessions';
import { usePiChat, EmbeddableChat } from '../Chat';
import type { ChatMessage } from '../Chat/types';
export type SelectedSession = {
id: string;
model?: string | null;
resumeSummary?: string;
initialMessages?: ChatMessage[];
} | null;
export type AutoSaveMapping = { sessionId: string; savedId: number } | null;
const CHANNEL = 'chat:selected-session';
const AUTO_SAVE_CHANNEL = 'chat:auto-save';
type ChatLocationState = {
initialMessage?: string;
@@ -27,21 +33,36 @@ type DetailBarProps = {
sessionTitle: string | undefined;
isConnected: boolean;
isGenerating: boolean;
onDelete: (() => void) | undefined;
sessionId: string | null;
isSaved: boolean;
onSave: () => void;
isSaving: boolean;
};
function DetailBar({ sessionTitle, isConnected, isGenerating, onDelete }: DetailBarProps) {
function DetailBar({ sessionTitle, isConnected, isGenerating, sessionId, isSaved, onSave, isSaving }: DetailBarProps) {
return (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<div className="flex items-center gap-1">
{onDelete && (
<button
onClick={onDelete}
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
)}
{sessionId &&
(isSaving ? (
<span className="flex items-center gap-1.5 px-1 text-xs text-duck-teal">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Saving
</span>
) : isSaved ? (
<span className="flex items-center gap-1.5 px-1 text-xs text-duck-teal/60">
<Check className="h-3.5 w-3.5" />
Saved
</span>
) : (
<button
onClick={onSave}
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal transition-colors cursor-pointer"
title="Save session"
>
<Save className="h-4 w-4" />
</button>
))}
</div>
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 dark:text-foreground/70 truncate px-3">
{sessionTitle ?? 'New chat'}
@@ -60,78 +81,44 @@ function DetailBar({ sessionTitle, isConnected, isGenerating, onDelete }: Detail
);
}
type SessionChatProps = {
sessionId: string;
model?: string | null;
type NewChatProps = {
resumeSummary?: string;
initialMessages?: ChatMessage[];
};
function SessionChat({ sessionId, model }: SessionChatProps) {
const navigate = useNavigate();
const { sessions, deleteSession } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
const chat = usePiChat(sessionId, model, { replaceUrl: false });
return (
<div className="flex flex-col h-full">
<DetailBar
sessionTitle={sessionTitle}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDelete={async () => {
await deleteSession(sessionId);
setSelected(null);
navigate('/chat', { replace: true });
}}
/>
<EmbeddableChat chat={chat} sessionId={sessionId} initialModel={model ?? undefined} className="flex-1 min-h-0" />
</div>
);
}
function CwdToggle({ cwdMode, onChange }: { cwdMode: 'user' | 'host'; onChange: (mode: 'user' | 'host') => void }) {
const hostHome = getHostHome();
if (!hostHome) return null;
return (
<div className="shrink-0 flex items-center gap-1 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/40">
<span className="text-xs text-duck-dark/50 dark:text-foreground/50 mr-1">cwd</span>
<button
onClick={() => onChange('user')}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors cursor-pointer ${
cwdMode === 'user'
? 'bg-duck-teal/15 text-duck-teal font-medium'
: 'text-duck-dark/40 dark:text-foreground/40 hover:text-duck-dark/60 dark:hover:text-foreground/60'
}`}
>
<Home className="h-3 w-3" />
User Home
</button>
<button
onClick={() => onChange('host')}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors cursor-pointer ${
cwdMode === 'host'
? 'bg-duck-teal/15 text-duck-teal font-medium'
: 'text-duck-dark/40 dark:text-foreground/40 hover:text-duck-dark/60 dark:hover:text-foreground/60'
}`}
>
<Monitor className="h-3 w-3" />
Host Home
</button>
</div>
);
}
function NewChat({ allowHostMode }: { allowHostMode?: boolean }) {
function NewChat({ resumeSummary, initialMessages }: NewChatProps) {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const { user } = useAuth();
const isSuperAdmin = user?.role === 'Super Admin';
const { saveSession, updateSessionMessages } = useSavedSessions();
const [isSaving, setIsSaving] = useState(false);
const [autoSave, setAutoSave] = usePanelChannel<AutoSaveMapping>(AUTO_SAVE_CHANNEL, null);
const autoSaveRef = useRef(autoSave);
autoSaveRef.current = autoSave;
const chat = usePiChat(undefined, locationState?.model);
const onTurnComplete = useCallback(() => {
const mapping = autoSaveRef.current;
if (mapping) {
updateSessionMessages(mapping.savedId, mapping.sessionId).catch(() => {});
}
}, [updateSessionMessages]);
const chat = usePiChat(undefined, locationState?.model, { resumeSummary, initialMessages, onTurnComplete });
const isSaved = !!autoSave && autoSave.sessionId === chat.sessionId;
const handleSave = async () => {
if (!chat.sessionId || isSaving) return;
setIsSaving(true);
try {
const result = await saveSession(chat.sessionId);
setAutoSave({ sessionId: chat.sessionId, savedId: result.id });
} finally {
setIsSaving(false);
}
};
// Super Admin always operates as host — no toggle needed
const sandboxed = !isSuperAdmin;
const cwd = isSuperAdmin ? { path: getHostHome() } : locationState?.cwd;
@@ -150,7 +137,10 @@ function NewChat({ allowHostMode }: { allowHostMode?: boolean }) {
sessionTitle={undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDelete={undefined}
sessionId={chat.sessionId}
isSaved={isSaved}
onSave={handleSave}
isSaving={isSaving}
/>
<EmbeddableChat
chat={chat}
@@ -166,11 +156,7 @@ function NewChat({ allowHostMode }: { allowHostMode?: boolean }) {
);
}
type ChatDetailPanelProps = {
allowHostMode?: boolean;
};
export const ChatDetailPanel = ({ allowHostMode }: ChatDetailPanelProps) => {
export const ChatDetailPanel = () => {
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
if (!selected) {
@@ -181,9 +167,7 @@ export const ChatDetailPanel = ({ allowHostMode }: ChatDetailPanelProps) => {
);
}
if (selected.id.startsWith('new')) {
return <NewChat key={selected.id} allowHostMode={allowHostMode} />;
}
return <SessionChat key={selected.id} sessionId={selected.id} model={selected.model} />;
return (
<NewChat key={selected.id} resumeSummary={selected.resumeSummary} initialMessages={selected.initialMessages} />
);
};
@@ -1,143 +0,0 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { useChatGroups } from 'state/useChatGroups';
type CreateGroupDialogProps = {
onClose: () => void;
onCreateWithSessions?: (name: string, slug: string, description?: string) => Promise<void>;
};
function toSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
export function CreateGroupDialog({ onClose, onCreateWithSessions }: CreateGroupDialogProps) {
const { createGroup } = useChatGroups();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) {
setError('Name is required');
return;
}
const slug = toSlug(name);
if (!slug) {
setError('Name must contain at least one alphanumeric character');
return;
}
setIsSubmitting(true);
setError(null);
try {
if (onCreateWithSessions) {
await onCreateWithSessions(name.trim(), slug, description.trim() || undefined);
} else {
await createGroup(name.trim(), slug, description.trim() || undefined);
}
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create group');
setIsSubmitting(false);
}
}
function handleBackdropClick(e: React.MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={handleBackdropClick}
>
<div className="bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-lg shadow-xl w-full max-w-md mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-duck-dark/10 dark:border-foreground/10">
<h2 className="text-lg font-semibold text-duck-dark dark:text-foreground">Create Group</h2>
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label htmlFor="group-name" className="block text-sm font-medium text-duck-dark/70 dark:text-foreground/70 mb-1.5">
Name <span className="text-red-500">*</span>
</label>
<input
id="group-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Work Projects"
className="w-full px-3 py-2 rounded-md border border-duck-dark/20 dark:border-foreground/20 bg-background text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 dark:placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-duck-teal/50"
autoFocus
disabled={isSubmitting}
/>
{name && (
<p className="mt-1 text-xs text-duck-dark/40 dark:text-foreground/40">
Slug: <span className="font-mono">{toSlug(name) || '(invalid)'}</span>
</p>
)}
</div>
<div>
<label htmlFor="group-description" className="block text-sm font-medium text-duck-dark/70 dark:text-foreground/70 mb-1.5">
Description <span className="text-duck-dark/40 dark:text-foreground/40">(optional)</span>
</label>
<textarea
id="group-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description for this group..."
className="w-full px-3 py-2 rounded-md border border-duck-dark/20 dark:border-foreground/20 bg-background text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 dark:placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-duck-teal/50 resize-none"
rows={3}
disabled={isSubmitting}
/>
</div>
{error && (
<div className="p-3 rounded-md bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 text-sm">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 rounded-md transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim()}
className="px-4 py-2 text-sm font-medium bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Creating...' : 'Create Group'}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -1,122 +0,0 @@
import { useState } from 'react';
import { MoreVertical, Edit2, Trash2 } from 'lucide-react';
import { useChatGroups } from 'state/useChatGroups';
import type { GroupEntry } from '../Chat';
type GroupContextMenuProps = {
group: GroupEntry;
};
export function GroupContextMenu({ group }: GroupContextMenuProps) {
const { updateGroup, deleteGroup } = useChatGroups();
const [isOpen, setIsOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(group.name);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
async function handleRename() {
if (editName.trim() && editName !== group.name) {
await updateGroup(group.slug, { name: editName.trim() });
}
setIsEditing(false);
setIsOpen(false);
}
async function handleDelete() {
await deleteGroup(group.slug);
setShowDeleteConfirm(false);
setIsOpen(false);
}
function handleButtonClick(e: React.MouseEvent) {
e.stopPropagation();
setIsOpen(!isOpen);
}
function handleBackdropClick() {
setIsOpen(false);
setShowDeleteConfirm(false);
}
if (isEditing) {
return (
<div className="flex items-center gap-2 ml-auto" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename();
if (e.key === 'Escape') {
setEditName(group.name);
setIsEditing(false);
}
}}
className="px-2 py-1 text-sm border border-duck-dark/20 dark:border-foreground/20 rounded bg-background focus:outline-none focus:ring-2 focus:ring-duck-teal/50"
autoFocus
onBlur={handleRename}
/>
</div>
);
}
return (
<div className="relative ml-auto" onClick={(e) => e.stopPropagation()}>
<button
onClick={handleButtonClick}
className="p-1 rounded-md hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors"
>
<MoreVertical className="h-4 w-4" />
</button>
{isOpen && (
<>
<div className="fixed inset-0 z-40" onClick={handleBackdropClick} />
<div className="absolute right-0 top-full mt-1 z-50 min-w-[160px] bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-md shadow-lg overflow-hidden">
{!showDeleteConfirm ? (
<>
<button
onClick={() => {
setIsEditing(true);
setIsOpen(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
<Edit2 className="h-4 w-4" />
Rename
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-red-500/10 text-red-600 dark:text-red-400 transition-colors text-left"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</>
) : (
<div className="p-3 space-y-2">
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
Delete "{group.name}"? Sessions will be ungrouped.
</p>
<div className="flex gap-2">
<button
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 px-3 py-1.5 text-xs rounded-md bg-duck-dark/10 dark:bg-foreground/10 hover:bg-duck-dark/20 dark:hover:bg-foreground/20 transition-colors"
>
Cancel
</button>
<button
onClick={handleDelete}
className="flex-1 px-3 py-1.5 text-xs rounded-md bg-red-500 hover:bg-red-600 text-white transition-colors"
>
Delete
</button>
</div>
</div>
)}
</div>
</>
)}
</div>
);
}
@@ -1,68 +1,19 @@
import { useState } from 'react';
import { MoreVertical, FolderInput, Edit2, Trash2 } from 'lucide-react';
import { useChatGroups } from 'state/useChatGroups';
import { useChatSessions } from 'state/useChatSessions';
import type { SessionEntry } from '../Chat';
import { MoreVertical, Trash2 } from 'lucide-react';
type SessionContextMenuProps = {
session: SessionEntry;
onDelete: (id: string) => void;
sessionId: number;
onDelete: (id: number) => void;
};
export function SessionContextMenu({ session, onDelete }: SessionContextMenuProps) {
const { groups, moveSession } = useChatGroups();
const { renameSession } = useChatSessions();
export function SessionContextMenu({ sessionId, onDelete }: SessionContextMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [showMoveMenu, setShowMoveMenu] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [editTitle, setEditTitle] = useState(session.title);
async function handleMove(groupSlug: string | null) {
await moveSession(session.id, groupSlug);
setIsOpen(false);
setShowMoveMenu(false);
}
async function handleRename() {
if (editTitle.trim() && editTitle !== session.title) {
await renameSession(session.id, editTitle.trim());
}
setIsRenaming(false);
setIsOpen(false);
}
function handleButtonClick(e: React.MouseEvent) {
e.stopPropagation();
setIsOpen(!isOpen);
}
function handleBackdropClick() {
setIsOpen(false);
setShowMoveMenu(false);
}
if (isRenaming) {
return (
<div className="flex items-center gap-2 ml-2" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename();
if (e.key === 'Escape') {
setEditTitle(session.title);
setIsRenaming(false);
}
}}
className="px-2 py-1 text-sm border border-duck-dark/20 dark:border-foreground/20 rounded bg-background focus:outline-none focus:ring-2 focus:ring-duck-teal/50"
autoFocus
onBlur={handleRename}
/>
</div>
);
}
return (
<div className="relative" onClick={(e) => e.stopPropagation()}>
<button
@@ -74,65 +25,18 @@ export function SessionContextMenu({ session, onDelete }: SessionContextMenuProp
{isOpen && (
<>
<div className="fixed inset-0 z-40" onClick={handleBackdropClick} />
<div className="absolute right-0 top-full mt-1 z-50 min-w-[160px] bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-md shadow-lg overflow-hidden">
{!showMoveMenu ? (
<>
<button
onClick={() => setShowMoveMenu(true)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
<FolderInput className="h-4 w-4" />
Move to group
</button>
<button
onClick={() => {
setIsRenaming(true);
setIsOpen(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
<Edit2 className="h-4 w-4" />
Rename
</button>
<button
onClick={() => onDelete(session.id)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-red-500/10 text-red-600 dark:text-red-400 transition-colors text-left"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</>
) : (
<>
<div className="px-3 py-2 text-xs font-medium text-duck-dark/50 dark:text-foreground/50 border-b border-duck-dark/10 dark:border-foreground/10">
Move to:
</div>
<button
onClick={() => handleMove(null)}
className="w-full px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
Ungrouped
</button>
{groups.map((group) => (
<button
key={group.slug}
onClick={() => handleMove(group.slug)}
className="w-full px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
{group.name}
</button>
))}
<div className="border-t border-duck-dark/10 dark:border-foreground/10">
<button
onClick={() => setShowMoveMenu(false)}
className="w-full px-3 py-2 text-xs text-duck-dark/50 dark:text-foreground/50 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
Back
</button>
</div>
</>
)}
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-50 min-w-[120px] bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-md shadow-lg overflow-hidden">
<button
onClick={() => {
onDelete(sessionId);
setIsOpen(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-red-500/10 text-red-600 dark:text-red-400 transition-colors text-left"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</div>
</>
)}
@@ -1,445 +1,137 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useState, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { Plus, MessageSquare, Folder, ChevronRight, FolderPlus, CheckSquare, Trash2, FolderInput } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, MessageSquare, Loader2 } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from 'state/useChatSessions';
import { useChatGroups } from 'state/useChatGroups';
import { getProviderDisplayName } from 'state/useModels';
import { useWorkspace } from '../../components/Workspace';
import { useSavedSessions, messagesToTranscript, type RawMessage } from 'state/useSavedSessions';
import type { SelectedSession } from './ChatDetailPanel';
import { CreateGroupDialog } from './CreateGroupDialog';
import { GroupContextMenu } from './GroupContextMenu';
import type { ChatMessage } from '../Chat/types';
import { SessionContextMenu } from './SessionContextMenu';
export const SessionList = () => {
const navigate = useNavigate();
const { dashboardId } = useWorkspace();
// Only scope sessions for actual project/workspace contexts, not screen layout IDs like 'screens/chat'
const contextFilter = dashboardId?.startsWith('proj-layout-')
? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') }
: dashboardId && !dashboardId.startsWith('screens/')
? { context: 'dashboard' as const, contextId: dashboardId }
: undefined;
const { sessions, deleteSession, deleteSessions } = useChatSessions(contextFilter);
const { groups, moveSession, createGroup } = useChatGroups();
const { sessions, deleteSavedSession, resumeSession } = useSavedSessions();
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [showCreateGroup, setShowCreateGroup] = useState(false);
// Selection mode state
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showMoveMenu, setShowMoveMenu] = useState(false);
const [showCreateGroupForMove, setShowCreateGroupForMove] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [isResuming, setIsResuming] = useState<number | null>(null);
const scrolledRef = useRef(false);
const selectedRef = useCallback(
(node: HTMLDivElement | null) => {
if (node && !scrolledRef.current) {
scrolledRef.current = true;
node.scrollIntoView({ block: 'center' });
}
},
[],
);
const selectedRef = useCallback((node: HTMLDivElement | null) => {
if (node && !scrolledRef.current) {
scrolledRef.current = true;
node.scrollIntoView({ block: 'center' });
}
}, []);
useEffect(() => {
scrolledRef.current = false;
}, [selected?.id]);
// Exit selection mode on Escape
useEffect(() => {
if (!selectionMode) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setSelectionMode(false);
setSelectedIds(new Set());
setShowMoveMenu(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectionMode]);
const exitSelectionMode = () => {
setSelectionMode(false);
setSelectedIds(new Set());
setShowMoveMenu(false);
setShowDeleteConfirm(false);
};
const toggleSelection = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleGroup = (slug: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
next.has(slug) ? next.delete(slug) : next.add(slug);
return next;
});
};
const toggleGroupSelection = (groupSessions: typeof sessions) => {
const ids = groupSessions.map((s) => s.id);
const allSelected = ids.every((id) => selectedIds.has(id));
setSelectedIds((prev) => {
const next = new Set(prev);
if (allSelected) {
ids.forEach((id) => next.delete(id));
} else {
ids.forEach((id) => next.add(id));
}
return next;
});
};
const selectAll = () => {
setSelectedIds(new Set(sessions.map((s) => s.id)));
};
const deselectAll = () => {
setSelectedIds(new Set());
};
const handleBulkDelete = async () => {
setIsBulkDeleting(true);
const handleSelect = async (session: (typeof sessions)[number]) => {
setIsResuming(session.id);
try {
const ids = [...selectedIds];
// Clear selected panel if deleting the active session
if (selected && selectedIds.has(selected.id)) {
setSelected(null);
navigate('/chat', { replace: true });
}
await deleteSessions(ids);
exitSelectionMode();
const result = await resumeSession(session.id);
const rawMessages = result.rawMessages ?? [];
const chatMessages: ChatMessage[] = rawMessages.map((m: RawMessage) => {
if (m.role === 'user') return { role: 'user' as const, text: m.text || '' };
if (m.role === 'assistant') return { role: 'assistant' as const, id: m.id, text: m.text || '' };
if (m.role === 'tool') {
return {
role: 'tool' as const,
toolName: m.toolName || '',
toolInput: m.toolInput || {},
toolCallId: m.toolCallId || '',
output: m.output,
isError: m.isError,
};
}
return { role: 'assistant' as const, text: '' };
});
const transcript = messagesToTranscript(rawMessages);
setSelected({
id: `resume:${session.id}`,
model: result.model,
resumeSummary: transcript,
initialMessages: chatMessages,
});
navigate(`/chat/new`, { replace: true });
} catch {
// Failed to resume
} finally {
setIsBulkDeleting(false);
setIsResuming(null);
}
};
const handleBulkMove = async (groupSlug: string | null) => {
const ids = [...selectedIds];
await Promise.all(ids.map((id) => moveSession(id, groupSlug)));
setShowMoveMenu(false);
exitSelectionMode();
};
const handleCreateGroupAndMove = async (name: string, slug: string, description?: string) => {
const ids = [...selectedIds];
await createGroup(name, slug, description, ids);
setShowCreateGroupForMove(false);
exitSelectionMode();
};
const ungrouped = sessions.filter((s) => !s.groupSlug);
const grouped = groups.map((g) => ({
...g,
sessions: sessions.filter((s) => s.groupSlug === g.slug),
}));
const handleSelect = (session: (typeof sessions)[number]) => {
setSelected({ id: session.id, model: session.model ?? null });
navigate(`/chat/${session.id}`, { replace: true });
};
const handleDelete = async (id: string) => {
if (selected?.id === id) {
const handleDelete = async (id: number) => {
if (selected?.id === `resume:${id}`) {
setSelected(null);
navigate('/chat', { replace: true });
}
await deleteSession(id);
};
const renderSession = (session: (typeof sessions)[number]) => {
const isActive = selected?.id === session.id;
const isChecked = selectedIds.has(session.id);
return (
<div
key={session.id}
ref={isActive ? selectedRef : undefined}
className={`group flex items-center rounded-lg border transition-colors cursor-pointer ${
selectionMode && isChecked
? 'border-duck-teal/40 bg-duck-teal/10 dark:bg-duck-teal/15'
: isActive
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
}`}
>
{selectionMode && (
<div className="shrink-0 pl-3" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleSelection(session.id)}
className="border-duck-dark/30 dark:border-foreground/30 data-[state=checked]:bg-duck-teal data-[state=checked]:border-duck-teal"
/>
</div>
)}
<button
onClick={() => (selectionMode ? toggleSelection(session.id) : handleSelect(session))}
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
>
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
{session.title}
<span className="ml-1.5 font-mono text-xs font-normal text-duck-dark/25 dark:text-foreground/25">
({session.id.slice(0, 8)})
</span>
</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
{session.model && (
<div className="text-xs text-duck-teal/70 dark:text-duck-teal/60 truncate">
{(() => {
if (!session.model.includes('/')) return session.model;
const [provider, modelId] = session.model.split('/') as [string, string];
// Handle local providers - show friendly name
if (provider.startsWith('officer-local-')) {
const friendlyName = getProviderDisplayName(provider);
return `${friendlyName} - ${modelId}`;
}
// Regular providers - just replace / with -
return session.model.replace('/', ' - ');
})()}
</div>
)}
</div>
</button>
{!selectionMode && (
<div className="shrink-0 mr-2">
<SessionContextMenu session={session} onDelete={handleDelete} />
</div>
)}
</div>
);
await deleteSavedSession(id);
};
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
<div className="flex gap-2">
{selectionMode ? (
<>
<button
onClick={selectedIds.size === sessions.length ? deselectAll : selectAll}
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-7 px-3 text-xs font-medium"
>
{selectedIds.size === sessions.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={exitSelectionMode}
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-7 px-3 text-xs font-medium"
>
Cancel
</button>
</>
) : (
<>
{sessions.length > 0 && (
<button
onClick={() => setSelectionMode(true)}
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-7 px-3 text-xs font-medium"
>
<CheckSquare className="h-3.5 w-3.5" />
Select
</button>
)}
<button
onClick={() => setShowCreateGroup(true)}
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-7 px-3 text-xs font-medium"
>
<FolderPlus className="h-3.5 w-3.5" />
New Group
</button>
<button
onClick={() => {
setSelected({ id: `new:${Date.now()}` });
navigate('/chat/new', { replace: true });
}}
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
>
<Plus className="h-3.5 w-3.5" />
New Chat
</button>
</>
)}
</div>
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Saved Sessions</h2>
<button
onClick={() => {
setSelected({ id: `new:${Date.now()}` });
navigate('/chat/new', { replace: true });
}}
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
>
<Plus className="h-3.5 w-3.5" />
New Chat
</button>
</div>
{/* Session list */}
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3">
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-1.5">
{sessions.length === 0 && (
<div className="text-center py-16 text-duck-dark/30 dark:text-foreground/30 text-sm">
No sessions yet. Start a new chat!
No saved sessions yet. Save a chat session to see it here.
</div>
)}
{/* Ungrouped sessions */}
{ungrouped.length > 0 && (
<div className="space-y-1.5">
{selectionMode && ungrouped.length > 1 && (
{sessions.map((session) => {
const isActive = selected?.id === `resume:${session.id}`;
return (
<div
key={session.id}
ref={isActive ? selectedRef : undefined}
className={`group flex items-center rounded-lg border transition-colors cursor-pointer ${
isActive
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
}`}
>
<button
onClick={() => toggleGroupSelection(ungrouped)}
className="text-xs text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark/70 dark:hover:text-foreground/70 px-3 py-1"
onClick={() => handleSelect(session)}
disabled={isResuming === session.id}
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
>
{ungrouped.every((s) => selectedIds.has(s.id)) ? 'Deselect ungrouped' : 'Select ungrouped'}
</button>
)}
{ungrouped.map(renderSession)}
</div>
)}
{/* Grouped sessions */}
{grouped.map((group) => (
<div key={group.slug} className="space-y-1.5">
<div className="flex items-center gap-2 px-3 py-2 cursor-pointer rounded-md hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors">
{selectionMode && group.sessions.length > 0 && (
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={group.sessions.length > 0 && group.sessions.every((s) => selectedIds.has(s.id))}
onCheckedChange={() => toggleGroupSelection(group.sessions)}
className="border-duck-dark/30 dark:border-foreground/30 data-[state=checked]:bg-duck-teal data-[state=checked]:border-duck-teal"
/>
{isResuming === session.id ? (
<Loader2 className="h-4 w-4 shrink-0 text-duck-teal/60 animate-spin" />
) : (
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
{session.title}
</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
)}
<div onClick={() => toggleGroup(group.slug)} className="flex items-center gap-2 flex-1 min-w-0">
<ChevronRight
className={`h-4 w-4 transition-transform ${!collapsed.has(group.slug) ? 'rotate-90' : ''}`}
/>
<Folder className="h-4 w-4 text-duck-teal" />
<span className="font-medium text-sm text-duck-dark/70 dark:text-foreground/70">{group.name}</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">({group.sessionCount})</span>
</button>
<div className="shrink-0 mr-2">
<SessionContextMenu sessionId={session.id} onDelete={handleDelete} />
</div>
{!selectionMode && <GroupContextMenu group={group} />}
</div>
{!collapsed.has(group.slug) && (
<div className="ml-6 space-y-1.5">
{group.sessions.map(renderSession)}
</div>
)}
</div>
))}
);
})}
</div>
{/* Floating action bar */}
{selectionMode && selectedIds.size > 0 && (
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-t border-duck-dark/10 dark:border-foreground/10 bg-background/95 backdrop-blur-sm">
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">
{selectedIds.size} selected
</span>
<div className="flex gap-2 relative">
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-1.5 rounded-md bg-red-500/10 hover:bg-red-500/20 text-red-600 dark:text-red-400 cursor-pointer h-8 px-3 text-xs font-medium"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
<button
onClick={() => setShowMoveMenu(!showMoveMenu)}
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-8 px-3 text-xs font-medium"
>
<FolderInput className="h-3.5 w-3.5" />
Move to Group
</button>
{showMoveMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowMoveMenu(false)} />
<div className="absolute right-0 bottom-full mb-1 z-50 min-w-[180px] bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-md shadow-lg overflow-hidden">
<div className="px-3 py-2 text-xs font-medium text-duck-dark/50 dark:text-foreground/50 border-b border-duck-dark/10 dark:border-foreground/10">
Move to:
</div>
<button
onClick={() => handleBulkMove(null)}
className="w-full px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
Ungrouped
</button>
{groups.map((group) => (
<button
key={group.slug}
onClick={() => handleBulkMove(group.slug)}
className="w-full px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
{group.name}
</button>
))}
<div className="border-t border-duck-dark/10 dark:border-foreground/10">
<button
onClick={() => {
setShowMoveMenu(false);
setShowCreateGroupForMove(true);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left text-duck-teal"
>
<FolderPlus className="h-3.5 w-3.5" />
New Group
</button>
</div>
</div>
</>
)}
</div>
</div>
)}
{/* Delete confirmation dialog */}
{showDeleteConfirm && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget) setShowDeleteConfirm(false);
}}
>
<div className="bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-lg shadow-xl w-full max-w-sm mx-4 p-6">
<h3 className="text-lg font-semibold text-duck-dark dark:text-foreground mb-2">Delete Sessions</h3>
<p className="text-sm text-duck-dark/60 dark:text-foreground/60 mb-6">
Are you sure you want to delete {selectedIds.size} session{selectedIds.size > 1 ? 's' : ''}? This action
cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={isBulkDeleting}
className="px-4 py-2 text-sm font-medium text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 rounded-md transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleBulkDelete}
disabled={isBulkDeleting}
className="px-4 py-2 text-sm font-medium bg-red-500 hover:bg-red-600 text-white rounded-md transition-colors disabled:opacity-50"
>
{isBulkDeleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
{showCreateGroup && <CreateGroupDialog onClose={() => setShowCreateGroup(false)} />}
{showCreateGroupForMove && (
<CreateGroupDialog onClose={() => setShowCreateGroupForMove(false)} onCreateWithSessions={handleCreateGroupAndMove} />
)}
</div>
);
};
@@ -20,7 +20,7 @@ export const appRegistryMetas: AppRegistryMeta[] = [
key: 'chat-detail',
name: 'Chat',
icon: MessageSquare,
component: () => ChatDetailPanel({ allowHostMode: true }),
component: ChatDetailPanel,
availableOnPanel: false,
},
];
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useChatSessions } from 'state/useChatSessions';
import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types';
@@ -19,6 +19,8 @@ type UsePiChatOptions = {
projectScoped?: boolean;
context?: string;
contextId?: string;
resumeSummary?: string;
initialMessages?: ChatMessage[];
onTurnComplete?: (hadToolCalls: boolean) => void;
};
@@ -31,9 +33,11 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
projectScoped,
context,
contextId,
resumeSummary: initialResumeSummary,
initialMessages: preloadedMessages,
onTurnComplete,
} = options ?? {};
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>(preloadedMessages ?? []);
const [streamingText, setStreamingText] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
@@ -43,7 +47,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
const [thinkingLevel, setThinkingLevel] = useState<string | null>(null);
// Track if session has started (first message sent)
const [hasStarted, setHasStarted] = useState(false);
const [hasStarted, setHasStarted] = useState(!!preloadedMessages?.length);
// Get user settings for default model
const { settings } = useSettings();
@@ -68,9 +72,9 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
const toolCallsInTurnRef = useRef(false);
const onTurnCompleteRef = useRef(onTurnComplete);
onTurnCompleteRef.current = onTurnComplete;
const resumeSummaryRef = useRef<string | undefined>(initialResumeSummary);
const sessionFilter = context ? { context, contextId } : undefined;
const { getSession, saveMessages, invalidate: invalidateSessions } = useChatSessions(sessionFilter);
const client = useClient();
const token = localStorage.getItem('BEARER_TOKEN');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -150,7 +154,6 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
},
]);
setIsGenerating(false);
invalidateSessions();
const hadTools = toolCallsInTurnRef.current;
toolCallsInTurnRef.current = false;
onTurnCompleteRef.current?.(hadTools);
@@ -221,7 +224,8 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
return;
}
if (!initialSessionId) return;
getSession(initialSessionId)
client
.get<{ session: { messages: Message[] } }>(`/pi/sessions/${initialSessionId}`)
.then((data) => {
if (data.session?.messages && data.session.messages.length > 0) {
// Convert backend Message[] to ChatMessage[]
@@ -261,7 +265,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
if (storage) {
storage.save(sid, snapshot).catch(() => {});
} else {
saveMessages(sid, snapshot).catch(() => {});
client.put(`/pi/sessions/${sid}/messages`, snapshot).catch(() => {});
}
saveTimerRef.current = null;
}, SAVE_DEBOUNCE_MS);
@@ -313,6 +317,9 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
})
.filter((x): x is { mediaType: string; data: string } => x !== null);
const pendingResumeSummary = resumeSummaryRef.current;
resumeSummaryRef.current = undefined;
send({
type: 'chat',
prompt: text,
@@ -330,6 +337,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
...(thinking ? { thinking } : {}),
...(context ? { context } : {}),
...(contextId ? { contextId } : {}),
...(pendingResumeSummary ? { resumeSummary: pendingResumeSummary } : {}),
});
}
+96 -8
View File
@@ -3,26 +3,114 @@ export * from './AppRegistry';
export * from './WidgetRegistry';
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)
export { MessageList, MessageBubble, StreamingBubble, ToolActivity, QuestionActivity, ModelSelector, InputArea, ChatLauncher, AttachmentList, AttachButton, WebpageDialog, EmbeddableChat, usePiChat, ChatList, useSlashCommands, useChatSessions, useChatSession, useAttachments, useAudioRecording } from './apps/Chat';
export type { UseEmbeddableChatType, UsePiChatType, UseChatSessionsType, UseChatSessionType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat';
export {
MessageList,
MessageBubble,
StreamingBubble,
ToolActivity,
QuestionActivity,
ModelSelector,
InputArea,
ChatLauncher,
AttachmentList,
AttachButton,
WebpageDialog,
EmbeddableChat,
usePiChat,
ChatList,
useSlashCommands,
useAttachments,
useAudioRecording,
} from './apps/Chat';
export type { UseEmbeddableChatType, UsePiChatType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat';
export * from './apps/Chat/types';
export { SessionBar, SessionList, ChatDetailPanel } from './apps/ChatHistory';
export type { SelectedSession } from './apps/ChatHistory';
export { CodeEditorView } from './apps/CodeEditor';
export { useFilesAPI, useTasks, useRecentFiles, usePinnedFiles, FileBrowserApp, FileBrowserPanelWrapper, FileBrowserWidget, TaskRunnerModal } from './apps/FileBrowser';
export {
useFilesAPI,
useTasks,
useRecentFiles,
usePinnedFiles,
FileBrowserApp,
FileBrowserPanelWrapper,
FileBrowserWidget,
TaskRunnerModal,
} from './apps/FileBrowser';
export type { DirEntry, TaskSummary } from './apps/FileBrowser';
export { FileViewerView, FileViewerProvider, FileViewerHeader, FileViewerBody, FileViewerPanelProvider, useFileViewer, getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS } from './apps/FileViewer';
export {
FileViewerView,
FileViewerProvider,
FileViewerHeader,
FileViewerBody,
FileViewerPanelProvider,
useFileViewer,
getFileType,
getLang,
getExt,
getArchiveBaseName,
ARCHIVE_EXTS,
} from './apps/FileViewer';
export type { FileType } from './apps/FileViewer';
export { TerminalView } from './apps/Terminal';
export type { TerminalViewProps } from './apps/Terminal';
export { DesktopView } from './apps/Desktop';
export type { DesktopViewProps } from './apps/Desktop';
export { DashboardListApp, DashboardPreview, SELECTED_DASHBOARD_KEY, CREATING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY, NEW_DASH_NAME_KEY, NEW_DASH_DESC_KEY, NEW_DASH_TEMPLATE_KEY } from './apps/Dashboards';
export { ProjectListApp, ProjectPreview, SELECTED_PROJECT, CREATING_PROJECT, EDITING_PROJECT, NEW_PROJ_NAME, NEW_PROJ_DESC, NEW_PROJ_TEMPLATE, NEW_PROJ_TYPE, NEW_PROJ_HAS_BACKEND, NEW_PROJ_HAS_AUTH, NEW_PROJ_PREVIEW_LAYOUT } from './apps/Projects';
export {
DashboardListApp,
DashboardPreview,
SELECTED_DASHBOARD_KEY,
CREATING_DASHBOARD_KEY,
EDITING_DASHBOARD_KEY,
NEW_DASH_NAME_KEY,
NEW_DASH_DESC_KEY,
NEW_DASH_TEMPLATE_KEY,
} from './apps/Dashboards';
export {
ProjectListApp,
ProjectPreview,
SELECTED_PROJECT,
CREATING_PROJECT,
EDITING_PROJECT,
NEW_PROJ_NAME,
NEW_PROJ_DESC,
NEW_PROJ_TEMPLATE,
NEW_PROJ_TYPE,
NEW_PROJ_HAS_BACKEND,
NEW_PROJ_HAS_AUTH,
NEW_PROJ_PREVIEW_LAYOUT,
} from './apps/Projects';
export { createUserAppPanel, createUserAppHeader } from './apps/UserApp';
export { resolveIcon, availableIconNames } from './utils/resolve-icon';
// Workspace
export { WorkspaceView, WorkspaceLayout, WorkspaceProvider, useWorkspace } from './components/Workspace';
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './components/Workspace';
export type { LayoutNode, LayoutGroup, LayoutPanel, DashboardDefinition, DashboardState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels, DropPosition, HomeRoot, DefaultFileSort } from './components/Workspace';
export {
createDefaultLayout,
splitPanel,
removePanel,
setApp,
updateSizes,
swapPanels,
movePanel,
pruneEmptyPanels,
countPanels,
hasAnyApp,
} from './components/Workspace';
export type {
LayoutNode,
LayoutGroup,
LayoutPanel,
DashboardDefinition,
DashboardState,
ProjectType,
ProjectDefinition,
AppRegistry,
AppRegistryEntry,
PanelComponents,
PanelComponentEntry,
EphemeralPanels,
DropPosition,
HomeRoot,
DefaultFileSort,
} from './components/Workspace';
+2 -3
View File
@@ -9,9 +9,8 @@ export { useRecentModels } from './useRecentModels';
export { usePlans } from './usePlans';
export { useLandingPage } from './useLandingPage';
export { useServerSettings } from './useServerSettings';
export { useChatSessions } from './useChatSessions';
export type { UseChatSessionsType } from './useChatSessions';
export { useChatGroups } from './useChatGroups';
export { useSavedSessions } from './useSavedSessions';
export type { UseSavedSessionsType, SavedSessionEntry } from './useSavedSessions';
export { useUserApps } from './useUserApps';
export type { AppManifest } from './useUserApps';
export { useServerEnvironment } from './useServerEnvironment';
-53
View File
@@ -1,53 +0,0 @@
import type { GroupEntry } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
export function useChatGroups() {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const { data: groups = [] } = useQuery<GroupEntry[]>({
queryKey: ['PI_GROUPS'],
enabled: isAuthenticated,
queryFn: () => client.get<{ groups: GroupEntry[] }>('/pi/groups').then((r) => r.groups),
});
async function createGroup(name: string, slug: string, description?: string, sessionIds?: string[]) {
const result = await client.post<{ group: GroupEntry }>('/pi/groups', {
name,
slug,
description,
sessionIds,
});
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
return result.group;
}
async function updateGroup(slug: string, updates: { name?: string; description?: string }) {
await client.patch(`/pi/groups/${slug}`, updates);
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
}
async function deleteGroup(slug: string) {
await client.delete(`/pi/groups/${slug}`);
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
}
async function moveSession(sessionId: string, groupSlug: string | null) {
await client.post(`/pi/sessions/${sessionId}/move`, { groupSlug });
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
}
return {
groups,
createGroup,
updateGroup,
deleteGroup,
moveSession,
};
}
-108
View File
@@ -1,108 +0,0 @@
import type { SessionEntry, ChatMessage, Message } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
type ChatSessionsFilter = {
context?: string;
contextId?: string;
};
export function useChatSessions(filter?: ChatSessionsFilter) {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const { data: sessions = [] } = useQuery<SessionEntry[]>({
queryKey: ['PI_SESSIONS', filter?.context, filter?.contextId],
enabled: isAuthenticated,
queryFn: () =>
client
.post<{ sessions: SessionEntry[] }>('/pi/sessions', {
...(filter?.context ? { context: filter.context } : {}),
...(filter?.contextId ? { contextId: filter.contextId } : {}),
})
.then((r) => r.sessions),
});
function getSession(sessionId: string) {
return client.get<{ session: SessionWithMessages }>(`/pi/sessions/${sessionId}`);
}
function saveMessages(sessionId: string, messages: ChatMessage[]) {
return client.put(`/pi/sessions/${sessionId}/messages`, messages);
}
async function renameSession(sessionId: string, title: string) {
await client.patch(`/pi/sessions/${sessionId}`, { title });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS', filter?.context, filter?.contextId] });
}
async function deleteSession(sessionId: string) {
await client.delete(`/pi/sessions/${sessionId}`);
queryClient.setQueryData<SessionEntry[]>(
['PI_SESSIONS', filter?.context, filter?.contextId],
(prev) => prev?.filter((s) => s.id !== sessionId) ?? [],
);
}
async function deleteSessions(sessionIds: string[]) {
await client.delete('/pi/sessions', {
sessionIds,
...(filter?.context ? { context: filter.context } : {}),
...(filter?.contextId ? { contextId: filter.contextId } : {}),
});
const idSet = new Set(sessionIds);
queryClient.setQueryData<SessionEntry[]>(
['PI_SESSIONS', filter?.context, filter?.contextId],
(prev) => prev?.filter((s) => !idSet.has(s.id)) ?? [],
);
}
async function clearSessions() {
await client.delete('/pi/sessions', {
...(filter?.context ? { context: filter.context } : {}),
...(filter?.contextId ? { contextId: filter.contextId } : {}),
});
queryClient.setQueryData<SessionEntry[]>(['PI_SESSIONS', filter?.context, filter?.contextId], []);
}
function searchSessions(query: string) {
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?q=${encodeURIComponent(query)}`);
}
function invalidate() {
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS', filter?.context, filter?.contextId] });
}
return {
sessions,
getSession,
saveMessages,
renameSession,
deleteSession,
deleteSessions,
clearSessions,
searchSessions,
invalidate,
};
}
export type UseChatSessionsType = ReturnType<typeof useChatSessions>;
type SessionWithMessages = {
id: string;
title: string;
model: string;
cwd: string;
groupSlug?: string | null;
createdAt: number;
updatedAt: number;
messageCount: number;
cost: {
inputTokens: number;
outputTokens: number;
totalUSD: number;
};
messages: Message[];
};
@@ -0,0 +1,105 @@
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
export type SavedSessionEntry = {
id: number;
provider: string;
context: string | null;
contextId: string | null;
title: string;
summary: string;
cwd: string;
cost: { inputTokens: number; outputTokens: number; totalUSD: number };
createdAt: string;
};
type RawMessage = {
id: string;
timestamp: number;
role: 'user' | 'assistant' | 'tool';
text?: string;
model?: string;
cost?: { inputTokens: number; outputTokens: number; totalUSD: number };
toolCallId?: string;
toolName?: string;
toolInput?: Record<string, unknown>;
output?: string;
isError?: boolean;
};
export type ResumeResult = {
context: string | null;
cwd: string;
model: string;
cost: { inputTokens: number; outputTokens: number; totalUSD: number };
rawMessages: RawMessage[];
};
export type { RawMessage };
export function messagesToTranscript(messages: RawMessage[]): string {
const lines: string[] = [];
for (const msg of messages) {
if (msg.role === 'user' && msg.text) {
lines.push(`User: ${msg.text}`);
} else if (msg.role === 'assistant' && msg.text) {
lines.push(`Assistant: ${msg.text}`);
} else if (msg.role === 'tool') {
const outcome = msg.isError ? `Error: ${msg.output}` : msg.output ? 'Success' : 'Pending';
lines.push(`Tool [${msg.toolName}]: ${outcome}`);
}
}
return lines.join('\n\n');
}
export function useSavedSessions() {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const { data: sessions = [], isLoading } = useQuery<SavedSessionEntry[]>({
queryKey: ['SAVED_SESSIONS'],
enabled: isAuthenticated,
queryFn: () => client.get<SavedSessionEntry[]>('/saved-sessions'),
});
async function saveSession(sessionId: string) {
try {
const result = await client.post<SavedSessionEntry>('/saved-sessions', { sessionId });
queryClient.invalidateQueries({ queryKey: ['SAVED_SESSIONS'] });
toast.success('Session saved');
return result;
} catch (err) {
toast.error('Failed to save session');
throw err;
}
}
async function deleteSavedSession(id: number) {
await client.delete(`/saved-sessions/${id}`);
queryClient.setQueryData<SavedSessionEntry[]>(['SAVED_SESSIONS'], (prev) => prev?.filter((s) => s.id !== id) ?? []);
}
async function resumeSession(id: number) {
return client.post<ResumeResult>(`/saved-sessions/${id}/resume`);
}
async function updateSessionMessages(savedId: number, sessionId: string) {
await client.put(`/saved-sessions/${savedId}`, { sessionId });
}
return {
sessions,
isLoading,
saveSession,
deleteSavedSession,
resumeSession,
updateSessionMessages,
};
}
export type UseSavedSessionsType = ReturnType<typeof useSavedSessions>;