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:
@@ -7,6 +7,7 @@ import { basename } from 'path';
|
|||||||
import type { ClientConnection } from './transport/client-connection.js';
|
import type { ClientConnection } from './transport/client-connection.js';
|
||||||
import { sessionReviews, sessionAdapters } from './db.js';
|
import { sessionReviews, sessionAdapters } from './db.js';
|
||||||
import { parseAskQuestionInput } from './adapters/shared/ask-question-utils.js';
|
import { parseAskQuestionInput } from './adapters/shared/ask-question-utils.js';
|
||||||
|
import { TaskAggregator, TASK_TOOL_NAMES, TASK_TOOLS_ON_START } from './stores/task-aggregator.js';
|
||||||
|
|
||||||
/** Push notification options */
|
/** Push notification options */
|
||||||
interface PushOptions {
|
interface PushOptions {
|
||||||
@@ -53,6 +54,22 @@ const sessionAdapterMap = new Map<string, string>(); // sessionId -
|
|||||||
// under the old key. This alias map resolves old → new so late-connecting clients
|
// under the old key. This alias map resolves old → new so late-connecting clients
|
||||||
// find the correct session.
|
// find the correct session.
|
||||||
const rekeyAliases = new Map<string, string>(); // oldKey -> newKey
|
const rekeyAliases = new Map<string, string>(); // oldKey -> newKey
|
||||||
|
const sessionTaskState = new Map<string, TaskAggregator>(); // sessionId -> task aggregator
|
||||||
|
|
||||||
|
function getOrCreateAggregator(sessionId: string): TaskAggregator {
|
||||||
|
let agg = sessionTaskState.get(sessionId);
|
||||||
|
if (!agg) {
|
||||||
|
agg = new TaskAggregator();
|
||||||
|
sessionTaskState.set(sessionId, agg);
|
||||||
|
}
|
||||||
|
return agg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastTaskState(sessionId: string): void {
|
||||||
|
const aggregator = sessionTaskState.get(sessionId);
|
||||||
|
if (!aggregator?.hasTasks) return;
|
||||||
|
broadcast(sessionId, { type: WS.TASK_STATE, ...aggregator.getSnapshot() });
|
||||||
|
}
|
||||||
|
|
||||||
export function setupSessionManager(): void {
|
export function setupSessionManager(): void {
|
||||||
const adapters = getAllAdapters();
|
const adapters = getAllAdapters();
|
||||||
@@ -69,11 +86,24 @@ export function setupSessionManager(): void {
|
|||||||
adapter.on('tool-start', (sessionId: string, data: { toolName: string; [key: string]: unknown }) => {
|
adapter.on('tool-start', (sessionId: string, data: { toolName: string; [key: string]: unknown }) => {
|
||||||
console.log(`[mgr] tool-start: ${data.toolName} for ${sessionId}`);
|
console.log(`[mgr] tool-start: ${data.toolName} for ${sessionId}`);
|
||||||
broadcast(sessionId, { type: WS.TOOL_START, ...data });
|
broadcast(sessionId, { type: WS.TOOL_START, ...data });
|
||||||
|
|
||||||
|
// TaskUpdate/TodoWrite can be processed on start (input is sufficient).
|
||||||
|
// TaskCreate is deferred to tool-done because we need the result text for the assigned ID.
|
||||||
|
if (TASK_TOOLS_ON_START.has(data.toolName)) {
|
||||||
|
getOrCreateAggregator(sessionId).processToolUse(data.toolName, (data.input as Record<string, unknown>) || {});
|
||||||
|
broadcastTaskState(sessionId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
adapter.on('tool-done', (sessionId: string, data: { toolName: string; [key: string]: unknown }) => {
|
adapter.on('tool-done', (sessionId: string, data: { toolName: string; result?: any; [key: string]: unknown }) => {
|
||||||
console.log(`[mgr] tool-done: ${data.toolName} for ${sessionId}`);
|
console.log(`[mgr] tool-done: ${data.toolName} for ${sessionId}`);
|
||||||
broadcast(sessionId, { type: WS.TOOL_DONE, ...data });
|
broadcast(sessionId, { type: WS.TOOL_DONE, ...data });
|
||||||
|
|
||||||
|
if (TASK_TOOL_NAMES.has(data.toolName)) {
|
||||||
|
const resultText = typeof data.result?.content === 'string' ? data.result.content : '';
|
||||||
|
getOrCreateAggregator(sessionId).processToolUse(data.toolName, (data.input as Record<string, unknown>) || {}, resultText);
|
||||||
|
broadcastTaskState(sessionId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
adapter.on('new-messages', (sessionId: string, messages: Array<{ role: string; [key: string]: unknown }>) => {
|
adapter.on('new-messages', (sessionId: string, messages: Array<{ role: string; [key: string]: unknown }>) => {
|
||||||
@@ -166,6 +196,7 @@ export function setupSessionManager(): void {
|
|||||||
// THEN clean up maps
|
// THEN clean up maps
|
||||||
sessionClients.delete(sessionId);
|
sessionClients.delete(sessionId);
|
||||||
sessionAdapterMap.delete(sessionId);
|
sessionAdapterMap.delete(sessionId);
|
||||||
|
sessionTaskState.delete(sessionId);
|
||||||
// Clean rekey alias pointing to this session
|
// Clean rekey alias pointing to this session
|
||||||
for (const [oldKey, newKey] of rekeyAliases) {
|
for (const [oldKey, newKey] of rekeyAliases) {
|
||||||
if (newKey === sessionId) rekeyAliases.delete(oldKey);
|
if (newKey === sessionId) rekeyAliases.delete(oldKey);
|
||||||
@@ -214,6 +245,12 @@ export function setupSessionManager(): void {
|
|||||||
sessionAdapterMap.delete(oldKey);
|
sessionAdapterMap.delete(oldKey);
|
||||||
sessionAdapterMap.set(newKey, adapterName);
|
sessionAdapterMap.set(newKey, adapterName);
|
||||||
}
|
}
|
||||||
|
// Move task state
|
||||||
|
const taskState = sessionTaskState.get(oldKey);
|
||||||
|
if (taskState) {
|
||||||
|
sessionTaskState.delete(oldKey);
|
||||||
|
sessionTaskState.set(newKey, taskState);
|
||||||
|
}
|
||||||
// Update any active reviews that reference the old key as child (FIX 3)
|
// Update any active reviews that reference the old key as child (FIX 3)
|
||||||
sessionReviews.updateChildCliId(oldKey, newKey);
|
sessionReviews.updateChildCliId(oldKey, newKey);
|
||||||
// Send SESSION_CREATED with the real UUID — for pendingRekey adapters,
|
// Send SESSION_CREATED with the real UUID — for pendingRekey adapters,
|
||||||
@@ -438,16 +475,38 @@ export async function handleReconnect(conn: ClientConnection, sessionId?: string
|
|||||||
// that would duplicate what HISTORY_LOAD delivers
|
// that would duplicate what HISTORY_LOAD delivers
|
||||||
adapter.syncWatcherPosition(resolvedId);
|
adapter.syncWatcherPosition(resolvedId);
|
||||||
|
|
||||||
// Send current messages from store (full history for reconnection)
|
const isStreaming = adapter.isProcessing(resolvedId);
|
||||||
|
let historyMessages: unknown[] = [];
|
||||||
try {
|
try {
|
||||||
const { messages } = await adapter.getMessages(resolvedId);
|
({ messages: historyMessages } = await adapter.getMessages(resolvedId));
|
||||||
if (messages.length > 0) {
|
|
||||||
send(conn, { type: WS.HISTORY_LOAD, messages });
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
|
// Rebuild task state from history if not already cached (e.g. after server restart)
|
||||||
|
if (!sessionTaskState.has(resolvedId) && historyMessages.length > 0) {
|
||||||
|
const aggregator = new TaskAggregator();
|
||||||
|
for (const msg of historyMessages as Array<{ role?: string; content?: any[] }>) {
|
||||||
|
if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue;
|
||||||
|
for (const block of msg.content) {
|
||||||
|
if (block.type === 'tool_use' && TASK_TOOL_NAMES.has(block.name)) {
|
||||||
|
const resultText = block._result?.content;
|
||||||
|
aggregator.processToolUse(block.name, block.input || {}, typeof resultText === 'string' ? resultText : undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (aggregator.hasTasks) {
|
||||||
|
sessionTaskState.set(resolvedId, aggregator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notify client if session is actively processing
|
send(conn, { type: WS.HISTORY_LOAD, messages: historyMessages, streaming: isStreaming });
|
||||||
if (adapter.isProcessing(resolvedId)) {
|
|
||||||
|
// Send accumulated task state if available
|
||||||
|
const taskAgg = sessionTaskState.get(resolvedId);
|
||||||
|
if (taskAgg?.hasTasks) {
|
||||||
|
send(conn, { type: WS.TASK_STATE, ...taskAgg.getSnapshot() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: client may receive broadcasts before HISTORY_LOAD during the async gap
|
||||||
|
if (isStreaming) {
|
||||||
send(conn, { type: WS.SESSION_STATE, streaming: true });
|
send(conn, { type: WS.SESSION_STATE, streaming: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Pure aggregator: processes tool_use blocks (TaskCreate, TaskUpdate, TodoWrite)
|
||||||
|
* and produces a unified AggregatedTask[] snapshot.
|
||||||
|
*
|
||||||
|
* Stateful — call processToolUse() for each relevant tool call in order.
|
||||||
|
* Call getSnapshot() to read current state (cached, invalidated on changes).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AggregatedTask {
|
||||||
|
id: string;
|
||||||
|
subject: string;
|
||||||
|
description?: string;
|
||||||
|
activeForm?: string;
|
||||||
|
status: 'pending' | 'in_progress' | 'completed';
|
||||||
|
blockedBy?: string[];
|
||||||
|
blocks?: string[];
|
||||||
|
source: 'task-api' | 'todo-write';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskSnapshot {
|
||||||
|
tasks: AggregatedTask[];
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tool names that produce task state changes. */
|
||||||
|
export const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TodoWrite']);
|
||||||
|
/** Subset handled on tool-start (TaskCreate needs result text, so it's deferred to tool-done). */
|
||||||
|
export const TASK_TOOLS_ON_START = new Set(['TaskUpdate', 'TodoWrite']);
|
||||||
|
|
||||||
|
export class TaskAggregator {
|
||||||
|
private taskApiTasks = new Map<string, AggregatedTask>();
|
||||||
|
private todoWriteTasks = new Map<string, AggregatedTask>();
|
||||||
|
private cachedSnapshot: TaskSnapshot | null = null;
|
||||||
|
|
||||||
|
/** Process a single tool_use block. Returns true if state changed. */
|
||||||
|
processToolUse(toolName: string, input: Record<string, unknown>, resultText?: string): boolean {
|
||||||
|
switch (toolName) {
|
||||||
|
case 'TaskCreate': {
|
||||||
|
const match = resultText?.match(/Task #(\d+)/);
|
||||||
|
const id = match?.[1] || String(this.taskApiTasks.size + 1);
|
||||||
|
this.taskApiTasks.set(id, {
|
||||||
|
id,
|
||||||
|
subject: (input.subject as string) || '',
|
||||||
|
description: (input.description as string) || undefined,
|
||||||
|
activeForm: (input.activeForm as string) || undefined,
|
||||||
|
status: 'pending',
|
||||||
|
source: 'task-api',
|
||||||
|
});
|
||||||
|
this.cachedSnapshot = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'TaskUpdate': {
|
||||||
|
const taskId = input.taskId as string;
|
||||||
|
if (!taskId) return false;
|
||||||
|
const existing = this.taskApiTasks.get(taskId);
|
||||||
|
if (!existing) return false;
|
||||||
|
if (input.status === 'deleted') {
|
||||||
|
this.taskApiTasks.delete(taskId);
|
||||||
|
this.cachedSnapshot = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const updated: AggregatedTask = { ...existing };
|
||||||
|
if (input.status) updated.status = input.status as AggregatedTask['status'];
|
||||||
|
if (input.subject) updated.subject = input.subject as string;
|
||||||
|
if (input.description) updated.description = input.description as string;
|
||||||
|
if (input.activeForm) updated.activeForm = input.activeForm as string;
|
||||||
|
if (input.addBlockedBy) {
|
||||||
|
updated.blockedBy = [...(updated.blockedBy || []), ...(input.addBlockedBy as string[])];
|
||||||
|
}
|
||||||
|
if (input.addBlocks) {
|
||||||
|
updated.blocks = [...(updated.blocks || []), ...(input.addBlocks as string[])];
|
||||||
|
}
|
||||||
|
this.taskApiTasks.set(taskId, updated);
|
||||||
|
this.cachedSnapshot = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'TodoWrite': {
|
||||||
|
this.todoWriteTasks.clear();
|
||||||
|
const tasks = (input.tasks || input.todos || []) as Array<{
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
for (const t of tasks) {
|
||||||
|
this.todoWriteTasks.set(t.id, {
|
||||||
|
id: t.id,
|
||||||
|
subject: t.content || '',
|
||||||
|
status: (t.status as AggregatedTask['status']) || 'pending',
|
||||||
|
source: 'todo-write',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.cachedSnapshot = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(): TaskSnapshot {
|
||||||
|
if (this.cachedSnapshot) return this.cachedSnapshot;
|
||||||
|
const tasks: AggregatedTask[] = [
|
||||||
|
...this.taskApiTasks.values(),
|
||||||
|
...this.todoWriteTasks.values(),
|
||||||
|
];
|
||||||
|
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||||
|
this.cachedSnapshot = { tasks, completed, total: tasks.length };
|
||||||
|
return this.cachedSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasTasks(): boolean {
|
||||||
|
return this.taskApiTasks.size > 0 || this.todoWriteTasks.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.taskApiTasks.clear();
|
||||||
|
this.todoWriteTasks.clear();
|
||||||
|
this.cachedSnapshot = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ export const WS = {
|
|||||||
// Cross-AI Review
|
// Cross-AI Review
|
||||||
REVIEW_STARTED: 'review-started',
|
REVIEW_STARTED: 'review-started',
|
||||||
REVIEW_ENDED: 'review-ended',
|
REVIEW_ENDED: 'review-ended',
|
||||||
|
// Task Progress
|
||||||
|
TASK_STATE: 'task-state',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type WsType = typeof WS[keyof typeof WS];
|
export type WsType = typeof WS[keyof typeof WS];
|
||||||
|
|||||||
+26
-15
@@ -1,8 +1,8 @@
|
|||||||
import React, { useRef, useState, useEffect, useMemo, Fragment } from 'react';
|
import React, { useRef, useState, useEffect, useCallback, useMemo, Fragment } from 'react';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
import type { ChatMessage, ToolStatus } from '../hooks/useChat';
|
import type { ChatMessage, ToolStatus } from '../hooks/useChat';
|
||||||
import { MessageBubble } from './MessageBubble';
|
import { MessageBubble } from './MessageBubble';
|
||||||
import { ToolCallCard } from './ToolCallCard';
|
import { ToolCallCard } from './ToolCallCard';
|
||||||
import { TaskProgress } from './TaskProgress';
|
|
||||||
import { SubagentGroup } from './SubagentGroup';
|
import { SubagentGroup } from './SubagentGroup';
|
||||||
import { ShimmerInput } from './ShimmerInput';
|
import { ShimmerInput } from './ShimmerInput';
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@ export interface ChatBodyProps {
|
|||||||
toolStatuses: Map<string, ToolStatus>;
|
toolStatuses: Map<string, ToolStatus>;
|
||||||
onSend: (text: string) => void;
|
onSend: (text: string) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
disabled: boolean;
|
|
||||||
interrupted: boolean;
|
interrupted: boolean;
|
||||||
sendTargets?: { adapter: string; label: string }[];
|
sendTargets?: { adapter: string; label: string }[];
|
||||||
onSendTo?: (messageId: string, adapter?: string) => void;
|
onSendTo?: (messageId: string, adapter?: string) => void;
|
||||||
@@ -48,7 +47,6 @@ export function ChatBody({
|
|||||||
toolStatuses,
|
toolStatuses,
|
||||||
onSend,
|
onSend,
|
||||||
onStop,
|
onStop,
|
||||||
disabled,
|
|
||||||
interrupted,
|
interrupted,
|
||||||
sendTargets,
|
sendTargets,
|
||||||
onSendTo,
|
onSendTo,
|
||||||
@@ -67,12 +65,19 @@ export function ChatBody({
|
|||||||
const scrollRef = scrollContainerRef || internalRef;
|
const scrollRef = scrollContainerRef || internalRef;
|
||||||
const [userScrolled, setUserScrolled] = useState(false);
|
const [userScrolled, setUserScrolled] = useState(false);
|
||||||
|
|
||||||
// Auto-scroll to bottom when new messages arrive, unless user scrolled up
|
const scrollToBottom = useCallback(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
}, [scrollRef]);
|
||||||
|
|
||||||
|
// Auto-scroll when new content arrives
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userScrolled && scrollRef.current) {
|
if (!userScrolled) {
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
}, [messages, userScrolled]);
|
}, [messages, streaming, userScrolled, scrollToBottom]);
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -95,10 +100,9 @@ export function ChatBody({
|
|||||||
? new Set(content.filter((b: any) => b.type === 'tool_result').map((b: any) => b.tool_use_id))
|
? new Set(content.filter((b: any) => b.type === 'tool_result').map((b: any) => b.tool_use_id))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const taskBlocks = toolBlocks.filter((b: any) => b.name === 'TodoWrite');
|
|
||||||
const planBlocks = toolBlocks.filter((b: any) => b.name === 'ExitPlanMode' && b.input?.plan);
|
const planBlocks = toolBlocks.filter((b: any) => b.name === 'ExitPlanMode' && b.input?.plan);
|
||||||
const regularTools = toolBlocks.filter(
|
const regularTools = toolBlocks.filter(
|
||||||
(b: any) => !['TodoWrite', 'EnterPlanMode', 'ExitPlanMode'].includes(b.name),
|
(b: any) => !['TodoWrite', 'TaskCreate', 'TaskUpdate', 'EnterPlanMode', 'ExitPlanMode'].includes(b.name),
|
||||||
);
|
);
|
||||||
|
|
||||||
const subagentGroups = new Map<string, any[]>();
|
const subagentGroups = new Map<string, any[]>();
|
||||||
@@ -151,10 +155,6 @@ export function ChatBody({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const task of taskBlocks) {
|
|
||||||
elements.push(<TaskProgress key={task.id} input={task.input} />);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const plan of planBlocks) {
|
for (const plan of planBlocks) {
|
||||||
if (renderPlanBlock) {
|
if (renderPlanBlock) {
|
||||||
const node = renderPlanBlock(plan, hasPlanResponse, plan.id);
|
const node = renderPlanBlock(plan, hasPlanResponse, plan.id);
|
||||||
@@ -243,12 +243,23 @@ export function ChatBody({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll-to-bottom button */}
|
||||||
|
{userScrolled && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setUserScrolled(false); scrollToBottom(); }}
|
||||||
|
className="absolute bottom-20 right-4 w-8 h-8 rounded-full bg-surface border border-border flex items-center justify-center shadow-lg hover:bg-surface-light transition-colors z-10"
|
||||||
|
aria-label="Scroll to bottom"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-4 h-4 text-text-secondary" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{renderAboveInput?.()}
|
{renderAboveInput?.()}
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="shrink-0 px-4 py-2 safe-bottom">
|
<div className="shrink-0 px-4 py-2 safe-bottom">
|
||||||
{!hideInput ? (
|
{!hideInput ? (
|
||||||
<ShimmerInput onSend={onSend} onStop={onStop} disabled={disabled} streaming={streaming} interrupted={interrupted} initialText={initialInputText} placeholder={inputPlaceholder} />
|
<ShimmerInput onSend={onSend} onStop={onStop} disabled={false} streaming={streaming} interrupted={interrupted} initialText={initialInputText} placeholder={inputPlaceholder} />
|
||||||
) : (
|
) : (
|
||||||
<div className="px-4 py-3 text-center text-text-dim/40 text-xs italic">
|
<div className="px-4 py-3 text-center text-text-dim/40 text-xs italic">
|
||||||
Review ended — read only
|
Review ended — read only
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo, Fragment, type RefObject } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo, Fragment, type RefObject } from 'react';
|
||||||
import { useChat } from '../hooks/useChat';
|
import { useChat } from '../hooks/useChat';
|
||||||
|
import { useVisualViewport } from '../hooks/useVisualViewport';
|
||||||
import { PLAN_OPTION } from '../lib/ws-types';
|
import { PLAN_OPTION } from '../lib/ws-types';
|
||||||
import { InteractivePromptOverlay } from './InteractivePromptOverlay';
|
import { InteractivePromptOverlay } from './InteractivePromptOverlay';
|
||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
@@ -10,6 +11,8 @@ import { ReviewActionMenu } from './ReviewActionMenu';
|
|||||||
import { SendToExistingSheet } from './SendToExistingSheet';
|
import { SendToExistingSheet } from './SendToExistingSheet';
|
||||||
import { CollapsedReviewCard } from './CollapsedReviewCard';
|
import { CollapsedReviewCard } from './CollapsedReviewCard';
|
||||||
import { BlockMarker } from './BlockMarker';
|
import { BlockMarker } from './BlockMarker';
|
||||||
|
import { TaskFab } from './TaskFab';
|
||||||
|
import { TaskBottomSheet } from './TaskBottomSheet';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { getBrand } from '../lib/adapter-brands';
|
import { getBrand } from '../lib/adapter-brands';
|
||||||
import { extractTextFromBlocks } from '../lib/content-utils';
|
import { extractTextFromBlocks } from '../lib/content-utils';
|
||||||
@@ -136,6 +139,7 @@ export function ChatView({
|
|||||||
const reviewPanelRef = useRef<ReviewPanelHandle>(null);
|
const reviewPanelRef = useRef<ReviewPanelHandle>(null);
|
||||||
const reviewRefetchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reviewRefetchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const headerHidden = useAutoHideHeader(chatScrollRef);
|
const headerHidden = useAutoHideHeader(chatScrollRef);
|
||||||
|
const viewportHeight = useVisualViewport();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus,
|
messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus,
|
||||||
@@ -143,10 +147,12 @@ export function ChatView({
|
|||||||
queuedMessage, clearQueuedMessage,
|
queuedMessage, clearQueuedMessage,
|
||||||
activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel,
|
activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel,
|
||||||
historyReview, setHistoryReview,
|
historyReview, setHistoryReview,
|
||||||
|
taskSnapshot,
|
||||||
sendMessage, respondPrompt, respondPlan, abort,
|
sendMessage, respondPrompt, respondPlan, abort,
|
||||||
updateModel, updatePermissionMode,
|
updateModel, updatePermissionMode,
|
||||||
} = useChat(initialSessionId, cwd, adapter, initialPrompt);
|
} = useChat(initialSessionId, cwd, adapter, initialPrompt);
|
||||||
|
|
||||||
|
const [taskSheetOpen, setTaskSheetOpen] = useState(false);
|
||||||
const [availableAdapters, setAvailableAdapters] = useState<string[]>([]);
|
const [availableAdapters, setAvailableAdapters] = useState<string[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.adapters()
|
api.adapters()
|
||||||
@@ -477,7 +483,10 @@ export function ChatView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh bg-bg relative overflow-hidden safe-top">
|
<div
|
||||||
|
className="flex flex-col bg-bg relative overflow-hidden safe-top"
|
||||||
|
style={{ height: viewportHeight ? `${viewportHeight}px` : '100dvh' }}
|
||||||
|
>
|
||||||
{/* Header — auto-hides when scrolling up to view history */}
|
{/* Header — auto-hides when scrolling up to view history */}
|
||||||
<div className={`flex items-center gap-2 px-4 py-3 border-b border-border shrink-0 transition-all duration-200 ${headerHidden ? 'max-h-0 py-0 overflow-hidden opacity-0 border-b-0' : 'max-h-16 opacity-100'}`}>
|
<div className={`flex items-center gap-2 px-4 py-3 border-b border-border shrink-0 transition-all duration-200 ${headerHidden ? 'max-h-0 py-0 overflow-hidden opacity-0 border-b-0' : 'max-h-16 opacity-100'}`}>
|
||||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
@@ -498,7 +507,6 @@ export function ChatView({
|
|||||||
toolStatuses={toolStatuses}
|
toolStatuses={toolStatuses}
|
||||||
onSend={sendMessage}
|
onSend={sendMessage}
|
||||||
onStop={abort}
|
onStop={abort}
|
||||||
disabled={false}
|
|
||||||
interrupted={interrupted}
|
interrupted={interrupted}
|
||||||
sendTargets={sendTargets}
|
sendTargets={sendTargets}
|
||||||
onSendTo={handleSendTo}
|
onSendTo={handleSendTo}
|
||||||
@@ -579,6 +587,10 @@ export function ChatView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Task progress FAB + bottom sheet */}
|
||||||
|
<TaskFab snapshot={taskSnapshot} onClick={() => setTaskSheetOpen(true)} />
|
||||||
|
<TaskBottomSheet snapshot={taskSnapshot} open={taskSheetOpen} onClose={() => setTaskSheetOpen(false)} />
|
||||||
|
|
||||||
{/* Interactive prompt overlay (permissions, questions, plan approval, etc.) */}
|
{/* Interactive prompt overlay (permissions, questions, plan approval, etc.) */}
|
||||||
{interactivePrompt && (
|
{interactivePrompt && (
|
||||||
<InteractivePromptOverlay
|
<InteractivePromptOverlay
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ const ReviewTab = React.memo(function ReviewTab({ review, cwd, onSessionCreated,
|
|||||||
toolStatuses={toolStatuses || new Map()}
|
toolStatuses={toolStatuses || new Map()}
|
||||||
onSend={sendMessage}
|
onSend={sendMessage}
|
||||||
onStop={abort}
|
onStop={abort}
|
||||||
disabled={false}
|
|
||||||
interrupted={false}
|
interrupted={false}
|
||||||
onSendBack={readOnly ? undefined : handleSendBack}
|
onSendBack={readOnly ? undefined : handleSendBack}
|
||||||
hideInput={readOnly}
|
hideInput={readOnly}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { CheckCircle2, Circle, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { Progress } from './ui/progress';
|
||||||
|
import { BottomSheet } from './BottomSheet';
|
||||||
|
import type { AggregatedTask, TaskSnapshot } from '../hooks/useTaskState';
|
||||||
|
|
||||||
|
interface TaskBottomSheetProps {
|
||||||
|
snapshot: TaskSnapshot;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskRow({ task, taskMap }: { task: AggregatedTask; taskMap: Map<string, AggregatedTask> }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const blockers = useMemo(() => {
|
||||||
|
if (!task.blockedBy?.length) return [];
|
||||||
|
return task.blockedBy
|
||||||
|
.map(id => taskMap.get(`${task.source}:${id}`))
|
||||||
|
.filter((b): b is AggregatedTask => !!b && b.status !== 'completed');
|
||||||
|
}, [task.blockedBy, task.source, taskMap]);
|
||||||
|
|
||||||
|
const isBlocked = blockers.length > 0;
|
||||||
|
const hasExpandable = task.description || task.activeForm;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`py-2 border-b border-border/50 last:border-b-0 ${isBlocked ? 'opacity-50' : ''}`}>
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-2.5 ${hasExpandable ? 'cursor-pointer' : ''}`}
|
||||||
|
onClick={() => hasExpandable && setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 shrink-0">
|
||||||
|
{task.status === 'completed' ? (
|
||||||
|
<CheckCircle2 className="size-4 text-success" />
|
||||||
|
) : task.status === 'in_progress' ? (
|
||||||
|
<Loader2 className="size-4 text-accent animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Circle className="size-4 text-text-dim" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={`text-sm flex-1 ${
|
||||||
|
task.status === 'completed' ? 'text-text-dim line-through' :
|
||||||
|
task.status === 'in_progress' ? 'text-text font-medium' :
|
||||||
|
'text-text-secondary'
|
||||||
|
}`}>
|
||||||
|
{task.subject}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isBlocked && (
|
||||||
|
<span className="text-[10px] bg-warning/20 text-warning px-1.5 py-0.5 rounded shrink-0">
|
||||||
|
blocked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasExpandable && (
|
||||||
|
<span className="shrink-0 mt-0.5">
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronUp className="size-3.5 text-text-dim" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="size-3.5 text-text-dim" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.status === 'in_progress' && task.activeForm && (
|
||||||
|
<div className="ml-7 mt-1 text-xs text-accent italic">
|
||||||
|
{task.activeForm}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isBlocked && (
|
||||||
|
<div className="ml-7 mt-1 text-[11px] text-text-dim">
|
||||||
|
<span className="text-warning">↳</span> waiting: {blockers.map(b => b.subject).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && task.description && (
|
||||||
|
<div className="ml-7 mt-2 px-2.5 py-2 bg-surface-light rounded-md text-xs text-text-secondary leading-relaxed">
|
||||||
|
{task.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskBottomSheet({ snapshot, open, onClose }: TaskBottomSheetProps) {
|
||||||
|
const { tasks, completed, total } = snapshot;
|
||||||
|
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
|
||||||
|
const taskMap = useMemo(() => {
|
||||||
|
const map = new Map<string, AggregatedTask>();
|
||||||
|
for (const t of tasks) map.set(`${t.source}:${t.id}`, t);
|
||||||
|
return map;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheet visible={open} onClose={onClose} className="max-h-[70vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-4 pb-2">
|
||||||
|
<span className="text-sm font-semibold text-text">Tasks</span>
|
||||||
|
<span className="text-xs text-success font-mono">{completed}/{total}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<Progress value={pct} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||||
|
{tasks.map(task => (
|
||||||
|
<TaskRow
|
||||||
|
key={`${task.source}:${task.id}`}
|
||||||
|
task={task}
|
||||||
|
taskMap={taskMap}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { TaskSnapshot } from '../hooks/useTaskState';
|
||||||
|
|
||||||
|
type FabState = 'hidden' | 'visible' | 'fading';
|
||||||
|
|
||||||
|
interface TaskFabProps {
|
||||||
|
snapshot: TaskSnapshot;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskFab({ snapshot, onClick }: TaskFabProps) {
|
||||||
|
const { completed, total } = snapshot;
|
||||||
|
const [fabState, setFabState] = useState<FabState>('hidden');
|
||||||
|
|
||||||
|
const allDone = total > 0 && completed === total;
|
||||||
|
const pct = total > 0 ? completed / total : 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (total === 0) {
|
||||||
|
setFabState('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFabState('visible');
|
||||||
|
if (allDone) {
|
||||||
|
const timer = setTimeout(() => setFabState('fading'), 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [total, allDone]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fabState !== 'fading') return;
|
||||||
|
const timer = setTimeout(() => setFabState('hidden'), 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [fabState]);
|
||||||
|
|
||||||
|
if (fabState === 'hidden') return null;
|
||||||
|
|
||||||
|
const size = 48;
|
||||||
|
const strokeWidth = 3;
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const strokeDashoffset = circumference * (1 - pct);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="fixed bottom-20 right-4 z-20 safe-bottom transition-opacity duration-500"
|
||||||
|
style={{ opacity: fabState === 'fading' ? 0 : 1 }}
|
||||||
|
aria-label={`Tasks: ${completed} of ${total} completed`}
|
||||||
|
>
|
||||||
|
<svg width={size} height={size} className="drop-shadow-lg">
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="var(--color-surface)"
|
||||||
|
stroke="var(--color-border)"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={allDone ? 'var(--color-success)' : 'var(--color-accent)'}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||||
|
className="transition-all duration-500 ease-out"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="50%"
|
||||||
|
y="50%"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fill={allDone ? 'var(--color-success)' : 'var(--color-text)'}
|
||||||
|
fontSize="13"
|
||||||
|
fontWeight="600"
|
||||||
|
fontFamily="var(--font-mono, monospace)"
|
||||||
|
>
|
||||||
|
{completed}/{total}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Progress } from './ui/progress';
|
|
||||||
import { Circle, CircleDot, CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react';
|
|
||||||
|
|
||||||
type TodoItem = { id: string; content: string; status: 'pending' | 'in_progress' | 'completed' };
|
|
||||||
|
|
||||||
export function TaskProgress({ input }: { input: any }) {
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
const tasks: TodoItem[] = input?.tasks || input?.todos || [];
|
|
||||||
if (tasks.length === 0) return null;
|
|
||||||
const completed = tasks.filter((t) => t.status === 'completed').length;
|
|
||||||
const pct = Math.round((completed / tasks.length) * 100);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
|
||||||
className="w-full flex items-center justify-between mb-2"
|
|
||||||
>
|
|
||||||
<span className="text-xs text-text-dim">Tasks ({completed}/{tasks.length})</span>
|
|
||||||
{collapsed ? (
|
|
||||||
<ChevronDown className="size-3.5 text-text-dim" />
|
|
||||||
) : (
|
|
||||||
<ChevronUp className="size-3.5 text-text-dim" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<Progress value={pct} className="mb-2" />
|
|
||||||
{!collapsed && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{tasks.map((task) => (
|
|
||||||
<div key={task.id} className="flex items-start gap-2">
|
|
||||||
<span className="mt-0.5">
|
|
||||||
{task.status === 'completed' ? (
|
|
||||||
<CheckCircle2 className="size-3.5 text-success" />
|
|
||||||
) : task.status === 'in_progress' ? (
|
|
||||||
<CircleDot className="size-3.5 text-accent" />
|
|
||||||
) : (
|
|
||||||
<Circle className="size-3.5 text-text-dim" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className={`text-sm ${task.status === 'completed' ? 'text-text-dim line-through' : 'text-text'}`}>
|
|
||||||
{task.content}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+26
-11
@@ -6,6 +6,7 @@ import { api } from '../lib/api';
|
|||||||
import { patchAdapterPrefs, loadAdapterPrefs } from '../lib/adapter-prefs';
|
import { patchAdapterPrefs, loadAdapterPrefs } from '../lib/adapter-prefs';
|
||||||
import { stripMarker } from '@/lib/content-utils';
|
import { stripMarker } from '@/lib/content-utils';
|
||||||
import { parseAskQuestionInput } from '@/lib/ask-question-utils';
|
import { parseAskQuestionInput } from '@/lib/ask-question-utils';
|
||||||
|
import { useTaskState } from './useTaskState';
|
||||||
|
|
||||||
export type ChatMessage = {
|
export type ChatMessage = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -153,6 +154,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
|||||||
|
|
||||||
const [activeReviewPanel, setActiveReviewPanel] = useState<'expanded' | 'minimized'>('expanded');
|
const [activeReviewPanel, setActiveReviewPanel] = useState<'expanded' | 'minimized'>('expanded');
|
||||||
const [historyReview, setHistoryReview] = useState<any>(null);
|
const [historyReview, setHistoryReview] = useState<any>(null);
|
||||||
|
const { taskSnapshot, handleTaskState, resetTasks } = useTaskState();
|
||||||
|
|
||||||
const queuedRef = useRef<string | null>(null);
|
const queuedRef = useRef<string | null>(null);
|
||||||
const streamingRef = useRef(false);
|
const streamingRef = useRef(false);
|
||||||
@@ -177,6 +179,13 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const enterStreaming = useCallback(() => {
|
||||||
|
if (!streamingRef.current) setInterrupted(false);
|
||||||
|
setStreaming(true);
|
||||||
|
setPendingResponse(true);
|
||||||
|
streamingRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// --- WebSocket Message Handler ---
|
// --- WebSocket Message Handler ---
|
||||||
const handleWsMessage = useCallback((msg: any) => {
|
const handleWsMessage = useCallback((msg: any) => {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
@@ -196,6 +205,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
|||||||
} else {
|
} else {
|
||||||
if (msg.permissionMode) setPermissionMode(msg.permissionMode);
|
if (msg.permissionMode) setPermissionMode(msg.permissionMode);
|
||||||
}
|
}
|
||||||
|
resetTasks();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case WS.CLIENT_ID:
|
case WS.CLIENT_ID:
|
||||||
@@ -399,22 +409,22 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case WS.SESSION_STATE:
|
case WS.SESSION_STATE:
|
||||||
if (msg.streaming) {
|
if (msg.streaming) enterStreaming();
|
||||||
if (!streamingRef.current) {
|
|
||||||
setInterrupted(false);
|
|
||||||
}
|
|
||||||
setStreaming(true);
|
|
||||||
setPendingResponse(true);
|
|
||||||
streamingRef.current = true;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Full history load on reconnection (replaces, not appends)
|
|
||||||
case WS.HISTORY_LOAD:
|
case WS.HISTORY_LOAD:
|
||||||
if (msg.messages && Array.isArray(msg.messages)) {
|
if (msg.messages && Array.isArray(msg.messages)) {
|
||||||
setMessages(convertMessages(msg.messages));
|
setMessages(convertMessages(msg.messages));
|
||||||
}
|
}
|
||||||
setPendingResponse(false);
|
if (msg.streaming) {
|
||||||
|
enterStreaming();
|
||||||
|
} else {
|
||||||
|
setStreaming(false);
|
||||||
|
setPendingResponse(false);
|
||||||
|
setStreamingText('');
|
||||||
|
setThinkingStatus(null);
|
||||||
|
streamingRef.current = false;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case WS.STATUS_UPDATE:
|
case WS.STATUS_UPDATE:
|
||||||
@@ -464,8 +474,12 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
|||||||
streamingRef.current = false;
|
streamingRef.current = false;
|
||||||
console.error('Server error:', msg.error);
|
console.error('Server error:', msg.error);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case WS.TASK_STATE:
|
||||||
|
handleTaskState({ tasks: msg.tasks, completed: msg.completed, total: msg.total });
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}, [drainQueue]);
|
}, [drainQueue, enterStreaming, handleTaskState, resetTasks]);
|
||||||
|
|
||||||
// --- WebSocket Connection ---
|
// --- WebSocket Connection ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -645,6 +659,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
|||||||
queuedMessage, clearQueuedMessage,
|
queuedMessage, clearQueuedMessage,
|
||||||
activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel,
|
activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel,
|
||||||
historyReview, setHistoryReview,
|
historyReview, setHistoryReview,
|
||||||
|
taskSnapshot,
|
||||||
sendMessage, respondPermission, respondAsk, respondPrompt, respondPlan, abort,
|
sendMessage, respondPermission, respondAsk, respondPrompt, respondPlan, abort,
|
||||||
updateModel, updatePermissionMode, updateAdapter,
|
updateModel, updatePermissionMode, updateAdapter,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import type { AggregatedTask, TaskSnapshot } from '../../server/stores/task-aggregator';
|
||||||
|
|
||||||
|
export type { AggregatedTask, TaskSnapshot };
|
||||||
|
|
||||||
|
const EMPTY_SNAPSHOT: TaskSnapshot = { tasks: [], completed: 0, total: 0 };
|
||||||
|
|
||||||
|
export function useTaskState() {
|
||||||
|
const [taskSnapshot, setTaskSnapshot] = useState<TaskSnapshot>(EMPTY_SNAPSHOT);
|
||||||
|
|
||||||
|
const handleTaskState = useCallback((msg: TaskSnapshot) => {
|
||||||
|
setTaskSnapshot(msg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetTasks = useCallback(() => {
|
||||||
|
setTaskSnapshot(EMPTY_SNAPSHOT);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { taskSnapshot, handleTaskState, resetTasks };
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks window.visualViewport.height to work around iOS PWA
|
||||||
|
* keyboard resize bugs where `dvh` units don't update correctly
|
||||||
|
* after the virtual keyboard dismisses.
|
||||||
|
*
|
||||||
|
* Also forces window.scrollTo(0, 0) when the keyboard closes,
|
||||||
|
* because iOS standalone PWA may leave the document scrolled up
|
||||||
|
* after keyboard dismissal, creating a gap at the bottom.
|
||||||
|
*
|
||||||
|
* Returns the current visual viewport height in pixels, or
|
||||||
|
* undefined on platforms that don't support the API (falls back to CSS dvh).
|
||||||
|
*/
|
||||||
|
export function useVisualViewport(): number | undefined {
|
||||||
|
const [height, setHeight] = useState<number | undefined>(() =>
|
||||||
|
typeof window !== 'undefined' && window.visualViewport
|
||||||
|
? window.visualViewport.height
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const prevHeight = useRef(height);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (!vv) return;
|
||||||
|
|
||||||
|
const KEYBOARD_CLOSE_THRESHOLD = 50;
|
||||||
|
const update = () => {
|
||||||
|
const h = vv.height;
|
||||||
|
if (prevHeight.current && h > prevHeight.current + KEYBOARD_CLOSE_THRESHOLD) {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
if (h !== prevHeight.current) {
|
||||||
|
prevHeight.current = h;
|
||||||
|
setHeight(h);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
vv.addEventListener('resize', update);
|
||||||
|
vv.addEventListener('scroll', update);
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener('resize', update);
|
||||||
|
vv.removeEventListener('scroll', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return height;
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ export const WS = {
|
|||||||
// Cross-AI Review
|
// Cross-AI Review
|
||||||
REVIEW_STARTED: 'review-started',
|
REVIEW_STARTED: 'review-started',
|
||||||
REVIEW_ENDED: 'review-ended',
|
REVIEW_ENDED: 'review-ended',
|
||||||
|
// Task Progress
|
||||||
|
TASK_STATE: 'task-state',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+51
-1
@@ -5,6 +5,9 @@ export type WsStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecti
|
|||||||
type MessageHandler = (msg: any) => void;
|
type MessageHandler = (msg: any) => void;
|
||||||
type StatusHandler = (status: WsStatus) => void;
|
type StatusHandler = (status: WsStatus) => void;
|
||||||
|
|
||||||
|
/** Minimum hidden duration (ms) before forcing reconnect on visibility change. */
|
||||||
|
const VISIBILITY_RECONNECT_THRESHOLD = 3000;
|
||||||
|
|
||||||
export class WsClient {
|
export class WsClient {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private url: string;
|
private url: string;
|
||||||
@@ -15,6 +18,8 @@ export class WsClient {
|
|||||||
private shouldReconnect = true;
|
private shouldReconnect = true;
|
||||||
private activeSessionId: string | null = null;
|
private activeSessionId: string | null = null;
|
||||||
private activeAdapter: 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) {
|
constructor(token: string, onMessage: MessageHandler, onStatus: StatusHandler) {
|
||||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
@@ -46,7 +51,9 @@ export class WsClient {
|
|||||||
if (msg.adapter) this.activeAdapter = msg.adapter;
|
if (msg.adapter) this.activeAdapter = msg.adapter;
|
||||||
}
|
}
|
||||||
this.onMessage(msg);
|
this.onMessage(msg);
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[ws] Failed to parse message:', err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
@@ -61,6 +68,8 @@ export class WsClient {
|
|||||||
this.ws.onerror = () => {
|
this.ws.onerror = () => {
|
||||||
this.ws?.close();
|
this.ws?.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this._startVisibilityWatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
send(msg: any) {
|
send(msg: any) {
|
||||||
@@ -74,9 +83,50 @@ export class WsClient {
|
|||||||
this.activeAdapter = adapter || null;
|
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() {
|
disconnect() {
|
||||||
this.shouldReconnect = false;
|
this.shouldReconnect = false;
|
||||||
|
this._stopVisibilityWatch();
|
||||||
this.ws?.close();
|
this.ws?.close();
|
||||||
this.ws = null;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user