v1.12.1: rich status indicator + server-side workspace pane sync
Status indicator (StatusDot): drops the flat amber pulse for a richer set of states — orbiting amber for streaming, spinning sky ring for tool_running, static violet for waiting_for_input, plus the existing idle/error. Backend chat_status frame widens from 'working|idle|error' to discriminate streaming vs tool execution vs paused for user input. Workspace pane sync: pane layout moves from per-device localStorage to server-side sessions.workspace_panes jsonb. PATCH /api/sessions/:id/workspace broadcasts session_workspace_updated on the user channel for cross-device live sync. Echo dedup via JSON comparison so the round-trip frame doesn't loop. Legacy localStorage seeds the server on first hydrate, then is deleted. Deprecated session_panes table dropped. Resilience: startup sweep marks any stale 'streaming' message older than 5 minutes as 'failed' so v1.12.0-style hung rows clear on container restart. useWorkspacePanes gains validatePanes() to prune dead chatId references from saved pane state when the chat list lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -143,6 +143,11 @@ export const api = {
|
||||
),
|
||||
openChatsCount: (id: string) =>
|
||||
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
|
||||
updateWorkspacePanes: (id: string, panes: Session['workspace_panes']) =>
|
||||
request<Session>(`/api/sessions/${id}/workspace`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ workspace_panes: panes }),
|
||||
}),
|
||||
},
|
||||
|
||||
chats: {
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface Session {
|
||||
agent_id: string | null;
|
||||
// v1.9: null = inherit from project.default_web_search_enabled.
|
||||
web_search_enabled: boolean | null;
|
||||
// v1.12.1: server-authoritative pane layout, replaces localStorage.
|
||||
workspace_panes: WorkspacePane[];
|
||||
}
|
||||
|
||||
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
|
||||
|
||||
@@ -6,15 +6,10 @@ interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATUS_CLASS: Record<DerivedStatus, string> = {
|
||||
working: 'bg-amber-500 animate-pulse',
|
||||
idle_warm: 'bg-emerald-500',
|
||||
idle_cold: 'bg-muted-foreground/40',
|
||||
error: 'bg-destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<DerivedStatus, string> = {
|
||||
working: 'working',
|
||||
streaming: 'streaming',
|
||||
tool_running: 'running tool',
|
||||
waiting_for_input: 'waiting for input',
|
||||
idle_warm: 'idle',
|
||||
idle_cold: 'idle',
|
||||
error: 'error',
|
||||
@@ -22,15 +17,58 @@ const STATUS_LABEL: Record<DerivedStatus, string> = {
|
||||
|
||||
export function StatusDot({ chatId, className }: Props) {
|
||||
const status = useChatStatus(chatId);
|
||||
|
||||
if (status === 'streaming') {
|
||||
return (
|
||||
<span
|
||||
aria-label="Status: streaming"
|
||||
title="streaming"
|
||||
className={cn('inline-block relative w-3 h-3 shrink-0', className)}
|
||||
>
|
||||
<span className="absolute inset-0 animate-spin-slow">
|
||||
<span className="absolute top-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-amber-500" />
|
||||
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-amber-500/60" />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'tool_running') {
|
||||
return (
|
||||
<span
|
||||
aria-label="Status: running tool"
|
||||
title="running tool"
|
||||
className={cn(
|
||||
'inline-block w-3 h-3 rounded-full border-2 border-sky-500 border-t-transparent animate-spin shrink-0',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'waiting_for_input') {
|
||||
return (
|
||||
<span
|
||||
aria-label="Status: waiting for input"
|
||||
title="waiting for input"
|
||||
className={cn(
|
||||
'inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-violet-500',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const bg =
|
||||
status === 'idle_warm' ? 'bg-emerald-500'
|
||||
: status === 'error' ? 'bg-destructive'
|
||||
: 'bg-muted-foreground/40';
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={`Status: ${STATUS_LABEL[status]}`}
|
||||
title={STATUS_LABEL[status]}
|
||||
className={cn(
|
||||
'inline-block w-1.5 h-1.5 rounded-full shrink-0',
|
||||
STATUS_CLASS[status],
|
||||
className,
|
||||
)}
|
||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', bg, className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@ export interface SessionUpdatedEvent {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SessionWorkspaceUpdatedEvent {
|
||||
type: 'session_workspace_updated';
|
||||
session_id: string;
|
||||
workspace_panes: import('@/api/types').WorkspacePane[];
|
||||
}
|
||||
|
||||
export interface SessionLoadedEvent {
|
||||
type: 'session_loaded';
|
||||
session_id: string;
|
||||
@@ -131,7 +137,7 @@ export interface ProjectUpdatedEvent {
|
||||
export interface ChatStatusEvent {
|
||||
type: 'chat_status';
|
||||
chat_id: string;
|
||||
status: 'working' | 'idle' | 'error';
|
||||
status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error';
|
||||
at: string;
|
||||
reason?: ErrorReason;
|
||||
}
|
||||
@@ -143,6 +149,7 @@ export type SessionEvent =
|
||||
| SessionCreatedEvent
|
||||
| SessionDeletedEvent
|
||||
| SessionUpdatedEvent
|
||||
| SessionWorkspaceUpdatedEvent
|
||||
| SessionLoadedEvent
|
||||
| OpenFileInBrowserEvent
|
||||
| AttachChatFileEvent
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
|
||||
export type RawStatus = 'working' | 'idle' | 'error';
|
||||
export type DerivedStatus = 'working' | 'idle_warm' | 'idle_cold' | 'error';
|
||||
export type RawStatus = 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error';
|
||||
export type DerivedStatus =
|
||||
| 'streaming'
|
||||
| 'tool_running'
|
||||
| 'waiting_for_input'
|
||||
| 'idle_warm'
|
||||
| 'idle_cold'
|
||||
| 'error';
|
||||
|
||||
// Window during which an idle dot stays green; after this, it fades to gray.
|
||||
const WARM_WINDOW_MS = 30_000;
|
||||
@@ -53,7 +59,9 @@ if (!G.__boocode_chat_status_subscribed) {
|
||||
|
||||
function derive(entry: Entry | undefined): DerivedStatus {
|
||||
if (!entry) return 'idle_cold';
|
||||
if (entry.status === 'working') return 'working';
|
||||
if (entry.status === 'streaming') return 'streaming';
|
||||
if (entry.status === 'tool_running') return 'tool_running';
|
||||
if (entry.status === 'waiting_for_input') return 'waiting_for_input';
|
||||
if (entry.status === 'error') return 'error';
|
||||
const age = Date.now() - new Date(entry.at).getTime();
|
||||
return age < WARM_WINDOW_MS ? 'idle_warm' : 'idle_cold';
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface UseSessionChatsOpts {
|
||||
// about pane indexing.
|
||||
openChatInActivePane: (chatId: string) => void;
|
||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||
validatePanes: (validChatIds: Set<string>) => void;
|
||||
}
|
||||
|
||||
export interface UseSessionChatsResult {
|
||||
@@ -44,12 +45,15 @@ export function useSessionChats(
|
||||
openChatInActivePaneRef.current = opts.openChatInActivePane;
|
||||
const initializeFirstChatIfEmptyRef = useRef(opts.initializeFirstChatIfEmpty);
|
||||
initializeFirstChatIfEmptyRef.current = opts.initializeFirstChatIfEmpty;
|
||||
const validatePanesRef = useRef(opts.validatePanes);
|
||||
validatePanesRef.current = opts.validatePanes;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.chats.listForSession(sessionId).then((list) => {
|
||||
if (cancelled) return;
|
||||
setChats(list);
|
||||
validatePanesRef.current(new Set(list.map((c) => c.id)));
|
||||
const openChat = list.find((c) => c.status === 'open');
|
||||
if (openChat) {
|
||||
initializeFirstChatIfEmptyRef.current(openChat.id);
|
||||
|
||||
@@ -143,6 +143,9 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
||||
case 'session_loaded':
|
||||
// activeSessionProjectId is updated in the subscribe callback; no data change here.
|
||||
return prev;
|
||||
case 'session_workspace_updated':
|
||||
// Pane layout is consumed by useWorkspacePanes; sidebar has no stake.
|
||||
return prev;
|
||||
case 'open_file_in_browser':
|
||||
// Consumed by Workspace (T7); no sidebar state change needed.
|
||||
return prev;
|
||||
|
||||
@@ -4,9 +4,14 @@ import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { WorkspacePane } from '@/api/types';
|
||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
|
||||
export const MAX_PANES = 5;
|
||||
const STORAGE_KEY = 'boocode.workspace.panes';
|
||||
// v1.12.1: legacy localStorage key. Read once on mount to seed the server
|
||||
// for sessions still on per-device state, then deleted. Server is now
|
||||
// authoritative via sessions.workspace_panes.
|
||||
const LEGACY_STORAGE_KEY = 'boocode.workspace.panes';
|
||||
const SAVE_DEBOUNCE_MS = 300;
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
@@ -51,9 +56,11 @@ function nonSettingsCount(panes: WorkspacePane[]): number {
|
||||
return panes.reduce((n, p) => n + (p.kind === 'settings' ? 0 : 1), 0);
|
||||
}
|
||||
|
||||
function loadPanes(sessionId: string): WorkspacePane[] | null {
|
||||
// v1.12.1: read legacy per-device localStorage. If present, the caller seeds
|
||||
// the server then deletes the key. One-time migration per session.
|
||||
function readLegacyPanes(sessionId: string): WorkspacePane[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
|
||||
const raw = localStorage.getItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as WorkspacePane[];
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
||||
@@ -63,15 +70,6 @@ function loadPanes(sessionId: string): WorkspacePane[] | null {
|
||||
}
|
||||
}
|
||||
|
||||
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${STORAGE_KEY}.${sessionId}`,
|
||||
JSON.stringify(persistablePanes(panes)),
|
||||
);
|
||||
} catch { /* quota or disabled */ }
|
||||
}
|
||||
|
||||
export interface UseWorkspacePanesResult {
|
||||
panes: WorkspacePane[];
|
||||
activePaneIdx: number;
|
||||
@@ -96,6 +94,7 @@ export interface UseWorkspacePanesResult {
|
||||
removePane: (idx: number) => void;
|
||||
removeChatFromPanes: (chatId: string) => void;
|
||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||
validatePanes: (validChatIds: Set<string>) => void;
|
||||
handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
||||
handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
||||
handlePaneDragLeave: () => void;
|
||||
@@ -106,15 +105,85 @@ export interface UseWorkspacePanesResult {
|
||||
}
|
||||
|
||||
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
|
||||
return loadPanes(sessionId) ?? [emptyPane()];
|
||||
});
|
||||
const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]);
|
||||
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
||||
const draggingIdxRef = useRef<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
// v1.12.1: skip PATCH while hydrating from the server. Without this, the
|
||||
// initial [emptyPane()] would be saved over the server's real state before
|
||||
// the GET resolves.
|
||||
const hydratedRef = useRef(false);
|
||||
// Tracks the last value broadcast by another device (or this one's own
|
||||
// round-trip). If a PATCH would echo this exact payload, we skip the call.
|
||||
const lastRemoteJsonRef = useRef<string>('[]');
|
||||
|
||||
// v1.12.1: hydrate from server on mount, then subscribe to remote updates.
|
||||
useEffect(() => {
|
||||
savePanes(sessionId, panes);
|
||||
hydratedRef.current = false;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const session = await api.sessions.get(sessionId);
|
||||
if (cancelled) return;
|
||||
let initial: WorkspacePane[] = Array.isArray(session.workspace_panes)
|
||||
? session.workspace_panes
|
||||
: [];
|
||||
// One-time migration: if server is empty but legacy localStorage has
|
||||
// a layout, seed the server and delete the local key.
|
||||
if (initial.length === 0) {
|
||||
const legacy = readLegacyPanes(sessionId);
|
||||
if (legacy && legacy.length > 0) {
|
||||
try {
|
||||
const updated = await api.sessions.updateWorkspacePanes(sessionId, legacy);
|
||||
if (cancelled) return;
|
||||
initial = updated.workspace_panes;
|
||||
localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
|
||||
} catch {
|
||||
initial = legacy;
|
||||
}
|
||||
}
|
||||
}
|
||||
const next = initial.length > 0 ? initial : [emptyPane()];
|
||||
lastRemoteJsonRef.current = JSON.stringify(persistablePanes(next));
|
||||
setPanes(next);
|
||||
setActivePaneIdx(0);
|
||||
} finally {
|
||||
if (!cancelled) hydratedRef.current = true;
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [sessionId]);
|
||||
|
||||
// v1.12.1: live cross-device sync. Replace local state when another device
|
||||
// (or our own write echo) lands a session_workspace_updated frame.
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((ev) => {
|
||||
if (ev.type !== 'session_workspace_updated') return;
|
||||
if (ev.session_id !== sessionId) return;
|
||||
const incoming = Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [];
|
||||
const json = JSON.stringify(incoming);
|
||||
if (json === lastRemoteJsonRef.current) return;
|
||||
lastRemoteJsonRef.current = json;
|
||||
setPanes(incoming.length > 0 ? incoming : [emptyPane()]);
|
||||
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
// v1.12.1: debounced PATCH on every change. Settings panes are stripped
|
||||
// before saving (ephemeral per v1.9).
|
||||
useEffect(() => {
|
||||
if (!hydratedRef.current) return;
|
||||
const payload = persistablePanes(panes);
|
||||
const json = JSON.stringify(payload);
|
||||
if (json === lastRemoteJsonRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
lastRemoteJsonRef.current = json;
|
||||
api.sessions.updateWorkspacePanes(sessionId, payload).catch(() => {
|
||||
// Non-fatal: next change retries. Persistent failures surface via
|
||||
// the network layer's existing reconnect toast.
|
||||
});
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [sessionId, panes]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -328,6 +397,23 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const validatePanes = useCallback((validChatIds: Set<string>) => {
|
||||
setPanes((prev) => {
|
||||
const cleaned = prev.map((pane) => {
|
||||
if (pane.kind !== 'chat' || pane.chatIds.length === 0) return pane;
|
||||
const nextIds = pane.chatIds.filter((id) => validChatIds.has(id));
|
||||
if (nextIds.length === pane.chatIds.length) return pane;
|
||||
if (nextIds.length === 0) {
|
||||
return { ...pane, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
||||
return { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx] };
|
||||
});
|
||||
const unchanged = cleaned.every((p, i) => p === prev[i]);
|
||||
return unchanged ? prev : cleaned;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeChatFromPanes = useCallback((chatId: string) => {
|
||||
setPanes((prev) => prev.map((p) => {
|
||||
const idx = p.chatIds.indexOf(chatId);
|
||||
@@ -411,6 +497,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
removePane,
|
||||
removeChatFromPanes,
|
||||
initializeFirstChatIfEmpty,
|
||||
validatePanes,
|
||||
handlePaneDragStart,
|
||||
handlePaneDragOver,
|
||||
handlePaneDragLeave,
|
||||
|
||||
@@ -59,6 +59,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
removePane,
|
||||
removeChatFromPanes,
|
||||
initializeFirstChatIfEmpty,
|
||||
validatePanes,
|
||||
} = panesHook;
|
||||
|
||||
const openChatInActivePane = useCallback(
|
||||
@@ -70,6 +71,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
openChatInPane,
|
||||
openChatInActivePane,
|
||||
initializeFirstChatIfEmpty,
|
||||
validatePanes,
|
||||
});
|
||||
const { chats, renameChat } = chatsHook;
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--font-sans: "Inter Variable", "Inter", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono Variable", ui-monospace, SFMono-Regular, monospace;
|
||||
--animate-spin-slow: spin 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
Reference in New Issue
Block a user