feat(tasks): aggregated task progress FAB + bottom sheet

- Add TaskAggregator (server/stores) to unify TaskCreate/TaskUpdate/TodoWrite
- Broadcast task-state snapshots via new WS event on tool events + reconnect
- TaskFab: SVG progress ring with fade-out on completion, reappears on new tasks
- TaskBottomSheet: full task list with dependencies, activeForm, expandable description
- Remove inline TodoWrite rendering (TaskProgress), filter task tools from chat flow
- Rebuild task state from JSONL history on server restart/reconnect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuannnn
2026-03-29 07:11:26 +08:00
parent fa81cb175c
commit a1ada37cba
14 changed files with 588 additions and 88 deletions
+51 -1
View File
@@ -5,6 +5,9 @@ export type WsStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecti
type MessageHandler = (msg: any) => void;
type StatusHandler = (status: WsStatus) => void;
/** Minimum hidden duration (ms) before forcing reconnect on visibility change. */
const VISIBILITY_RECONNECT_THRESHOLD = 3000;
export class WsClient {
private ws: WebSocket | null = null;
private url: string;
@@ -15,6 +18,8 @@ export class WsClient {
private shouldReconnect = true;
private activeSessionId: string | null = null;
private activeAdapter: string | null = null;
private visibilityHandler: (() => void) | null = null;
private hiddenSince: number | null = null;
constructor(token: string, onMessage: MessageHandler, onStatus: StatusHandler) {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
@@ -46,7 +51,9 @@ export class WsClient {
if (msg.adapter) this.activeAdapter = msg.adapter;
}
this.onMessage(msg);
} catch {}
} catch (err) {
console.error('[ws] Failed to parse message:', err);
}
};
this.ws.onclose = () => {
@@ -61,6 +68,8 @@ export class WsClient {
this.ws.onerror = () => {
this.ws?.close();
};
this._startVisibilityWatch();
}
send(msg: any) {
@@ -74,9 +83,50 @@ export class WsClient {
this.activeAdapter = adapter || null;
}
/** Force close and reconnect immediately (reset backoff). */
forceReconnect() {
if (!this.shouldReconnect) return;
this.reconnectDelay = 1000;
this.hiddenSince = null;
const old = this.ws;
this.ws = null;
if (old) {
old.onclose = null;
old.onerror = null;
old.onmessage = null;
old.close();
}
this.connect();
}
disconnect() {
this.shouldReconnect = false;
this._stopVisibilityWatch();
this.ws?.close();
this.ws = null;
}
private _startVisibilityWatch() {
if (this.visibilityHandler) return;
this.visibilityHandler = () => {
if (document.visibilityState === 'hidden') {
this.hiddenSince = Date.now();
} else if (document.visibilityState === 'visible' && this.shouldReconnect) {
const elapsed = this.hiddenSince ? Date.now() - this.hiddenSince : 0;
this.hiddenSince = null;
// Only force reconnect if page was hidden long enough for the WS to go stale
if (elapsed >= VISIBILITY_RECONNECT_THRESHOLD) {
this.forceReconnect();
}
}
};
document.addEventListener('visibilitychange', this.visibilityHandler);
}
private _stopVisibilityWatch() {
if (this.visibilityHandler) {
document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null;
}
}
}