From 9ef25c95171289bc1a786d8d03c7cc99f7f2f54d Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 6 Mar 2026 20:31:14 +0200 Subject: [PATCH] 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. --- package.json | 1 + pnpm-lock.yaml | 10 + src/main/ipc/teams.ts | 5 +- .../infrastructure/NotificationManager.ts | 5 +- src/main/utils/textFormatting.ts | 16 + src/renderer/components/layout/TabBar.tsx | 22 ++ .../components/sidebar/GlobalTaskList.tsx | 292 +++++++++++---- .../components/sidebar/TaskFiltersPopover.tsx | 5 + .../components/sidebar/taskFiltersState.ts | 12 +- .../components/team/CliLogsRichView.tsx | 148 +++++++- src/renderer/components/team/TeamListView.tsx | 11 - .../team/activity/LeadThoughtsGroup.tsx | 345 ++++++++++++++---- src/renderer/hooks/useCollapsedGroups.ts | 71 ++++ src/renderer/utils/streamJsonParser.ts | 100 +++++ .../utils/toolRendering/toolSummaryHelpers.ts | 5 + 15 files changed, 869 insertions(+), 179 deletions(-) create mode 100644 src/main/utils/textFormatting.ts create mode 100644 src/renderer/hooks/useCollapsedGroups.ts diff --git a/package.json b/package.json index 4db5dd7d..446a4711 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80ad3475..3d4fa02b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 85f36da7..b855253b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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 } : {}), diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index d9427ce8..4e03fc6b 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -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 } : {}), diff --git a/src/main/utils/textFormatting.ts b/src/main/utils/textFormatting.ts new file mode 100644 index 00000000..c22f6d34 --- /dev/null +++ b/src/main/utils/textFormatting.ts @@ -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(); +} diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 5b59053c..6f8a167d 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -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 => { + {/* GitHub link */} + + {/* Settings gear icon */} )} + + + + + +
+ {SORT_OPTIONS.map((opt) => ( + + ))} +
+
+
{ if (group.tasks.length === 0) return null; + const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); let lastTeam: string | null = null; return (
-
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 ? ( + + ) : ( + + )} - + {group.projectLabel} -
- {group.tasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - 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)} - > - + {group.tasks.length} + + + {!isGroupCollapsed && + group.tasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + - 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) } - /> - -
- ); - })} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> +
+
+ ); + })}
); })} @@ -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 (
-
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} -
+ {isGroupCollapsed ? ( + + ) : ( + + )} + {dateCategoryLabels[category] ?? category} + + {tasks.length} + + - {tasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; + {!isGroupCollapsed && + tasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - 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)} - > - + {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + - 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) } - /> - -
- ); - })} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> + +
+ ); + })} ); })} diff --git a/src/renderer/components/sidebar/TaskFiltersPopover.tsx b/src/renderer/components/sidebar/TaskFiltersPopover.tsx index 5fe94de2..f0f48152 100644 --- a/src/renderer/components/sidebar/TaskFiltersPopover.tsx +++ b/src/renderer/components/sidebar/TaskFiltersPopover.tsx @@ -81,6 +81,11 @@ export const TaskFiltersPopover = ({ toggleStatus(opt.id)} + style={{ '--color-accent': opt.color } as React.CSSProperties} + /> + {opt.label} diff --git a/src/renderer/components/sidebar/taskFiltersState.ts b/src/renderer/components/sidebar/taskFiltersState.ts index 3ef596fd..34a1a466 100644 --- a/src/renderer/components/sidebar/taskFiltersState.ts +++ b/src/renderer/components/sidebar/taskFiltersState.ts @@ -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 { diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 986a763b..1cfa5805 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -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; + onGroupToggle: (groupId: string) => void; + expandedItemIds: Set; + onItemClick: (itemId: string) => void; + searchQueryOverride?: string; +}): React.JSX.Element => { + const label = `Agent — ${section.description} (${section.toolCount} tool${section.toolCount !== 1 ? 's' : ''})`; + + return ( +
+ + {isExpanded && ( +
+ {section.groups.map((group) => + group.items.length === 1 ? ( + + ) : ( + onGroupToggle(group.id)} + expandedItemIds={expandedItemIds} + onItemClick={onItemClick} + searchQueryOverride={searchQueryOverride} + /> + ) + )} +
+ )} +
+ ); +}; + 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>(new Set()); const [expandedItemIds, setExpandedItemIds] = useState>(new Set()); + // Subagent sections are collapsed by default; track which are expanded + const [expandedSubagentIds, setExpandedSubagentIds] = useState>(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(); - 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 (
- {visibleGroups.map((group) => - group.items.length === 1 ? ( - // Single item — render flat without collapsible group wrapper + {visibleEntries.map((entry) => + entry.type === 'subagent-section' ? ( + handleSubagentToggle(entry.section.id)} + collapsedGroupIds={collapsedGroupIds} + onGroupToggle={handleGroupToggle} + expandedItemIds={expandedItemIds} + onItemClick={handleItemClick} + searchQueryOverride={searchQueryOverride} + /> + ) : entry.group.items.length === 1 ? ( ) : ( 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} diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 9633d7af..4b9ccf4b 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -553,17 +553,6 @@ export const TeamListView = (): React.JSX.Element => { > Create Team -
{!canCreate ? ( diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 9e247eed..bbfd6a1a 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -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 {toolSummary ?? ''}; }; +interface LeadThoughtItemProps { + thought: InboxMessage; + showDivider: boolean; + shouldAnimate: boolean; +} + +const LeadThoughtItem = ({ + thought, + showDivider, + shouldAnimate, +}: LeadThoughtItemProps): JSX.Element => { + const wrapperRef = useRef(null); + const contentRef = useRef(null); + const previousHeightRef = useRef(null); + const animationFrameRef = useRef(null); + const cleanupTimerRef = useRef(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 ( +
+
+ {showDivider && ( +
+
+ + {formatTimeWithSec(thought.timestamp)} + +
+
+ )} +
+
+ +
+
+ {thought.toolSummary && ( + + +
+ 🔧 {thought.toolSummary} +
+
+ + + +
+ )} +
+
+ ); +}; + export const LeadThoughtsGroupRow = ({ group, memberColor, @@ -170,6 +346,7 @@ export const LeadThoughtsGroupRow = ({ }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); + const contentRef = useRef(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 = ({ )} - {/* Scrollable body — fixed height, always visible */} + {/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
- {chronologicalThoughts.map((thought, idx) => ( -
- {idx > 0 && ( -
-
- - {formatTimeWithSec(thought.timestamp)} - -
-
- )} -
-
- -
-
- {thought.toolSummary && ( - - -
- 🔧 {thought.toolSummary} -
-
- - - -
- )} -
- ))} +
+ {chronologicalThoughts.map((thought, idx) => ( + 0} + shouldAnimate={isLive && idx === chronologicalThoughts.length - 1} + /> + ))} +
+ {!expanded && needsTruncation ? ( +
+ +
+ ) : null} + {expanded && needsTruncation ? ( +
+ +
+ ) : null} ); }; diff --git a/src/renderer/hooks/useCollapsedGroups.ts b/src/renderer/hooks/useCollapsedGroups.ts new file mode 100644 index 00000000..c2c582a6 --- /dev/null +++ b/src/renderer/hooks/useCollapsedGroups.ts @@ -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 { + const set = new Set(); + 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>(() => + 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; +} diff --git a/src/renderer/utils/streamJsonParser.ts b/src/renderer/utils/streamJsonParser.ts index 8fd71bfb..040e8332 100644 --- a/src/renderer/utils/streamJsonParser.ts +++ b/src/renderer/utils/streamJsonParser.ts @@ -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(); @@ -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).agentId === 'string' + ? ((parsed as Record).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(); + const sectionCountByAgent = new Map(); + 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 | 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; +} diff --git a/src/renderer/utils/toolRendering/toolSummaryHelpers.ts b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts index a130e44e..2b06119b 100644 --- a/src/renderer/utils/toolRendering/toolSummaryHelpers.ts +++ b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts @@ -248,6 +248,11 @@ export function getToolSummary(toolName: string, input: Record) 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);