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:
parent
795e1248aa
commit
9ef25c9517
15 changed files with 869 additions and 179 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
16
src/main/utils/textFormatting.ts
Normal file
16
src/main/utils/textFormatting.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
71
src/renderer/hooks/useCollapsedGroups.ts
Normal file
71
src/renderer/hooks/useCollapsedGroups.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue