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:
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)],
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user