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",
|
"simple-git": "^3.32.3",
|
||||||
"ssh-config": "^5.0.4",
|
"ssh-config": "^5.0.4",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
|
"strip-markdown": "^6.0.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,9 @@ importers:
|
||||||
ssh2:
|
ssh2:
|
||||||
specifier: ^1.17.0
|
specifier: ^1.17.0
|
||||||
version: 1.17.0
|
version: 1.17.0
|
||||||
|
strip-markdown:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
|
|
@ -6046,6 +6049,9 @@ packages:
|
||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||||
|
|
||||||
|
strip-markdown@6.0.0:
|
||||||
|
resolution: {integrity: sha512-mSa8FtUoX3ExJYDkjPUTC14xaBAn4Ik5GPQD45G5E2egAmeV3kHgVSTfIoSDggbF6Pk9stahVgqsLCNExv6jHw==}
|
||||||
|
|
||||||
strtok3@10.3.4:
|
strtok3@10.3.4:
|
||||||
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
|
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -13548,6 +13554,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
|
strip-markdown@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/mdast': 4.0.4
|
||||||
|
|
||||||
strtok3@10.3.4:
|
strtok3@10.3.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tokenizer/token': 0.3.0
|
'@tokenizer/token': 0.3.0
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||||||
import { getAppIconPath } from '@main/utils/appIcon';
|
import { getAppIconPath } from '@main/utils/appIcon';
|
||||||
|
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||||
import {
|
import {
|
||||||
TEAM_ADD_MEMBER,
|
TEAM_ADD_MEMBER,
|
||||||
TEAM_ADD_TASK_COMMENT,
|
TEAM_ADD_TASK_COMMENT,
|
||||||
|
|
@ -1971,8 +1972,8 @@ export function showTeamNativeNotification(opts: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMac = process.platform === 'darwin';
|
const isMac = process.platform === 'darwin';
|
||||||
const truncatedBody = opts.body.slice(0, 300);
|
const truncatedBody = stripMarkdown(opts.body).slice(0, 300);
|
||||||
const iconPath = getAppIconPath();
|
const iconPath = isMac ? undefined : getAppIconPath();
|
||||||
const notification = new Notification({
|
const notification = new Notification({
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
...(isMac && opts.subtitle ? { subtitle: opts.subtitle } : {}),
|
...(isMac && opts.subtitle ? { subtitle: opts.subtitle } : {}),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
import { getAppIconPath } from '@main/utils/appIcon';
|
import { getAppIconPath } from '@main/utils/appIcon';
|
||||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||||
|
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { type BrowserWindow, Notification } from 'electron';
|
import { type BrowserWindow, Notification } from 'electron';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
|
@ -398,8 +399,8 @@ export class NotificationManager extends EventEmitter {
|
||||||
const config = this.configManager.getConfig();
|
const config = this.configManager.getConfig();
|
||||||
|
|
||||||
const isMac = process.platform === 'darwin';
|
const isMac = process.platform === 'darwin';
|
||||||
const truncatedMessage = error.message.slice(0, 200);
|
const truncatedMessage = stripMarkdown(error.message).slice(0, 200);
|
||||||
const iconPath = getAppIconPath();
|
const iconPath = isMac ? undefined : getAppIconPath();
|
||||||
const notification = new Notification({
|
const notification = new Notification({
|
||||||
title: 'Claude Code Error',
|
title: 'Claude Code Error',
|
||||||
...(isMac ? { subtitle: error.context.projectName } : {}),
|
...(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 [newTabHover, setNewTabHover] = useState(false);
|
||||||
const [notificationsHover, setNotificationsHover] = useState(false);
|
const [notificationsHover, setNotificationsHover] = useState(false);
|
||||||
const [teamsHover, setTeamsHover] = useState(false);
|
const [teamsHover, setTeamsHover] = useState(false);
|
||||||
|
const [githubHover, setGithubHover] = useState(false);
|
||||||
const [settingsHover, setSettingsHover] = useState(false);
|
const [settingsHover, setSettingsHover] = useState(false);
|
||||||
|
|
||||||
// Context menu state
|
// Context menu state
|
||||||
|
|
@ -415,6 +416,27 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
||||||
<Users className="size-4" />
|
<Users className="size-4" />
|
||||||
</button>
|
</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 */}
|
{/* Settings gear icon */}
|
||||||
<button
|
<button
|
||||||
onClick={() => openSettingsTab()}
|
onClick={() => openSettingsTab()}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups';
|
||||||
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
|
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
|
@ -13,10 +14,21 @@ import {
|
||||||
groupTasksByProject,
|
groupTasksByProject,
|
||||||
sortTasksByFreshness,
|
sortTasksByFreshness,
|
||||||
} from '@renderer/utils/taskGrouping';
|
} 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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { Combobox, type ComboboxOption } from '../ui/combobox';
|
import { Combobox, type ComboboxOption } from '../ui/combobox';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||||
|
|
||||||
import { SidebarTaskItem } from './SidebarTaskItem';
|
import { SidebarTaskItem } from './SidebarTaskItem';
|
||||||
import { TaskContextMenu } from './TaskContextMenu';
|
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 {
|
export interface GlobalTaskListProps {
|
||||||
/** When true, do not render the header row (Tasks + Filters); parent renders tabs and filters. */
|
/** When true, do not render the header row (Tasks + Filters); parent renders tabs and filters. */
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
|
|
@ -124,6 +188,8 @@ export const GlobalTaskList = ({
|
||||||
const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen;
|
const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen;
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [groupingMode, setGroupingModeState] = useState<TaskGroupingMode>(loadGroupingMode);
|
const [groupingMode, setGroupingModeState] = useState<TaskGroupingMode>(loadGroupingMode);
|
||||||
|
const [sortMode, setSortModeState] = useState<TaskSortMode>(loadSortMode);
|
||||||
|
const [sortPopoverOpen, setSortPopoverOpen] = useState(false);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
|
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -139,6 +205,11 @@ export const GlobalTaskList = ({
|
||||||
saveGroupingMode(mode);
|
saveGroupingMode(mode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setSortMode = (mode: TaskSortMode): void => {
|
||||||
|
setSortModeState(mode);
|
||||||
|
saveSortMode(mode);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => {
|
const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => {
|
||||||
taskLocalState.renameTask(teamName, taskId, newSubject);
|
taskLocalState.renameTask(teamName, taskId, newSubject);
|
||||||
setRenamingTaskKey(null);
|
setRenamingTaskKey(null);
|
||||||
|
|
@ -265,11 +336,21 @@ export const GlobalTaskList = ({
|
||||||
[filtered, taskLocalState]
|
[filtered, taskLocalState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortedFlat = useMemo(() => sortTasksByFreshness(normalTasks), [normalTasks]);
|
const sortedFlat = useMemo(() => applySortMode(normalTasks, sortMode), [normalTasks, sortMode]);
|
||||||
const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]);
|
const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]);
|
||||||
const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]);
|
const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]);
|
||||||
const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]);
|
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 =
|
const hasContent =
|
||||||
pinnedTasks.length > 0 ||
|
pinnedTasks.length > 0 ||
|
||||||
(groupingMode === 'none'
|
(groupingMode === 'none'
|
||||||
|
|
@ -315,6 +396,44 @@ export const GlobalTaskList = ({
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
</button>
|
</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
|
<TaskFiltersPopover
|
||||||
open={filtersPopoverOpen}
|
open={filtersPopoverOpen}
|
||||||
onOpenChange={setFiltersPopoverOpen}
|
onOpenChange={setFiltersPopoverOpen}
|
||||||
|
|
@ -469,54 +588,71 @@ export const GlobalTaskList = ({
|
||||||
{groupingMode === 'project' &&
|
{groupingMode === 'project' &&
|
||||||
projectGroups.map((group) => {
|
projectGroups.map((group) => {
|
||||||
if (group.tasks.length === 0) return null;
|
if (group.tasks.length === 0) return null;
|
||||||
|
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
|
||||||
let lastTeam: string | null = null;
|
let lastTeam: string | null = null;
|
||||||
return (
|
return (
|
||||||
<div key={group.projectKey}>
|
<div key={group.projectKey}>
|
||||||
<div
|
<button
|
||||||
className="sticky top-0 z-10 flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold"
|
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)' }}
|
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
|
<span
|
||||||
className="inline-block size-1.5 shrink-0 rounded-full"
|
className="inline-block size-1.5 shrink-0 rounded-full"
|
||||||
style={{ backgroundColor: projectColor(group.projectLabel).border }}
|
style={{ backgroundColor: projectColor(group.projectLabel).border }}
|
||||||
/>
|
/>
|
||||||
<span style={{ color: projectColor(group.projectLabel).text }}>
|
<span
|
||||||
|
className="truncate"
|
||||||
|
style={{ color: projectColor(group.projectLabel).text }}
|
||||||
|
>
|
||||||
{group.projectLabel}
|
{group.projectLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
|
||||||
{group.tasks.map((task) => {
|
{group.tasks.length}
|
||||||
const showTeamHeader = task.teamName !== lastTeam;
|
</span>
|
||||||
lastTeam = task.teamName;
|
</button>
|
||||||
return (
|
{!isGroupCollapsed &&
|
||||||
<div key={`${task.teamName}-${task.id}`}>
|
group.tasks.map((task) => {
|
||||||
{showTeamHeader && (
|
const showTeamHeader = task.teamName !== lastTeam;
|
||||||
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
|
lastTeam = task.teamName;
|
||||||
Team: {task.teamDisplayName}
|
return (
|
||||||
</div>
|
<div key={`${task.teamName}-${task.id}`}>
|
||||||
)}
|
{showTeamHeader && (
|
||||||
<TaskContextMenu
|
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
|
||||||
task={task}
|
Team: {task.teamDisplayName}
|
||||||
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
|
</div>
|
||||||
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
|
)}
|
||||||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
<TaskContextMenu
|
||||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
|
||||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
|
||||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
|
||||||
>
|
|
||||||
<SidebarTaskItem
|
|
||||||
task={task}
|
task={task}
|
||||||
hideTeamName
|
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
|
||||||
renamingKey={renamingTaskKey}
|
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
|
||||||
onRenameComplete={handleRenameComplete}
|
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||||
onRenameCancel={handleRenameCancel}
|
onToggleArchive={() =>
|
||||||
getDisplaySubject={(t) =>
|
taskLocalState.toggleArchive(task.teamName, task.id)
|
||||||
taskLocalState.getRenamedSubject(t.teamName, t.id)
|
|
||||||
}
|
}
|
||||||
/>
|
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||||
</TaskContextMenu>
|
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||||
</div>
|
>
|
||||||
);
|
<SidebarTaskItem
|
||||||
})}
|
task={task}
|
||||||
|
hideTeamName
|
||||||
|
renamingKey={renamingTaskKey}
|
||||||
|
onRenameComplete={handleRenameComplete}
|
||||||
|
onRenameCancel={handleRenameCancel}
|
||||||
|
getDisplaySubject={(t) =>
|
||||||
|
taskLocalState.getRenamedSubject(t.teamName, t.id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TaskContextMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -524,50 +660,64 @@ export const GlobalTaskList = ({
|
||||||
{groupingMode === 'time' &&
|
{groupingMode === 'time' &&
|
||||||
categories.map((category) => {
|
categories.map((category) => {
|
||||||
const tasks = grouped[category];
|
const tasks = grouped[category];
|
||||||
|
const isGroupCollapsed = timeCollapsed.isCollapsed(category);
|
||||||
let lastTeam: string | null = null;
|
let lastTeam: string | null = null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={category}>
|
<div key={category}>
|
||||||
<div
|
<button
|
||||||
className="sticky top-0 z-10 px-3 py-1.5 text-[11px] font-semibold text-text-secondary"
|
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)' }}
|
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
|
||||||
>
|
>
|
||||||
{dateCategoryLabels[category] ?? category}
|
{isGroupCollapsed ? (
|
||||||
</div>
|
<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) => {
|
{!isGroupCollapsed &&
|
||||||
const showTeamHeader = task.teamName !== lastTeam;
|
tasks.map((task) => {
|
||||||
lastTeam = task.teamName;
|
const showTeamHeader = task.teamName !== lastTeam;
|
||||||
|
lastTeam = task.teamName;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${task.teamName}-${task.id}`}>
|
<div key={`${task.teamName}-${task.id}`}>
|
||||||
{showTeamHeader && (
|
{showTeamHeader && (
|
||||||
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
|
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
|
||||||
Team: {task.teamDisplayName}
|
Team: {task.teamDisplayName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<TaskContextMenu
|
<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
|
|
||||||
task={task}
|
task={task}
|
||||||
renamingKey={renamingTaskKey}
|
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
|
||||||
onRenameComplete={handleRenameComplete}
|
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
|
||||||
onRenameCancel={handleRenameCancel}
|
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||||
getDisplaySubject={(t) =>
|
onToggleArchive={() =>
|
||||||
taskLocalState.getRenamedSubject(t.teamName, t.id)
|
taskLocalState.toggleArchive(task.teamName, task.id)
|
||||||
}
|
}
|
||||||
/>
|
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||||
</TaskContextMenu>
|
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||||
</div>
|
>
|
||||||
);
|
<SidebarTaskItem
|
||||||
})}
|
task={task}
|
||||||
|
renamingKey={renamingTaskKey}
|
||||||
|
onRenameComplete={handleRenameComplete}
|
||||||
|
onRenameCancel={handleRenameCancel}
|
||||||
|
getDisplaySubject={(t) =>
|
||||||
|
taskLocalState.getRenamedSubject(t.teamName, t.id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TaskContextMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,11 @@ export const TaskFiltersPopover = ({
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={filters.statusIds.has(opt.id)}
|
checked={filters.statusIds.has(opt.id)}
|
||||||
onCheckedChange={() => toggleStatus(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}
|
{opt.label}
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/comme
|
||||||
|
|
||||||
export type TaskStatusFilterId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
|
export type TaskStatusFilterId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
|
||||||
|
|
||||||
export const STATUS_OPTIONS: { id: TaskStatusFilterId; label: string }[] = [
|
export const STATUS_OPTIONS: { id: TaskStatusFilterId; label: string; color: string }[] = [
|
||||||
{ id: 'todo', label: 'TODO' },
|
{ id: 'todo', label: 'TODO', color: '#3b82f6' },
|
||||||
{ id: 'in_progress', label: 'IN PROGRESS' },
|
{ id: 'in_progress', label: 'IN PROGRESS', color: '#eab308' },
|
||||||
{ id: 'done', label: 'DONE' },
|
{ id: 'done', label: 'DONE', color: '#22c55e' },
|
||||||
{ id: 'review', label: 'REVIEW' },
|
{ id: 'review', label: 'REVIEW', color: '#8b5cf6' },
|
||||||
{ id: 'approved', label: 'APPROVED' },
|
{ id: 'approved', label: 'APPROVED', color: '#16a34a' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface TaskFiltersState {
|
export interface TaskFiltersState {
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,14 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
|
import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
|
||||||
import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils';
|
import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils';
|
||||||
import { cn } from '@renderer/lib/utils';
|
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 { 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';
|
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 = ({
|
export const CliLogsRichView = ({
|
||||||
cliLogsTail,
|
cliLogsTail,
|
||||||
order = 'oldest-first',
|
order = 'oldest-first',
|
||||||
|
|
@ -163,19 +244,29 @@ export const CliLogsRichView = ({
|
||||||
// Tracks groups manually collapsed by user (default: all auto-expanded)
|
// Tracks groups manually collapsed by user (default: all auto-expanded)
|
||||||
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
|
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
|
||||||
const [expandedItemIds, setExpandedItemIds] = 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 groups = useMemo(() => parseStreamJsonToGroups(cliLogsTail), [cliLogsTail]);
|
||||||
|
const entries = useMemo(() => groupBySubagent(groups), [groups]);
|
||||||
|
|
||||||
// Derive expanded state: all groups expanded unless manually collapsed
|
// Derive expanded state: all groups expanded unless manually collapsed
|
||||||
const expandedGroupIds = useMemo(() => {
|
const expandedGroupIds = useMemo(() => {
|
||||||
const expanded = new Set<string>();
|
const expanded = new Set<string>();
|
||||||
for (const group of groups) {
|
const addGroups = (gs: StreamJsonGroup[]): void => {
|
||||||
if (!collapsedGroupIds.has(group.id)) {
|
for (const g of gs) {
|
||||||
expanded.add(group.id);
|
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;
|
return expanded;
|
||||||
}, [groups, collapsedGroupIds]);
|
}, [entries, collapsedGroupIds]);
|
||||||
|
|
||||||
const computeShouldStickToEdge = useCallback(
|
const computeShouldStickToEdge = useCallback(
|
||||||
(el: HTMLDivElement): boolean => {
|
(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
|
// cliLogsTail has data but no parseable assistant messages — show raw text fallback
|
||||||
const hasContent = cliLogsTail.trim().length > 0;
|
const hasContent = cliLogsTail.trim().length > 0;
|
||||||
return (
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -290,22 +393,33 @@ export const CliLogsRichView = ({
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{visibleGroups.map((group) =>
|
{visibleEntries.map((entry) =>
|
||||||
group.items.length === 1 ? (
|
entry.type === 'subagent-section' ? (
|
||||||
// Single item — render flat without collapsible group wrapper
|
<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
|
<FlatGroupItem
|
||||||
key={group.id}
|
key={entry.group.id}
|
||||||
group={group}
|
group={entry.group}
|
||||||
expandedItemIds={expandedItemIds}
|
expandedItemIds={expandedItemIds}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
searchQueryOverride={searchQueryOverride}
|
searchQueryOverride={searchQueryOverride}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StreamGroup
|
<StreamGroup
|
||||||
key={group.id}
|
key={entry.group.id}
|
||||||
group={group}
|
group={entry.group}
|
||||||
isExpanded={expandedGroupIds.has(group.id)}
|
isExpanded={expandedGroupIds.has(entry.group.id)}
|
||||||
onToggle={() => handleGroupToggle(group.id)}
|
onToggle={() => handleGroupToggle(entry.group.id)}
|
||||||
expandedItemIds={expandedItemIds}
|
expandedItemIds={expandedItemIds}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
searchQueryOverride={searchQueryOverride}
|
searchQueryOverride={searchQueryOverride}
|
||||||
|
|
|
||||||
|
|
@ -553,17 +553,6 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
>
|
>
|
||||||
Create Team
|
Create Team
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={teamsLoading}
|
|
||||||
onClick={() => {
|
|
||||||
void fetchTeams();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{teamsLoading ? <RotateCcw className="size-3.5 animate-spin" /> : null}
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!canCreate ? (
|
{!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 { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
|
|
@ -71,7 +73,9 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
|
||||||
|
|
||||||
const VIEWPORT_THRESHOLD = 0.15;
|
const VIEWPORT_THRESHOLD = 0.15;
|
||||||
const LIVE_WINDOW_MS = 5_000;
|
const LIVE_WINDOW_MS = 5_000;
|
||||||
|
const COLLAPSED_THOUGHTS_HEIGHT = 200;
|
||||||
const AUTO_SCROLL_THRESHOLD = 30;
|
const AUTO_SCROLL_THRESHOLD = 30;
|
||||||
|
const THOUGHT_HEIGHT_ANIMATION_MS = 220;
|
||||||
|
|
||||||
interface LeadThoughtsGroupRowProps {
|
interface LeadThoughtsGroupRowProps {
|
||||||
group: LeadThoughtGroup;
|
group: LeadThoughtGroup;
|
||||||
|
|
@ -160,6 +164,178 @@ const ToolSummaryTooltipContent = ({
|
||||||
return <span>{toolSummary ?? ''}</span>;
|
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 = ({
|
export const LeadThoughtsGroupRow = ({
|
||||||
group,
|
group,
|
||||||
memberColor,
|
memberColor,
|
||||||
|
|
@ -170,6 +346,7 @@ export const LeadThoughtsGroupRow = ({
|
||||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const isUserScrolledUpRef = useRef(false);
|
const isUserScrolledUpRef = useRef(false);
|
||||||
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
|
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
|
||||||
const leadActivity = useStore((s) => {
|
const leadActivity = useStore((s) => {
|
||||||
|
|
@ -227,6 +404,8 @@ export const LeadThoughtsGroupRow = ({
|
||||||
[canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
|
[canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
|
||||||
);
|
);
|
||||||
const [isLive, setIsLive] = useState(computeIsLive);
|
const [isLive, setIsLive] = useState(computeIsLive);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [needsTruncation, setNeedsTruncation] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap
|
// 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();
|
return () => observer.disconnect();
|
||||||
}, [onVisible, thoughts]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const contentEl = contentRef.current;
|
||||||
if (!el || isUserScrolledUpRef.current) return;
|
if (!contentEl) return;
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
}, [chronologicalThoughts]);
|
syncScrollableBody(true);
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
syncScrollableBody();
|
||||||
|
});
|
||||||
|
observer.observe(contentEl);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [syncScrollableBody]);
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
|
if (expanded) return;
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD;
|
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 (
|
return (
|
||||||
|
|
@ -323,80 +541,67 @@ export const LeadThoughtsGroupRow = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable body — fixed height, always visible */}
|
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="border-t"
|
className="border-t"
|
||||||
style={{
|
style={{
|
||||||
borderColor: 'var(--color-border-subtle)',
|
borderColor: 'var(--color-border-subtle)',
|
||||||
maxHeight: '200px',
|
maxHeight: expanded || !needsTruncation ? 'none' : `${COLLAPSED_THOUGHTS_HEIGHT}px`,
|
||||||
overflowY: 'scroll',
|
overflowY: expanded ? 'visible' : needsTruncation ? 'auto' : 'hidden',
|
||||||
scrollbarWidth: 'thin',
|
scrollbarWidth: expanded || !needsTruncation ? undefined : 'thin',
|
||||||
scrollbarColor: 'var(--scrollbar-thumb) transparent',
|
scrollbarColor:
|
||||||
|
expanded || !needsTruncation ? undefined : 'var(--scrollbar-thumb) transparent',
|
||||||
|
overflowAnchor: 'none',
|
||||||
|
overscrollBehavior: 'contain',
|
||||||
}}
|
}}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
{chronologicalThoughts.map((thought, idx) => (
|
<div ref={contentRef}>
|
||||||
<div key={thought.messageId ?? idx} className="thought-expand-in">
|
{chronologicalThoughts.map((thought, idx) => (
|
||||||
{idx > 0 && (
|
<LeadThoughtItem
|
||||||
<div className="mx-auto flex w-2/5 items-center justify-center gap-[5px] py-px">
|
key={thought.messageId ?? idx}
|
||||||
<hr
|
thought={thought}
|
||||||
className="flex-1 border-0"
|
showDivider={idx > 0}
|
||||||
style={{
|
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
|
||||||
height: '1px',
|
/>
|
||||||
backgroundColor: 'var(--color-border-emphasis)',
|
))}
|
||||||
}}
|
</div>
|
||||||
/>
|
|
||||||
<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>
|
</div>
|
||||||
</article>
|
</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>
|
</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;
|
summary: string;
|
||||||
/** Timestamp of first message in group */
|
/** Timestamp of first message in group */
|
||||||
timestamp: Date;
|
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 {
|
interface ContentBlock {
|
||||||
type: string;
|
type: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
|
@ -182,6 +200,7 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
|
||||||
let currentItems: AIGroupDisplayItem[] = [];
|
let currentItems: AIGroupDisplayItem[] = [];
|
||||||
let currentTimestamp: Date | null = null;
|
let currentTimestamp: Date | null = null;
|
||||||
let currentGroupId: string | null = null;
|
let currentGroupId: string | null = null;
|
||||||
|
let currentAgentId: string | undefined = undefined;
|
||||||
// Track how many times each messageId has been seen to disambiguate duplicates
|
// Track how many times each messageId has been seen to disambiguate duplicates
|
||||||
const msgIdOccurrences = new Map<string, number>();
|
const msgIdOccurrences = new Map<string, number>();
|
||||||
|
|
||||||
|
|
@ -193,10 +212,12 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
|
||||||
items: currentItems,
|
items: currentItems,
|
||||||
summary: buildGroupSummary(currentItems),
|
summary: buildGroupSummary(currentItems),
|
||||||
timestamp: currentTimestamp,
|
timestamp: currentTimestamp,
|
||||||
|
agentId: currentAgentId,
|
||||||
});
|
});
|
||||||
currentItems = [];
|
currentItems = [];
|
||||||
currentTimestamp = null;
|
currentTimestamp = null;
|
||||||
currentGroupId = null;
|
currentGroupId = null;
|
||||||
|
currentAgentId = undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -227,6 +248,12 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
|
||||||
continue;
|
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) {
|
if (!currentTimestamp) {
|
||||||
// Use stable cached timestamp keyed by line content to survive re-parses
|
// Use stable cached timestamp keyed by line content to survive re-parses
|
||||||
let ts = lineTimestampCache.get(trimmed);
|
let ts = lineTimestampCache.get(trimmed);
|
||||||
|
|
@ -242,6 +269,7 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
|
||||||
currentTimestamp = ts;
|
currentTimestamp = ts;
|
||||||
}
|
}
|
||||||
if (!currentGroupId) {
|
if (!currentGroupId) {
|
||||||
|
currentAgentId = lineAgentId;
|
||||||
const msgId = extractAssistantMessageId(parsed);
|
const msgId = extractAssistantMessageId(parsed);
|
||||||
if (msgId) {
|
if (msgId) {
|
||||||
const occurrence = msgIdOccurrences.get(msgId) ?? 0;
|
const occurrence = msgIdOccurrences.get(msgId) ?? 0;
|
||||||
|
|
@ -262,3 +290,75 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
|
||||||
|
|
||||||
return groups;
|
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':
|
case 'TeamDelete':
|
||||||
return 'Delete team';
|
return 'Delete team';
|
||||||
|
|
||||||
|
case 'Agent': {
|
||||||
|
const desc = input.description ?? input.prompt;
|
||||||
|
return typeof desc === 'string' ? truncate(desc, 60) : 'Subagent';
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
// For unknown tools, try to extract a meaningful summary
|
// For unknown tools, try to extract a meaningful summary
|
||||||
const keys = Object.keys(input);
|
const keys = Object.keys(input);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue