feat: add strip-markdown dependency and integrate markdown stripping in notifications

- Added `strip-markdown` package to handle markdown formatting in plain-text contexts.
- Updated `teams.ts` and `NotificationManager.ts` to use `stripMarkdown` for truncating notification bodies and error messages, enhancing readability.
- Introduced `textFormatting.ts` utility for markdown stripping functionality.
- Enhanced `GlobalTaskList` component with sorting options and improved UI for task management.
- Implemented localStorage persistence for collapsed group states in `useCollapsedGroups` hook.
This commit is contained in:
iliya 2026-03-06 20:31:14 +02:00
parent 795e1248aa
commit 9ef25c9517
15 changed files with 869 additions and 179 deletions

View file

@ -132,6 +132,7 @@
"simple-git": "^3.32.3",
"ssh-config": "^5.0.4",
"ssh2": "^1.17.0",
"strip-markdown": "^6.0.0",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",

View file

@ -224,6 +224,9 @@ importers:
ssh2:
specifier: ^1.17.0
version: 1.17.0
strip-markdown:
specifier: ^6.0.0
version: 6.0.0
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
@ -6046,6 +6049,9 @@ packages:
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
strip-markdown@6.0.0:
resolution: {integrity: sha512-mSa8FtUoX3ExJYDkjPUTC14xaBAn4Ik5GPQD45G5E2egAmeV3kHgVSTfIoSDggbF6Pk9stahVgqsLCNExv6jHw==}
strtok3@10.3.4:
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
engines: {node: '>=18'}
@ -13548,6 +13554,10 @@ snapshots:
dependencies:
js-tokens: 9.0.1
strip-markdown@6.0.0:
dependencies:
'@types/mdast': 4.0.4
strtok3@10.3.4:
dependencies:
'@tokenizer/token': 0.3.0

View file

@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
import { getAppIconPath } from '@main/utils/appIcon';
import { stripMarkdown } from '@main/utils/textFormatting';
import {
TEAM_ADD_MEMBER,
TEAM_ADD_TASK_COMMENT,
@ -1971,8 +1972,8 @@ export function showTeamNativeNotification(opts: {
}
const isMac = process.platform === 'darwin';
const truncatedBody = opts.body.slice(0, 300);
const iconPath = getAppIconPath();
const truncatedBody = stripMarkdown(opts.body).slice(0, 300);
const iconPath = isMac ? undefined : getAppIconPath();
const notification = new Notification({
title: opts.title,
...(isMac && opts.subtitle ? { subtitle: opts.subtitle } : {}),

View file

@ -14,6 +14,7 @@
import { getAppIconPath } from '@main/utils/appIcon';
import { getHomeDir } from '@main/utils/pathDecoder';
import { stripMarkdown } from '@main/utils/textFormatting';
import { createLogger } from '@shared/utils/logger';
import { type BrowserWindow, Notification } from 'electron';
import { EventEmitter } from 'events';
@ -398,8 +399,8 @@ export class NotificationManager extends EventEmitter {
const config = this.configManager.getConfig();
const isMac = process.platform === 'darwin';
const truncatedMessage = error.message.slice(0, 200);
const iconPath = getAppIconPath();
const truncatedMessage = stripMarkdown(error.message).slice(0, 200);
const iconPath = isMac ? undefined : getAppIconPath();
const notification = new Notification({
title: 'Claude Code Error',
...(isMac ? { subtitle: error.context.projectName } : {}),

View file

@ -0,0 +1,16 @@
import remarkParse from 'remark-parse';
import stripMarkdownPlugin from 'strip-markdown';
import { unified } from 'unified';
const processor = unified().use(remarkParse).use(stripMarkdownPlugin);
/**
* Strips markdown formatting from text for use in plain-text contexts
* like native OS notifications.
*
* Uses remark ecosystem (strip-markdown plugin) for reliable parsing.
*/
export function stripMarkdown(text: string): string {
const result = processor.processSync(text);
return String(result).trim();
}

View file

@ -104,6 +104,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
const [newTabHover, setNewTabHover] = useState(false);
const [notificationsHover, setNotificationsHover] = useState(false);
const [teamsHover, setTeamsHover] = useState(false);
const [githubHover, setGithubHover] = useState(false);
const [settingsHover, setSettingsHover] = useState(false);
// Context menu state
@ -415,6 +416,27 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
<Users className="size-4" />
</button>
{/* GitHub link */}
<button
onClick={() =>
void window.electronAPI.openExternal(
'https://github.com/777genius/claude_agent_teams_ui'
)
}
onMouseEnter={() => setGithubHover(true)}
onMouseLeave={() => setGithubHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: githubHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: githubHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="GitHub"
>
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z" />
</svg>
</button>
{/* Settings gear icon */}
<button
onClick={() => openSettingsTab()}

View file

@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups';
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
@ -13,10 +14,21 @@ import {
groupTasksByProject,
sortTasksByFreshness,
} from '@renderer/utils/taskGrouping';
import { Archive, ListTodo, Pin, Search, X } from 'lucide-react';
import {
Archive,
ArrowUpDown,
Check,
ChevronDown,
ChevronRight,
ListTodo,
Pin,
Search,
X,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Combobox, type ComboboxOption } from '../ui/combobox';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { SidebarTaskItem } from './SidebarTaskItem';
import { TaskContextMenu } from './TaskContextMenu';
@ -53,6 +65,58 @@ function saveGroupingMode(mode: TaskGroupingMode): void {
}
}
export type TaskSortMode = 'time' | 'project' | 'team';
const TASK_SORT_STORAGE_KEY = 'sidebarTasksSort';
const SORT_OPTIONS: { id: TaskSortMode; label: string }[] = [
{ id: 'time', label: 'By time' },
{ id: 'project', label: 'By project' },
{ id: 'team', label: 'By team' },
];
function loadSortMode(): TaskSortMode {
try {
const v = localStorage.getItem(TASK_SORT_STORAGE_KEY);
if (v === 'time' || v === 'project' || v === 'team') return v;
} catch {
/* ignore */
}
return 'time';
}
function saveSortMode(mode: TaskSortMode): void {
try {
localStorage.setItem(TASK_SORT_STORAGE_KEY, mode);
} catch {
/* ignore */
}
}
function applySortMode(tasks: GlobalTask[], mode: TaskSortMode): GlobalTask[] {
const sorted = [...tasks];
switch (mode) {
case 'time':
return sortTasksByFreshness(sorted);
case 'project':
return sorted.sort((a, b) => {
const pa = a.projectPath ?? '';
const pb = b.projectPath ?? '';
const cmp = pa.localeCompare(pb);
if (cmp !== 0) return cmp;
return (b.updatedAt ?? b.createdAt ?? '').localeCompare(a.updatedAt ?? a.createdAt ?? '');
});
case 'team':
return sorted.sort((a, b) => {
const cmp = a.teamDisplayName.localeCompare(b.teamDisplayName);
if (cmp !== 0) return cmp;
return (b.updatedAt ?? b.createdAt ?? '').localeCompare(a.updatedAt ?? a.createdAt ?? '');
});
default:
return sortTasksByFreshness(sorted);
}
}
export interface GlobalTaskListProps {
/** When true, do not render the header row (Tasks + Filters); parent renders tabs and filters. */
hideHeader?: boolean;
@ -124,6 +188,8 @@ export const GlobalTaskList = ({
const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen;
const [searchQuery, setSearchQuery] = useState('');
const [groupingMode, setGroupingModeState] = useState<TaskGroupingMode>(loadGroupingMode);
const [sortMode, setSortModeState] = useState<TaskSortMode>(loadSortMode);
const [sortPopoverOpen, setSortPopoverOpen] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@ -139,6 +205,11 @@ export const GlobalTaskList = ({
saveGroupingMode(mode);
};
const setSortMode = (mode: TaskSortMode): void => {
setSortModeState(mode);
saveSortMode(mode);
};
const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => {
taskLocalState.renameTask(teamName, taskId, newSubject);
setRenamingTaskKey(null);
@ -265,11 +336,21 @@ export const GlobalTaskList = ({
[filtered, taskLocalState]
);
const sortedFlat = useMemo(() => sortTasksByFreshness(normalTasks), [normalTasks]);
const sortedFlat = useMemo(() => applySortMode(normalTasks, sortMode), [normalTasks, sortMode]);
const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]);
const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]);
const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]);
// Collapsed group keys for each grouping mode
const projectGroupKeys = useMemo(
() => projectGroups.filter((g) => g.tasks.length > 0).map((g) => g.projectKey),
[projectGroups]
);
const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]);
const projectCollapsed = useCollapsedGroups('project', projectGroupKeys);
const timeCollapsed = useCollapsedGroups('time', timeGroupKeys);
const hasContent =
pinnedTasks.length > 0 ||
(groupingMode === 'none'
@ -315,6 +396,44 @@ export const GlobalTaskList = ({
<X className="size-3" />
</button>
)}
<Popover open={sortPopoverOpen} onOpenChange={setSortPopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex shrink-0 items-center justify-center rounded p-0.5 text-text-muted transition-colors hover:text-text-secondary data-[state=open]:bg-surface-raised data-[state=open]:text-text"
>
<ArrowUpDown className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="end" sideOffset={6}>
<div className="flex flex-col">
{SORT_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => {
setSortMode(opt.id);
setSortPopoverOpen(false);
}}
className={cn(
'flex items-center gap-2 rounded px-2 py-1.5 text-[12px] transition-colors',
sortMode === opt.id
? 'bg-surface-raised text-text'
: 'hover:bg-surface-raised/60 text-text-secondary hover:text-text'
)}
>
<Check
className={cn(
'size-3 shrink-0',
sortMode === opt.id ? 'opacity-100' : 'opacity-0'
)}
/>
{opt.label}
</button>
))}
</div>
</PopoverContent>
</Popover>
<TaskFiltersPopover
open={filtersPopoverOpen}
onOpenChange={setFiltersPopoverOpen}
@ -469,54 +588,71 @@ export const GlobalTaskList = ({
{groupingMode === 'project' &&
projectGroups.map((group) => {
if (group.tasks.length === 0) return null;
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
let lastTeam: string | null = null;
return (
<div key={group.projectKey}>
<div
className="sticky top-0 z-10 flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold"
<button
type="button"
onClick={() => projectCollapsed.toggle(group.projectKey)}
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1 px-2 py-1.5 text-[11px] font-semibold transition-colors"
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
>
{isGroupCollapsed ? (
<ChevronRight className="size-3 shrink-0 text-text-muted" />
) : (
<ChevronDown className="size-3 shrink-0 text-text-muted" />
)}
<span
className="inline-block size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: projectColor(group.projectLabel).border }}
/>
<span style={{ color: projectColor(group.projectLabel).text }}>
<span
className="truncate"
style={{ color: projectColor(group.projectLabel).text }}
>
{group.projectLabel}
</span>
</div>
{group.tasks.map((task) => {
const showTeamHeader = task.teamName !== lastTeam;
lastTeam = task.teamName;
return (
<div key={`${task.teamName}-${task.id}`}>
{showTeamHeader && (
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
Team: {task.teamDisplayName}
</div>
)}
<TaskContextMenu
task={task}
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)}
>
<SidebarTaskItem
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
{group.tasks.length}
</span>
</button>
{!isGroupCollapsed &&
group.tasks.map((task) => {
const showTeamHeader = task.teamName !== lastTeam;
lastTeam = task.teamName;
return (
<div key={`${task.teamName}-${task.id}`}>
{showTeamHeader && (
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
Team: {task.teamDisplayName}
</div>
)}
<TaskContextMenu
task={task}
hideTeamName
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}
getDisplaySubject={(t) =>
taskLocalState.getRenamedSubject(t.teamName, t.id)
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() =>
taskLocalState.toggleArchive(task.teamName, task.id)
}
/>
</TaskContextMenu>
</div>
);
})}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)}
>
<SidebarTaskItem
task={task}
hideTeamName
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}
getDisplaySubject={(t) =>
taskLocalState.getRenamedSubject(t.teamName, t.id)
}
/>
</TaskContextMenu>
</div>
);
})}
</div>
);
})}
@ -524,50 +660,64 @@ export const GlobalTaskList = ({
{groupingMode === 'time' &&
categories.map((category) => {
const tasks = grouped[category];
const isGroupCollapsed = timeCollapsed.isCollapsed(category);
let lastTeam: string | null = null;
return (
<div key={category}>
<div
className="sticky top-0 z-10 px-3 py-1.5 text-[11px] font-semibold text-text-secondary"
<button
type="button"
onClick={() => timeCollapsed.toggle(category)}
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1 px-2 py-1.5 text-[11px] font-semibold text-text-secondary transition-colors"
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
>
{dateCategoryLabels[category] ?? category}
</div>
{isGroupCollapsed ? (
<ChevronRight className="size-3 shrink-0 text-text-muted" />
) : (
<ChevronDown className="size-3 shrink-0 text-text-muted" />
)}
<span className="truncate">{dateCategoryLabels[category] ?? category}</span>
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
{tasks.length}
</span>
</button>
{tasks.map((task) => {
const showTeamHeader = task.teamName !== lastTeam;
lastTeam = task.teamName;
{!isGroupCollapsed &&
tasks.map((task) => {
const showTeamHeader = task.teamName !== lastTeam;
lastTeam = task.teamName;
return (
<div key={`${task.teamName}-${task.id}`}>
{showTeamHeader && (
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
Team: {task.teamDisplayName}
</div>
)}
<TaskContextMenu
task={task}
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)}
>
<SidebarTaskItem
return (
<div key={`${task.teamName}-${task.id}`}>
{showTeamHeader && (
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
Team: {task.teamDisplayName}
</div>
)}
<TaskContextMenu
task={task}
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}
getDisplaySubject={(t) =>
taskLocalState.getRenamedSubject(t.teamName, t.id)
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() =>
taskLocalState.toggleArchive(task.teamName, task.id)
}
/>
</TaskContextMenu>
</div>
);
})}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)}
>
<SidebarTaskItem
task={task}
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}
getDisplaySubject={(t) =>
taskLocalState.getRenamedSubject(t.teamName, t.id)
}
/>
</TaskContextMenu>
</div>
);
})}
</div>
);
})}

