batch4: chats-in-sessions, force-send, /compact, right-rail file browser
Session 1:N Chat data model with backfill. Workspace switches to client-side multi-tab pane management. Right-rail file browser with float-over viewer and click-drag line selection replaces FileBrowserPane. Adds /compact streaming summarizer (respects compact markers in context builder), force-send (cancels in-flight, persists partial as 'cancelled', awaits cancellation completion via deferred Promise + 5s timeout), message queue, stop generation, chat auto-rename, session archive/unarchive with Closed Sessions section on repo landing page. CHECK constraints on sessions.status, messages.role, messages.status with KEEP IN SYNC comments tying to MESSAGE_ROLES / MESSAGE_STATUSES const arrays. Deletes dead pane routes/hook and the api.panes.* client block. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
// across hooks (e.g. AI rename arriving via WS in the session view needs to
|
||||
// also refresh the sidebar's session list).
|
||||
|
||||
import type { Project, Session } from '@/api/types';
|
||||
import type { Chat, Project, Session } from '@/api/types';
|
||||
import type { Attachment } from '@/lib/attachments';
|
||||
|
||||
export interface SessionRenamedEvent {
|
||||
type: 'session_renamed';
|
||||
@@ -51,6 +52,37 @@ export interface OpenFileInBrowserEvent {
|
||||
path: string; // project-relative
|
||||
}
|
||||
|
||||
export interface AttachChatFileEvent {
|
||||
type: 'attach_chat_file';
|
||||
attachment: Omit<Attachment, 'id'>;
|
||||
}
|
||||
|
||||
export interface SessionArchivedEvent {
|
||||
type: 'session_archived';
|
||||
session_id: string;
|
||||
project_id: string;
|
||||
}
|
||||
|
||||
export interface ChatCreatedEvent {
|
||||
type: 'chat_created';
|
||||
chat: Chat;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface ChatUpdatedEvent {
|
||||
type: 'chat_updated';
|
||||
chat_id: string;
|
||||
session_id: string;
|
||||
name: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ChatClosedEvent {
|
||||
type: 'chat_closed';
|
||||
chat_id: string;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export type SessionEvent =
|
||||
| SessionRenamedEvent
|
||||
| ProjectCreatedEvent
|
||||
@@ -59,7 +91,12 @@ export type SessionEvent =
|
||||
| SessionDeletedEvent
|
||||
| SessionUpdatedEvent
|
||||
| SessionLoadedEvent
|
||||
| OpenFileInBrowserEvent;
|
||||
| OpenFileInBrowserEvent
|
||||
| AttachChatFileEvent
|
||||
| SessionArchivedEvent
|
||||
| ChatCreatedEvent
|
||||
| ChatUpdatedEvent
|
||||
| ChatClosedEvent;
|
||||
type Listener = (event: SessionEvent) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Pane, PaneCreateRequest, PaneState, PaneUpdateRequest } from '@/api/types';
|
||||
|
||||
export function usePanes(sessionId: string | undefined): {
|
||||
panes: Pane[] | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
create: (body: PaneCreateRequest) => Promise<Pane>;
|
||||
update: (id: string, body: PaneUpdateRequest) => Promise<void>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
} {
|
||||
const [panes, setPanes] = useState<Pane[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Pending debounced state PATCHes: pane id -> latest PaneState
|
||||
const pendingState = useRef<Map<string, PaneState>>(new Map());
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!sessionId) {
|
||||
setPanes(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { panes: list } = await api.panes.getForSession(sessionId);
|
||||
setPanes(list);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'pane operation failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
const flushPendingState = useCallback(async () => {
|
||||
if (debounceTimer.current !== null) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = null;
|
||||
}
|
||||
const updates = Array.from(pendingState.current.entries());
|
||||
pendingState.current.clear();
|
||||
if (updates.length === 0) return;
|
||||
try {
|
||||
await Promise.all(updates.map(([id, state]) => api.panes.update(id, { state })));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'pane state PATCH failed');
|
||||
// server truth may diverge from optimistic local state; resync
|
||||
void refresh();
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
// Fetch on mount / sessionId change; preserve previous list while reloading
|
||||
// (loading=true but panes stays non-null after first fetch to avoid flash)
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Flush debounced PATCHes on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
flushPendingState();
|
||||
};
|
||||
}, [flushPendingState]);
|
||||
|
||||
const create = useCallback(
|
||||
async (body: PaneCreateRequest): Promise<Pane> => {
|
||||
if (!sessionId) throw new Error('no session');
|
||||
const created = await api.panes.create(sessionId, body);
|
||||
await refresh();
|
||||
return created;
|
||||
},
|
||||
[sessionId, refresh]
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string, body: PaneUpdateRequest): Promise<void> => {
|
||||
if (body.state !== undefined && body.position === undefined) {
|
||||
const nextState = body.state;
|
||||
// Optimistic local update
|
||||
setPanes((prev) => {
|
||||
if (!prev) return prev;
|
||||
let changed = false;
|
||||
const next = prev.map((pane) => {
|
||||
if (pane.id !== id) return pane;
|
||||
changed = true;
|
||||
// Narrow via discriminated union to satisfy TypeScript
|
||||
if (pane.kind === 'chat') {
|
||||
return { ...pane, state: nextState as typeof pane.state };
|
||||
}
|
||||
if (pane.kind === 'file_browser') {
|
||||
return { ...pane, state: nextState as typeof pane.state };
|
||||
}
|
||||
return pane;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
// Coalesce: last state wins within debounce window
|
||||
pendingState.current.set(id, nextState);
|
||||
|
||||
if (debounceTimer.current !== null) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
debounceTimer.current = null;
|
||||
flushPendingState();
|
||||
}, 300);
|
||||
} else {
|
||||
// position involved — fire immediately
|
||||
try {
|
||||
await api.panes.update(id, body);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'pane operation failed');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
[refresh, flushPendingState]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
// Optimistic remove — capture snapshot inside functional updater to avoid stale closure
|
||||
let snapshot: Pane[] | null = null;
|
||||
setPanes((prev) => {
|
||||
snapshot = prev;
|
||||
return prev ? prev.filter((p) => p.id !== id) : prev;
|
||||
});
|
||||
|
||||
try {
|
||||
await api.panes.remove(id);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
// Rollback to the truly-most-recent value captured above
|
||||
setPanes(snapshot);
|
||||
setError(err instanceof Error ? err.message : 'pane operation failed');
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
return { panes, loading, error, refresh, create, update, remove };
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project } from '@/api/types';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
|
||||
export function useProjects() {
|
||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||
@@ -33,7 +32,6 @@ export function useProjects() {
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
await api.projects.remove(id);
|
||||
sessionEvents.emit({ type: 'project_deleted', project_id: id });
|
||||
await refresh();
|
||||
},
|
||||
[refresh]
|
||||
|
||||
@@ -19,8 +19,10 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
const newMsg: Message = {
|
||||
id: frame.message_id,
|
||||
session_id: '',
|
||||
chat_id: frame.chat_id ?? '',
|
||||
role: frame.role,
|
||||
content: '',
|
||||
kind: 'message',
|
||||
tool_calls: null,
|
||||
tool_results: null,
|
||||
status: 'streaming',
|
||||
@@ -71,8 +73,10 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
const newMsg: Message = {
|
||||
id: frame.tool_message_id,
|
||||
session_id: '',
|
||||
chat_id: frame.chat_id ?? '',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
kind: 'message',
|
||||
tool_calls: null,
|
||||
tool_results: {
|
||||
tool_call_id: frame.tool_call_id,
|
||||
@@ -115,7 +119,6 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
};
|
||||
}
|
||||
case 'session_renamed': {
|
||||
// Side-effect, not state — dispatch via event bus to other hooks.
|
||||
sessionEvents.emit({
|
||||
type: 'session_renamed',
|
||||
session_id: frame.session_id,
|
||||
@@ -123,6 +126,16 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
});
|
||||
return state;
|
||||
}
|
||||
case 'chat_renamed': {
|
||||
sessionEvents.emit({
|
||||
type: 'chat_updated',
|
||||
chat_id: frame.chat_id,
|
||||
session_id: '',
|
||||
name: frame.name,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
return state;
|
||||
}
|
||||
case 'error': {
|
||||
const next = frame.message_id
|
||||
? state.messages.map((m) =>
|
||||
|
||||
@@ -140,26 +140,50 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
||||
case 'open_file_in_browser':
|
||||
// Consumed by Workspace (T7); no sidebar state change needed.
|
||||
return prev;
|
||||
case 'attach_chat_file':
|
||||
return prev;
|
||||
case 'session_archived': {
|
||||
let changed = false;
|
||||
const projects = prev.projects.map((p) => {
|
||||
if (p.id !== event.project_id) return p;
|
||||
const recent = p.recent_sessions.filter((s) => s.id !== event.session_id);
|
||||
if (recent.length === p.recent_sessions.length) return p;
|
||||
changed = true;
|
||||
return {
|
||||
...p,
|
||||
recent_sessions: recent,
|
||||
total_sessions: Math.max(0, p.total_sessions - 1),
|
||||
};
|
||||
});
|
||||
return changed ? { ...prev, projects } : prev;
|
||||
}
|
||||
case 'chat_created':
|
||||
case 'chat_updated':
|
||||
case 'chat_closed':
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
|
||||
// One bus subscription for the lifetime of the module. Events arriving
|
||||
// before the initial fetch resolves are dropped; the eventual fetch
|
||||
// result is the source of truth.
|
||||
sessionEvents.subscribe((event) => {
|
||||
// session_loaded updates activeSessionProjectId regardless of whether
|
||||
// sharedData is populated yet — notify so subscribers can re-read.
|
||||
if (event.type === 'session_loaded') {
|
||||
activeSession = { session_id: event.session_id, project_id: event.project_id };
|
||||
// Guard prevents duplicate listeners during Vite HMR reloads.
|
||||
const G = globalThis as Record<string, unknown>;
|
||||
if (!G.__boocode_sidebar_subscribed) {
|
||||
G.__boocode_sidebar_subscribed = true;
|
||||
sessionEvents.subscribe((event) => {
|
||||
if (event.type === 'session_loaded') {
|
||||
activeSession = { session_id: event.session_id, project_id: event.project_id };
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
if (!sharedData) return;
|
||||
const next = applyEvent(sharedData, event);
|
||||
if (next === sharedData) return;
|
||||
sharedData = next;
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
if (!sharedData) return;
|
||||
const next = applyEvent(sharedData, event);
|
||||
if (next === sharedData) return;
|
||||
sharedData = next;
|
||||
notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
data: SidebarResponse | null;
|
||||
|
||||
Reference in New Issue
Block a user