Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 45 additions & 38 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from '@qwen-code/webui/daemon-react-sdk';
import { isDaemonTurnError } from '@qwen-code/sdk/daemon';
import { extractPendingPermission } from './adapters/transcriptAdapter';
import { MessageList } from './components/MessageList';
import { MessageList, type MessageListHandle } from './components/MessageList';
import { Editor, type EditorHandle } from './components/Editor';
import type { PromptImage } from './adapters/promptTypes';
import { StatusBar, type StatusBarHandle } from './components/StatusBar';
Expand Down Expand Up @@ -117,13 +117,8 @@ import {
} from './components/messages/GoalStatusMessage';
import { TASKS_STATUS_ACTIVE_EVENT } from './components/messages/TasksStatusMessage';
import { BtwMessage } from './components/messages/BtwMessage';
import type {
ACPToolCall,
Message,
PermissionRequest,
TodoItem,
} from './adapters/types';
import { extractTodosFromToolCall, hasActiveTodos } from './utils/todos';
import type { ACPToolCall, Message, PermissionRequest } from './adapters/types';
import { getFloatingTodos } from './utils/todos';
import { ThemeProvider } from './themeContext';
import {
WebShellThemeId,
Expand Down Expand Up @@ -447,31 +442,6 @@ function getBackgroundTaskActivityKey(messages: readonly Message[]): string {
return parts.join('|');
}

function getFloatingTodos(messages: readonly Message[]): TodoItem[] {
let todos: TodoItem[] | undefined;

for (const message of messages) {
if (message.role === 'plan') {
if (hasActiveTodos(message.todos)) {
todos = message.todos;
} else {
todos = [];
}
continue;
}
if (message.role !== 'tool_group') continue;

for (const tool of message.tools) {
const nextTodos = extractTodosFromToolCall(tool);
if (nextTodos) {
todos = hasActiveTodos(nextTodos) ? nextTodos : [];
}
}
}

return todos ?? [];
}

function translateCopyMessage(
message: string,
t: ReturnType<typeof getTranslator>,
Expand Down Expand Up @@ -661,20 +631,49 @@ export function App({
const pendingApprovalRef = useRef(pendingApproval);
pendingApprovalRef.current = pendingApproval;
const shouldHideComposer = pendingApproval !== null;
const rawFloatingTodos = useMemo(
const floatingTodosState = useMemo(
() => getFloatingTodos(messages),
[messages],
);
const floatingTodos = useStableArray(
rawFloatingTodos,
floatingTodosState.todos,
(t) => `${t.id}:${t.status}:${t.content}`,
);
const floatingTodosAllCompleted = floatingTodosState.allCompleted;
// The all-completed list is only shown as a transient "all done" moment
// when the panel was already visible live in this client; on session
// restore (catch-up replay) a historical finished list stays hidden.
// State is adjusted during render (not in an effect) so the
// active → completed transition doesn't unmount the panel for a frame.
const [todoPanelMode, setTodoPanelMode] = useState<
'hidden' | 'active' | 'completed'
>('hidden');
const nextTodoPanelMode =
connection.catchingUp || floatingTodos.length === 0
? 'hidden'
: !floatingTodosAllCompleted
? 'active'
: todoPanelMode === 'hidden'
? 'hidden'
: 'completed';
if (nextTodoPanelMode !== todoPanelMode) {
setTodoPanelMode(nextTodoPanelMode);
}
const showFloatingTodos = nextTodoPanelMode !== 'hidden';
const backgroundTaskActivityKey = useMemo(
() => getBackgroundTaskActivityKey(messages),
[messages],
);
const statusBarRef = useRef<StatusBarHandle>(null);
const editorRef = useRef<EditorHandle>(null);
const messageListRef = useRef<MessageListHandle>(null);
const handleLocateFloatingTodos = useCallback(() => {
if (!floatingTodosState.sourceMessageId) return;
messageListRef.current?.scrollToMessage(
floatingTodosState.sourceMessageId,
floatingTodosState.sourceCallId ?? undefined,
);
}, [floatingTodosState.sourceMessageId, floatingTodosState.sourceCallId]);
const [activeGoal, setActiveGoal] = useState<ActiveGoalStatus | null>(null);
const activeGoalRef = useRef<ActiveGoalStatus | null>(null);
activeGoalRef.current = activeGoal;
Expand Down Expand Up @@ -2501,13 +2500,14 @@ export function App({
<CompactModeContext.Provider value={compactMode}>
<div
className={
floatingTodos.length > 0
showFloatingTodos
? `${styles.content} ${styles.contentHasMessages}`
: styles.content
}
style={dialogOpen ? { visibility: 'hidden' } : undefined}
>
<MessageList
ref={messageListRef}
messages={displayMessages}
pendingApproval={pendingApproval}
onConfirm={handleConfirm}
Expand Down Expand Up @@ -2640,9 +2640,16 @@ export function App({
: styles.footer
}
>
{floatingTodos.length > 0 && !tasksPanelMessage && (
{showFloatingTodos && !tasksPanelMessage && (
<div className={styles.bottomPanels}>
<TodoPanel todos={floatingTodos} />
<TodoPanel
todos={floatingTodos}
onLocateSource={
floatingTodosState.sourceMessageId
? handleLocateFloatingTodos
: undefined
}
/>
</div>
)}
{!shouldHideComposer && (
Expand Down
15 changes: 15 additions & 0 deletions packages/web-shell/client/components/MessageList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,18 @@
background: var(--border-color);
border-radius: 3px;
}

.rowFlash {
border-radius: 8px;
animation: row-flash 1.6s ease-out;
}

@keyframes row-flash {
0%,
30% {
background: color-mix(in srgb, var(--accent-color) 16%, transparent);
}
100% {
background: transparent;
}
}
39 changes: 39 additions & 0 deletions packages/web-shell/client/components/MessageList.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { Message } from '../adapters/types';
import {
findDisplayItemIndex,
getDisplayItemVirtualKey,
groupParallelAgents,
shouldUseVirtualScroll,
Expand Down Expand Up @@ -255,3 +256,41 @@ describe('shouldUseVirtualScroll', () => {
expect(shouldUseVirtualScroll(51, 50)).toBe(true);
});
});

describe('findDisplayItemIndex', () => {
it('finds a row by message id', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeUserMessage('u2'),
]);
expect(findDisplayItemIndex(items, 'g1')).toBe(1);
expect(findDisplayItemIndex(items, 'missing')).toBe(-1);
});

it('falls back to the call id when the message id was merged away', () => {
// Simulates compact mode, where consecutive tool groups collapse into
// the first group's message id.
const merged: Message = {
id: 'g1',
role: 'tool_group',
tools: [
{ callId: 'call-a', toolName: 'Read', status: 'completed' },
{ callId: 'call-b', toolName: 'TodoWrite', status: 'completed' },
],
};
const items = groupParallelAgents([makeUserMessage('u1'), merged]);
expect(findDisplayItemIndex(items, 'g2', 'call-b')).toBe(1);
expect(findDisplayItemIndex(items, 'g2', 'call-x')).toBe(-1);
});

it('finds tool calls grouped into a parallel agents row', () => {
const items = groupParallelAgents([
makeAgentToolGroup('a1'),
makeAgentToolGroup('a2'),
]);
expect(items).toHaveLength(1);
expect(items[0].type).toBe('parallel_agents');
expect(findDisplayItemIndex(items, 'a2', 'call-a2')).toBe(0);
});
});
Loading
Loading