batch3 T6: usePanes hook + Shiki integration in CodeBlock
- hooks/usePanes: per-session panes CRUD; debounced (300ms) state PATCH; immediate position-change PATCH with refresh - CodeBlock: shiki async highlighting via codeToHtml + github-dark theme; LANG_MAP for ts/tsx/js/jsx/py/go/rs/rb/java/c/cpp/cs/php/sh/yaml/json/ toml/md/sql/dockerfile/html/css; falls back to plain pre on unknown lang or async failure - package.json: + shiki Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
145
apps/web/src/hooks/usePanes.ts
Normal file
145
apps/web/src/hooks/usePanes.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
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 flushPendingState = useCallback(() => {
|
||||
if (debounceTimer.current !== null) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = null;
|
||||
}
|
||||
const snapshot = new Map(pendingState.current);
|
||||
pendingState.current.clear();
|
||||
for (const [id, state] of snapshot) {
|
||||
// fire-and-forget; caller surface handles errors via hook error state
|
||||
void api.panes.update(id, { state }).catch((err) => {
|
||||
setError(err instanceof Error ? err.message : 'pane operation failed');
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
// 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> => {
|
||||
const stateOnly = body.state !== undefined && body.position === undefined;
|
||||
|
||||
if (stateOnly) {
|
||||
// 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: body.state as typeof pane.state };
|
||||
}
|
||||
if (pane.kind === 'file_browser') {
|
||||
return { ...pane, state: body.state as typeof pane.state };
|
||||
}
|
||||
return pane;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
// Coalesce: last state wins within debounce window
|
||||
pendingState.current.set(id, body.state!);
|
||||
|
||||
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
|
||||
const previous = panes;
|
||||
setPanes((prev) => (prev ? prev.filter((p) => p.id !== id) : prev));
|
||||
|
||||
try {
|
||||
await api.panes.remove(id);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
// Rollback
|
||||
setPanes(previous);
|
||||
setError(err instanceof Error ? err.message : 'pane operation failed');
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[panes, refresh]
|
||||
);
|
||||
|
||||
return { panes, loading, error, refresh, create, update, remove };
|
||||
}
|
||||
Reference in New Issue
Block a user