View file

@ -81,6 +81,11 @@ export const TaskFiltersPopover = ({
<Checkbox
checked={filters.statusIds.has(opt.id)}
onCheckedChange={() => toggleStatus(opt.id)}
style={{ '--color-accent': opt.color } as React.CSSProperties}
/>
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: opt.color }}
/>
{opt.label}
</label>

View file

@ -4,12 +4,12 @@ import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/comme
export type TaskStatusFilterId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
export const STATUS_OPTIONS: { id: TaskStatusFilterId; label: string }[] = [
{ id: 'todo', label: 'TODO' },
{ id: 'in_progress', label: 'IN PROGRESS' },
{ id: 'done', label: 'DONE' },
{ id: 'review', label: 'REVIEW' },
{ id: 'approved', label: 'APPROVED' },
export const STATUS_OPTIONS: { id: TaskStatusFilterId; label: string; color: string }[] = [
{ id: 'todo', label: 'TODO', color: '#3b82f6' },
{ id: 'in_progress', label: 'IN PROGRESS', color: '#eab308' },
{ id: 'done', label: 'DONE', color: '#22c55e' },
{ id: 'review', label: 'REVIEW', color: '#8b5cf6' },
{ id: 'approved', label: 'APPROVED', color: '#16a34a' },
];
export interface TaskFiltersState {

View file

@ -12,10 +12,14 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils';
import { cn } from '@renderer/lib/utils';
import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser';
import { parseStreamJsonToGroups, groupBySubagent } from '@renderer/utils/streamJsonParser';
import { Bot, ChevronRight } from 'lucide-react';
import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser';
import type {
StreamJsonGroup,
StreamJsonEntry,
SubagentSection,
} from '@renderer/utils/streamJsonParser';
type CliLogsOrder = 'oldest-first' | 'newest-first';
@ -149,6 +153,83 @@ const StreamGroup = ({
);
};
/**
* Collapsible section wrapping all groups from one subagent.
* Collapsed by default.
*/
const SubagentSectionBlock = ({
section,
isExpanded,
onToggle,
collapsedGroupIds,
onGroupToggle,
expandedItemIds,
onItemClick,
searchQueryOverride,
}: {
section: SubagentSection;
isExpanded: boolean;
onToggle: () => void;
collapsedGroupIds: Set<string>;
onGroupToggle: (groupId: string) => void;
expandedItemIds: Set<string>;
onItemClick: (itemId: string) => void;
searchQueryOverride?: string;
}): React.JSX.Element => {
const label = `Agent — ${section.description} (${section.toolCount} tool${section.toolCount !== 1 ? 's' : ''})`;
return (
<div className="rounded border border-l-2 border-amber-500/30 bg-[var(--color-surface)]">
<button
type="button"
className="flex w-full items-center gap-1.5 px-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
onClick={onToggle}
>
<ChevronRight
size={12}
className={cn(
'shrink-0 text-amber-400 transition-transform duration-150',
isExpanded && 'rotate-90'
)}
/>
<Bot size={13} className="shrink-0 text-amber-400" />
<span className="min-w-0 truncate text-[11px] text-amber-300/80">
{searchQueryOverride && searchQueryOverride.trim().length > 0
? highlightQueryInText(label, searchQueryOverride, `${section.id}:section-summary`, {
forceAllActive: true,
})
: label}
</span>
</button>
{isExpanded && (
<div className="space-y-1 border-t border-amber-500/20 p-1.5">
{section.groups.map((group) =>
group.items.length === 1 ? (
<FlatGroupItem
key={group.id}
group={group}
expandedItemIds={expandedItemIds}
onItemClick={onItemClick}
searchQueryOverride={searchQueryOverride}
/>
) : (
<StreamGroup
key={group.id}
group={group}
isExpanded={!collapsedGroupIds.has(group.id)}
onToggle={() => onGroupToggle(group.id)}
expandedItemIds={expandedItemIds}
onItemClick={onItemClick}
searchQueryOverride={searchQueryOverride}
/>
)
)}
</div>
)}
</div>
);
};
export const CliLogsRichView = ({
cliLogsTail,
order = 'oldest-first',
@ -163,19 +244,29 @@ export const CliLogsRichView = ({
// Tracks groups manually collapsed by user (default: all auto-expanded)
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(new Set());
// Subagent sections are collapsed by default; track which are expanded
const [expandedSubagentIds, setExpandedSubagentIds] = useState<Set<string>>(new Set());
const groups = useMemo(() => parseStreamJsonToGroups(cliLogsTail), [cliLogsTail]);
const entries = useMemo(() => groupBySubagent(groups), [groups]);
// Derive expanded state: all groups expanded unless manually collapsed
const expandedGroupIds = useMemo(() => {
const expanded = new Set<string>();
for (const group of groups) {
if (!collapsedGroupIds.has(group.id)) {
expanded.add(group.id);
const addGroups = (gs: StreamJsonGroup[]): void => {
for (const g of gs) {
if (!collapsedGroupIds.has(g.id)) expanded.add(g.id);
}
};
for (const entry of entries) {
if (entry.type === 'group') {
if (!collapsedGroupIds.has(entry.group.id)) expanded.add(entry.group.id);
} else {
addGroups(entry.section.groups);
}
}
return expanded;
}, [groups, collapsedGroupIds]);
}, [entries, collapsedGroupIds]);
const computeShouldStickToEdge = useCallback(
(el: HTMLDivElement): boolean => {
@ -235,7 +326,19 @@ export const CliLogsRichView = ({
});
}, []);
if (groups.length === 0) {
const handleSubagentToggle = useCallback((sectionId: string) => {
setExpandedSubagentIds((prev) => {
const next = new Set(prev);
if (next.has(sectionId)) {
next.delete(sectionId);
} else {
next.add(sectionId);
}
return next;
});
}, []);
if (entries.length === 0) {
// cliLogsTail has data but no parseable assistant messages — show raw text fallback
const hasContent = cliLogsTail.trim().length > 0;
return (
@ -271,7 +374,7 @@ export const CliLogsRichView = ({
);
}
const visibleGroups = order === 'newest-first' ? [...groups].reverse() : groups;
const visibleEntries = order === 'newest-first' ? [...entries].reverse() : entries;
return (
<div
@ -290,22 +393,33 @@ export const CliLogsRichView = ({
});
}}
>
{visibleGroups.map((group) =>
group.items.length === 1 ? (
// Single item — render flat without collapsible group wrapper
{visibleEntries.map((entry) =>
entry.type === 'subagent-section' ? (
<SubagentSectionBlock
key={entry.section.id}
section={entry.section}
isExpanded={expandedSubagentIds.has(entry.section.id)}
onToggle={() => handleSubagentToggle(entry.section.id)}
collapsedGroupIds={collapsedGroupIds}
onGroupToggle={handleGroupToggle}
expandedItemIds={expandedItemIds}
onItemClick={handleItemClick}
searchQueryOverride={searchQueryOverride}
/>
) : entry.group.items.length === 1 ? (
<FlatGroupItem
key={group.id}
group={group}
key={entry.group.id}
group={entry.group}
expandedItemIds={expandedItemIds}
onItemClick={handleItemClick}
searchQueryOverride={searchQueryOverride}
/>
) : (
<StreamGroup
key={group.id}
group={group}
isExpanded={expandedGroupIds.has(group.id)}
onToggle={() => handleGroupToggle(group.id)}
key={entry.group.id}
group={entry.group}
isExpanded={expandedGroupIds.has(entry.group.id)}
onToggle={() => handleGroupToggle(entry.group.id)}
expandedItemIds={expandedItemIds}
onItemClick={handleItemClick}
searchQueryOverride={searchQueryOverride}

View file

@ -553,17 +553,6 @@ export const TeamListView = (): React.JSX.Element => {
>
Create Team
</Button>
<Button
variant="outline"
size="sm"
disabled={teamsLoading}
onClick={() => {
void fetchTeams();
}}
>
{teamsLoading ? <RotateCcw className="size-3.5 animate-spin" /> : null}
Refresh
</Button>
</div>
</div>
{!canCreate ? (

View file

@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
@ -71,7 +73,9 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
const VIEWPORT_THRESHOLD = 0.15;
const LIVE_WINDOW_MS = 5_000;
const COLLAPSED_THOUGHTS_HEIGHT = 200;
const AUTO_SCROLL_THRESHOLD = 30;
const THOUGHT_HEIGHT_ANIMATION_MS = 220;
interface LeadThoughtsGroupRowProps {
group: LeadThoughtGroup;
@ -160,6 +164,178 @@ const ToolSummaryTooltipContent = ({
return <span>{toolSummary ?? ''}</span>;
};
interface LeadThoughtItemProps {
thought: InboxMessage;
showDivider: boolean;
shouldAnimate: boolean;
}
const LeadThoughtItem = ({
thought,
showDivider,
shouldAnimate,
}: LeadThoughtItemProps): JSX.Element => {
const wrapperRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const previousHeightRef = useRef<number | null>(null);
const animationFrameRef = useRef<number | null>(null);
const cleanupTimerRef = useRef<number | null>(null);
const clearPendingAnimation = useCallback(() => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (cleanupTimerRef.current !== null) {
window.clearTimeout(cleanupTimerRef.current);
cleanupTimerRef.current = null;
}
}, []);
const resetWrapperStyles = useCallback(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
wrapper.style.height = 'auto';
wrapper.style.opacity = '1';
wrapper.style.overflow = 'visible';
wrapper.style.transition = '';
}, []);
useLayoutEffect(() => {
const wrapper = wrapperRef.current;
const content = contentRef.current;
if (!wrapper || !content) return;
const animateHeight = (
targetHeight: number,
startHeight: number,
startOpacity: number
): void => {
clearPendingAnimation();
wrapper.style.transition = 'none';
wrapper.style.overflow = 'hidden';
wrapper.style.height = `${Math.max(startHeight, 0)}px`;
wrapper.style.opacity = `${startOpacity}`;
void wrapper.offsetHeight;
animationFrameRef.current = requestAnimationFrame(() => {
wrapper.style.transition = `height ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease, opacity ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease`;
wrapper.style.height = `${Math.max(targetHeight, 0)}px`;
wrapper.style.opacity = '1';
});
cleanupTimerRef.current = window.setTimeout(() => {
resetWrapperStyles();
cleanupTimerRef.current = null;
}, THOUGHT_HEIGHT_ANIMATION_MS + 40);
};
const syncHeight = (nextHeight: number, animateFromZero: boolean): void => {
const previousHeight = previousHeightRef.current;
previousHeightRef.current = nextHeight;
if (!shouldAnimate) {
resetWrapperStyles();
return;
}
if (previousHeight === null) {
if (nextHeight > 0 && animateFromZero) {
animateHeight(nextHeight, 0, 0);
} else {
resetWrapperStyles();
}
return;
}
if (Math.abs(nextHeight - previousHeight) < 1) return;
const renderedHeight = wrapper.getBoundingClientRect().height;
animateHeight(nextHeight, renderedHeight > 0 ? renderedHeight : previousHeight, 1);
};
syncHeight(content.getBoundingClientRect().height, true);
const observer = new ResizeObserver((entries) => {
const nextHeight = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height;
syncHeight(nextHeight, false);
});
observer.observe(content);
return () => {
observer.disconnect();
clearPendingAnimation();
resetWrapperStyles();
};
}, [clearPendingAnimation, resetWrapperStyles, shouldAnimate]);
useEffect(
() => () => {
clearPendingAnimation();
},
[clearPendingAnimation]
);
return (
<div ref={wrapperRef}>
<div ref={contentRef}>
{showDivider && (
<div className="mx-auto flex w-2/5 items-center justify-center gap-[5px] py-px">
<hr
className="flex-1 border-0"
style={{
height: '1px',
backgroundColor: 'var(--color-border-emphasis)',
}}
/>
<span className="shrink-0 font-mono text-[9px]" style={{ color: CARD_ICON_MUTED }}>
{formatTimeWithSec(thought.timestamp)}
</span>
<hr
className="flex-1 border-0"
style={{
height: '1px',
backgroundColor: 'var(--color-border-emphasis)',
}}
/>
</div>
)}
<div className="flex text-[11px]">
<div className="min-w-0 flex-1 [&_>div>div]:p-0" style={{ color: CARD_TEXT_LIGHT }}>
<MarkdownViewer
content={thought.text.replace(/\n/g, ' \n')}
maxHeight="max-h-none"
bare
/>
</div>
</div>
{thought.toolSummary && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="mb-[7px] cursor-default pb-0.5 pl-3 pr-1 font-mono text-[9px]"
style={{ color: CARD_ICON_MUTED }}
>
🔧 {thought.toolSummary}
</div>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-[420px] font-mono text-[11px]"
>
<ToolSummaryTooltipContent
toolCalls={thought.toolCalls}
toolSummary={thought.toolSummary}
/>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
};
export const LeadThoughtsGroupRow = ({
group,
memberColor,
@ -170,6 +346,7 @@ export const LeadThoughtsGroupRow = ({
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const isUserScrolledUpRef = useRef(false);
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
const leadActivity = useStore((s) => {
@ -227,6 +404,8 @@ export const LeadThoughtsGroupRow = ({
[canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
);
const [isLive, setIsLive] = useState(computeIsLive);
const [expanded, setExpanded] = useState(false);
const [needsTruncation, setNeedsTruncation] = useState(false);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap
@ -258,18 +437,57 @@ export const LeadThoughtsGroupRow = ({
return () => observer.disconnect();
}, [onVisible, thoughts]);
// Auto-scroll when new thoughts arrive
const syncScrollableBody = useCallback(
(forceScrollToBottom = false) => {
const scrollEl = scrollRef.current;
const contentEl = contentRef.current;
if (!scrollEl || !contentEl) return;
const nextNeedsTruncation = contentEl.scrollHeight > COLLAPSED_THOUGHTS_HEIGHT + 1;
setNeedsTruncation((prev) => (prev === nextNeedsTruncation ? prev : nextNeedsTruncation));
if (expanded) return;
if (!forceScrollToBottom && isUserScrolledUpRef.current) return;
scrollEl.scrollTop = scrollEl.scrollHeight;
},
[expanded]
);
useEffect(() => {
const el = scrollRef.current;
if (!el || isUserScrolledUpRef.current) return;
el.scrollTop = el.scrollHeight;
}, [chronologicalThoughts]);
const contentEl = contentRef.current;
if (!contentEl) return;
syncScrollableBody(true);
const observer = new ResizeObserver(() => {
syncScrollableBody();
});
observer.observe(contentEl);
return () => observer.disconnect();
}, [syncScrollableBody]);
const handleScroll = useCallback(() => {
if (expanded) return;
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD;
}, [expanded]);
const handleCollapse = useCallback(() => {
isUserScrolledUpRef.current = false;
setExpanded(false);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const scrollEl = scrollRef.current;
if (scrollEl) {
scrollEl.scrollTop = scrollEl.scrollHeight;
}
ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
});
}, []);
return (
@ -323,80 +541,67 @@ export const LeadThoughtsGroupRow = ({
)}
</div>
{/* Scrollable body — fixed height, always visible */}
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
<div
ref={scrollRef}
className="border-t"
style={{
borderColor: 'var(--color-border-subtle)',
maxHeight: '200px',
overflowY: 'scroll',
scrollbarWidth: 'thin',
scrollbarColor: 'var(--scrollbar-thumb) transparent',
maxHeight: expanded || !needsTruncation ? 'none' : `${COLLAPSED_THOUGHTS_HEIGHT}px`,
overflowY: expanded ? 'visible' : needsTruncation ? 'auto' : 'hidden',
scrollbarWidth: expanded || !needsTruncation ? undefined : 'thin',
scrollbarColor:
expanded || !needsTruncation ? undefined : 'var(--scrollbar-thumb) transparent',
overflowAnchor: 'none',
overscrollBehavior: 'contain',
}}
onScroll={handleScroll}
>
{chronologicalThoughts.map((thought, idx) => (
<div key={thought.messageId ?? idx} className="thought-expand-in">
{idx > 0 && (
<div className="mx-auto flex w-2/5 items-center justify-center gap-[5px] py-px">
<hr
className="flex-1 border-0"
style={{
height: '1px',
backgroundColor: 'var(--color-border-emphasis)',
}}
/>
<span
className="shrink-0 font-mono text-[9px]"
style={{ color: CARD_ICON_MUTED }}
>
{formatTimeWithSec(thought.timestamp)}
</span>
<hr
className="flex-1 border-0"
style={{
height: '1px',
backgroundColor: 'var(--color-border-emphasis)',
}}
/>
</div>
)}
<div className="flex text-[11px]">
<div className="min-w-0 flex-1 [&_>div>div]:p-0" style={{ color: CARD_TEXT_LIGHT }}>
<MarkdownViewer
content={thought.text.replace(/\n/g, ' \n')}
maxHeight="max-h-none"
bare
/>
</div>
</div>
{thought.toolSummary && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="cursor-default pb-0.5 pl-3 pr-1 font-mono text-[9px]"
style={{ color: CARD_ICON_MUTED }}
>
🔧 {thought.toolSummary}
</div>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-[420px] font-mono text-[11px]"
>
<ToolSummaryTooltipContent
toolCalls={thought.toolCalls}
toolSummary={thought.toolSummary}
/>
</TooltipContent>
</Tooltip>
)}
</div>
))}
<div ref={contentRef}>
{chronologicalThoughts.map((thought, idx) => (
<LeadThoughtItem
key={thought.messageId ?? idx}
thought={thought}
showDivider={idx > 0}
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
/>
))}
</div>
</div>
</article>
{!expanded && needsTruncation ? (
<div className="flex justify-center pt-1" style={{ transform: 'translateY(-20px)' }}>
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setExpanded(true);
}}
>
<ChevronDown size={12} />
Show more
</button>
</div>
) : null}
{expanded && needsTruncation ? (
<div
className="sticky bottom-0 z-10 flex justify-center pb-1 pt-2"
style={{ transform: 'translateY(-20px)' }}
>
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
handleCollapse();
}}
>
<ChevronUp size={12} />
Show less
</button>
</div>
) : null}
</div>
);
};

View file

@ -0,0 +1,71 @@
import { useCallback, useState } from 'react';
/**
* Manages collapsed/expanded state for group headers with localStorage persistence.
* Each grouping mode gets a unique prefix to avoid key collisions.
*/
const STORAGE_PREFIX = 'taskGroupCollapsed';
function storageKey(prefix: string, groupKey: string): string {
return `${STORAGE_PREFIX}:${prefix}:${groupKey}`;
}
function loadCollapsedSet(prefix: string, groupKeys: string[]): Set<string> {
const set = new Set<string>();
try {
for (const key of groupKeys) {
if (localStorage.getItem(storageKey(prefix, key)) === '1') {
set.add(key);
}
}
} catch {
/* ignore storage errors */
}
return set;
}
export function useCollapsedGroups(prefix: string, groupKeys: string[]) {
// Re-initialize when prefix or keys change
const [collapsed, setCollapsed] = useState<Set<string>>(() =>
loadCollapsedSet(prefix, groupKeys)
);
// Sync with new keys when they change (e.g. new projects appear)
// We use a key string to detect changes without deep comparison
const keysFingerprint = groupKeys.join('\0');
const [prevFingerprint, setPrevFingerprint] = useState(keysFingerprint);
const [prevPrefix, setPrevPrefix] = useState(prefix);
if (keysFingerprint !== prevFingerprint || prefix !== prevPrefix) {
setPrevFingerprint(keysFingerprint);
setPrevPrefix(prefix);
setCollapsed(loadCollapsedSet(prefix, groupKeys));
}
const isCollapsed = useCallback((groupKey: string) => collapsed.has(groupKey), [collapsed]);
const toggle = useCallback(
(groupKey: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
const key = storageKey(prefix, groupKey);
try {
if (next.has(groupKey)) {
next.delete(groupKey);
localStorage.removeItem(key);
} else {
next.add(groupKey);
localStorage.setItem(key, '1');
}
} catch {
/* ignore storage errors */
}
return next;
});
},
[prefix]
);
return { isCollapsed, toggle } as const;
}

View file

@ -21,8 +21,26 @@ export interface StreamJsonGroup {
summary: string;
/** Timestamp of first message in group */
timestamp: Date;
/** If set, this group belongs to a subagent (not the lead). */
agentId?: string;
}
/** A subagent section wrapping consecutive groups from the same agentId. */
export interface SubagentSection {
id: string;
agentId: string;
/** Human-readable description from the Agent tool_use that spawned this subagent */
description: string;
groups: StreamJsonGroup[];
toolCount: number;
timestamp: Date;
}
/** Union type for the final render list after subagent grouping. */
export type StreamJsonEntry =
| { type: 'group'; group: StreamJsonGroup }
| { type: 'subagent-section'; section: SubagentSection };
interface ContentBlock {
type: string;
text?: string;
@ -182,6 +200,7 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
let currentItems: AIGroupDisplayItem[] = [];
let currentTimestamp: Date | null = null;
let currentGroupId: string | null = null;
let currentAgentId: string | undefined = undefined;
// Track how many times each messageId has been seen to disambiguate duplicates
const msgIdOccurrences = new Map<string, number>();
@ -193,10 +212,12 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
items: currentItems,
summary: buildGroupSummary(currentItems),
timestamp: currentTimestamp,
agentId: currentAgentId,
});
currentItems = [];
currentTimestamp = null;
currentGroupId = null;
currentAgentId = undefined;
}
};
@ -227,6 +248,12 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
continue;
}
// Extract agentId from top-level (subagent messages have it, lead messages don't)
const lineAgentId =
typeof (parsed as Record<string, unknown>).agentId === 'string'
? ((parsed as Record<string, unknown>).agentId as string)
: undefined;
if (!currentTimestamp) {
// Use stable cached timestamp keyed by line content to survive re-parses
let ts = lineTimestampCache.get(trimmed);
@ -242,6 +269,7 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
currentTimestamp = ts;
}
if (!currentGroupId) {
currentAgentId = lineAgentId;
const msgId = extractAssistantMessageId(parsed);
if (msgId) {
const occurrence = msgIdOccurrences.get(msgId) ?? 0;
@ -262,3 +290,75 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
return groups;
}
/**
* Groups consecutive StreamJsonGroups by agentId into SubagentSections.
* Lead groups (no agentId) remain as individual entries.
* Must be called on chronological (oldest-first) groups.
*/
export function groupBySubagent(groups: StreamJsonGroup[]): StreamJsonEntry[] {
const result: StreamJsonEntry[] = [];
const pendingDescriptions: string[] = [];
const agentDescMap = new Map<string, string>();
const sectionCountByAgent = new Map<string, number>();
let currentRun: { agentId: string; groups: StreamJsonGroup[] } | null = null;
const flushRun = (): void => {
if (!currentRun) return;
const desc = agentDescMap.get(currentRun.agentId) ?? 'Subagent';
let toolCount = 0;
for (const g of currentRun.groups) {
for (const item of g.items) {
if (item.type === 'tool') toolCount++;
}
}
const count = sectionCountByAgent.get(currentRun.agentId) ?? 0;
sectionCountByAgent.set(currentRun.agentId, count + 1);
const idSuffix = count > 0 ? `-${count}` : '';
result.push({
type: 'subagent-section',
section: {
id: `subagent-section-${currentRun.agentId}${idSuffix}`,
agentId: currentRun.agentId,
description: desc,
groups: currentRun.groups,
toolCount,
timestamp: currentRun.groups[0].timestamp,
},
});
currentRun = null;
};
for (const group of groups) {
if (!group.agentId) {
// Lead group — check for Agent/Task tool_use and extract description
for (const item of group.items) {
if (item.type === 'tool' && (item.tool.name === 'Agent' || item.tool.name === 'Task')) {
const input = item.tool.input as Record<string, unknown> | undefined;
const desc =
(typeof input?.description === 'string' && input.description) ||
(typeof input?.prompt === 'string' && input.prompt.slice(0, 80)) ||
'Subagent';
pendingDescriptions.push(desc);
}
}
flushRun();
result.push({ type: 'group', group });
} else {
// Subagent group
if (!agentDescMap.has(group.agentId)) {
agentDescMap.set(group.agentId, pendingDescriptions.shift() ?? 'Subagent');
}
if (currentRun && currentRun.agentId === group.agentId) {
currentRun.groups.push(group);
} else {
flushRun();
currentRun = { agentId: group.agentId, groups: [group] };
}
}
}
flushRun();
return result;
}

View file

@ -248,6 +248,11 @@ export function getToolSummary(toolName: string, input: Record<string, unknown>)
case 'TeamDelete':
return 'Delete team';
case 'Agent': {
const desc = input.description ?? input.prompt;
return typeof desc === 'string' ? truncate(desc, 60) : 'Subagent';
}
default: {
// For unknown tools, try to extract a meaningful summary
const keys = Object.keys(input);