From 9ef25c95171289bc1a786d8d03c7cc99f7f2f54d Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 6 Mar 2026 20:31:14 +0200 Subject: [PATCH 01/15] feat: add strip-markdown dependency and integrate markdown stripping in notifications - Added `strip-markdown` package to handle markdown formatting in plain-text contexts. - Updated `teams.ts` and `NotificationManager.ts` to use `stripMarkdown` for truncating notification bodies and error messages, enhancing readability. - Introduced `textFormatting.ts` utility for markdown stripping functionality. - Enhanced `GlobalTaskList` component with sorting options and improved UI for task management. - Implemented localStorage persistence for collapsed group states in `useCollapsedGroups` hook. --- package.json | 1 + pnpm-lock.yaml | 10 + src/main/ipc/teams.ts | 5 +- .../infrastructure/NotificationManager.ts | 5 +- src/main/utils/textFormatting.ts | 16 + src/renderer/components/layout/TabBar.tsx | 22 ++ .../components/sidebar/GlobalTaskList.tsx | 292 +++++++++++---- .../components/sidebar/TaskFiltersPopover.tsx | 5 + .../components/sidebar/taskFiltersState.ts | 12 +- .../components/team/CliLogsRichView.tsx | 148 +++++++- src/renderer/components/team/TeamListView.tsx | 11 - .../team/activity/LeadThoughtsGroup.tsx | 345 ++++++++++++++---- src/renderer/hooks/useCollapsedGroups.ts | 71 ++++ src/renderer/utils/streamJsonParser.ts | 100 +++++ .../utils/toolRendering/toolSummaryHelpers.ts | 5 + 15 files changed, 869 insertions(+), 179 deletions(-) create mode 100644 src/main/utils/textFormatting.ts create mode 100644 src/renderer/hooks/useCollapsedGroups.ts diff --git a/package.json b/package.json index 4db5dd7d..446a4711 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "simple-git": "^3.32.3", "ssh-config": "^5.0.4", "ssh2": "^1.17.0", + "strip-markdown": "^6.0.0", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", "unified": "^11.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80ad3475..3d4fa02b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,6 +224,9 @@ importers: ssh2: specifier: ^1.17.0 version: 1.17.0 + strip-markdown: + specifier: ^6.0.0 + version: 6.0.0 tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -6046,6 +6049,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strip-markdown@6.0.0: + resolution: {integrity: sha512-mSa8FtUoX3ExJYDkjPUTC14xaBAn4Ik5GPQD45G5E2egAmeV3kHgVSTfIoSDggbF6Pk9stahVgqsLCNExv6jHw==} + strtok3@10.3.4: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} engines: {node: '>=18'} @@ -13548,6 +13554,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strip-markdown@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + strtok3@10.3.4: dependencies: '@tokenizer/token': 0.3.0 diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 85f36da7..b855253b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor'; import { getAppIconPath } from '@main/utils/appIcon'; +import { stripMarkdown } from '@main/utils/textFormatting'; import { TEAM_ADD_MEMBER, TEAM_ADD_TASK_COMMENT, @@ -1971,8 +1972,8 @@ export function showTeamNativeNotification(opts: { } const isMac = process.platform === 'darwin'; - const truncatedBody = opts.body.slice(0, 300); - const iconPath = getAppIconPath(); + const truncatedBody = stripMarkdown(opts.body).slice(0, 300); + const iconPath = isMac ? undefined : getAppIconPath(); const notification = new Notification({ title: opts.title, ...(isMac && opts.subtitle ? { subtitle: opts.subtitle } : {}), diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index d9427ce8..4e03fc6b 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -14,6 +14,7 @@ import { getAppIconPath } from '@main/utils/appIcon'; import { getHomeDir } from '@main/utils/pathDecoder'; +import { stripMarkdown } from '@main/utils/textFormatting'; import { createLogger } from '@shared/utils/logger'; import { type BrowserWindow, Notification } from 'electron'; import { EventEmitter } from 'events'; @@ -398,8 +399,8 @@ export class NotificationManager extends EventEmitter { const config = this.configManager.getConfig(); const isMac = process.platform === 'darwin'; - const truncatedMessage = error.message.slice(0, 200); - const iconPath = getAppIconPath(); + const truncatedMessage = stripMarkdown(error.message).slice(0, 200); + const iconPath = isMac ? undefined : getAppIconPath(); const notification = new Notification({ title: 'Claude Code Error', ...(isMac ? { subtitle: error.context.projectName } : {}), diff --git a/src/main/utils/textFormatting.ts b/src/main/utils/textFormatting.ts new file mode 100644 index 00000000..c22f6d34 --- /dev/null +++ b/src/main/utils/textFormatting.ts @@ -0,0 +1,16 @@ +import remarkParse from 'remark-parse'; +import stripMarkdownPlugin from 'strip-markdown'; +import { unified } from 'unified'; + +const processor = unified().use(remarkParse).use(stripMarkdownPlugin); + +/** + * Strips markdown formatting from text for use in plain-text contexts + * like native OS notifications. + * + * Uses remark ecosystem (strip-markdown plugin) for reliable parsing. + */ +export function stripMarkdown(text: string): string { + const result = processor.processSync(text); + return String(result).trim(); +} diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 5b59053c..6f8a167d 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -104,6 +104,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { const [newTabHover, setNewTabHover] = useState(false); const [notificationsHover, setNotificationsHover] = useState(false); const [teamsHover, setTeamsHover] = useState(false); + const [githubHover, setGithubHover] = useState(false); const [settingsHover, setSettingsHover] = useState(false); // Context menu state @@ -415,6 +416,27 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { + {/* GitHub link */} + + {/* Settings gear icon */} )} + + + + + +
+ {SORT_OPTIONS.map((opt) => ( + + ))} +
+
+
{ if (group.tasks.length === 0) return null; + const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); let lastTeam: string | null = null; return (
-
projectCollapsed.toggle(group.projectKey)} + className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1 px-2 py-1.5 text-[11px] font-semibold transition-colors" style={{ backgroundColor: 'var(--color-surface-sidebar)' }} > + {isGroupCollapsed ? ( + + ) : ( + + )} - + {group.projectLabel} -
- {group.tasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - + {group.tasks.length} + + + {!isGroupCollapsed && + group.tasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + - taskLocalState.getRenamedSubject(t.teamName, t.id) + isPinned={taskLocalState.isPinned(task.teamName, task.id)} + isArchived={taskLocalState.isArchived(task.teamName, task.id)} + onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => + taskLocalState.toggleArchive(task.teamName, task.id) } - /> - -
- ); - })} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> +
+
+ ); + })}
); })} @@ -524,50 +660,64 @@ export const GlobalTaskList = ({ {groupingMode === 'time' && categories.map((category) => { const tasks = grouped[category]; + const isGroupCollapsed = timeCollapsed.isCollapsed(category); let lastTeam: string | null = null; return (
-
timeCollapsed.toggle(category)} + className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1 px-2 py-1.5 text-[11px] font-semibold text-text-secondary transition-colors" style={{ backgroundColor: 'var(--color-surface-sidebar)' }} > - {dateCategoryLabels[category] ?? category} -
+ {isGroupCollapsed ? ( + + ) : ( + + )} + {dateCategoryLabels[category] ?? category} + + {tasks.length} + + - {tasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; + {!isGroupCollapsed && + tasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - + {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + - taskLocalState.getRenamedSubject(t.teamName, t.id) + isPinned={taskLocalState.isPinned(task.teamName, task.id)} + isArchived={taskLocalState.isArchived(task.teamName, task.id)} + onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => + taskLocalState.toggleArchive(task.teamName, task.id) } - /> - -
- ); - })} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> + +
+ ); + })} ); })} diff --git a/src/renderer/components/sidebar/TaskFiltersPopover.tsx b/src/renderer/components/sidebar/TaskFiltersPopover.tsx index 5fe94de2..f0f48152 100644 --- a/src/renderer/components/sidebar/TaskFiltersPopover.tsx +++ b/src/renderer/components/sidebar/TaskFiltersPopover.tsx @@ -81,6 +81,11 @@ export const TaskFiltersPopover = ({ toggleStatus(opt.id)} + style={{ '--color-accent': opt.color } as React.CSSProperties} + /> + {opt.label} diff --git a/src/renderer/components/sidebar/taskFiltersState.ts b/src/renderer/components/sidebar/taskFiltersState.ts index 3ef596fd..34a1a466 100644 --- a/src/renderer/components/sidebar/taskFiltersState.ts +++ b/src/renderer/components/sidebar/taskFiltersState.ts @@ -4,12 +4,12 @@ import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/comme export type TaskStatusFilterId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved'; -export const STATUS_OPTIONS: { id: TaskStatusFilterId; label: string }[] = [ - { id: 'todo', label: 'TODO' }, - { id: 'in_progress', label: 'IN PROGRESS' }, - { id: 'done', label: 'DONE' }, - { id: 'review', label: 'REVIEW' }, - { id: 'approved', label: 'APPROVED' }, +export const STATUS_OPTIONS: { id: TaskStatusFilterId; label: string; color: string }[] = [ + { id: 'todo', label: 'TODO', color: '#3b82f6' }, + { id: 'in_progress', label: 'IN PROGRESS', color: '#eab308' }, + { id: 'done', label: 'DONE', color: '#22c55e' }, + { id: 'review', label: 'REVIEW', color: '#8b5cf6' }, + { id: 'approved', label: 'APPROVED', color: '#16a34a' }, ]; export interface TaskFiltersState { diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 986a763b..1cfa5805 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -12,10 +12,14 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { DisplayItemList } from '@renderer/components/chat/DisplayItemList'; import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils'; import { cn } from '@renderer/lib/utils'; -import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser'; +import { parseStreamJsonToGroups, groupBySubagent } from '@renderer/utils/streamJsonParser'; import { Bot, ChevronRight } from 'lucide-react'; -import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser'; +import type { + StreamJsonGroup, + StreamJsonEntry, + SubagentSection, +} from '@renderer/utils/streamJsonParser'; type CliLogsOrder = 'oldest-first' | 'newest-first'; @@ -149,6 +153,83 @@ const StreamGroup = ({ ); }; +/** + * Collapsible section wrapping all groups from one subagent. + * Collapsed by default. + */ +const SubagentSectionBlock = ({ + section, + isExpanded, + onToggle, + collapsedGroupIds, + onGroupToggle, + expandedItemIds, + onItemClick, + searchQueryOverride, +}: { + section: SubagentSection; + isExpanded: boolean; + onToggle: () => void; + collapsedGroupIds: Set; + onGroupToggle: (groupId: string) => void; + expandedItemIds: Set; + onItemClick: (itemId: string) => void; + searchQueryOverride?: string; +}): React.JSX.Element => { + const label = `Agent — ${section.description} (${section.toolCount} tool${section.toolCount !== 1 ? 's' : ''})`; + + return ( +
+ + {isExpanded && ( +
+ {section.groups.map((group) => + group.items.length === 1 ? ( + + ) : ( + onGroupToggle(group.id)} + expandedItemIds={expandedItemIds} + onItemClick={onItemClick} + searchQueryOverride={searchQueryOverride} + /> + ) + )} +
+ )} +
+ ); +}; + export const CliLogsRichView = ({ cliLogsTail, order = 'oldest-first', @@ -163,19 +244,29 @@ export const CliLogsRichView = ({ // Tracks groups manually collapsed by user (default: all auto-expanded) const [collapsedGroupIds, setCollapsedGroupIds] = useState>(new Set()); const [expandedItemIds, setExpandedItemIds] = useState>(new Set()); + // Subagent sections are collapsed by default; track which are expanded + const [expandedSubagentIds, setExpandedSubagentIds] = useState>(new Set()); const groups = useMemo(() => parseStreamJsonToGroups(cliLogsTail), [cliLogsTail]); + const entries = useMemo(() => groupBySubagent(groups), [groups]); // Derive expanded state: all groups expanded unless manually collapsed const expandedGroupIds = useMemo(() => { const expanded = new Set(); - for (const group of groups) { - if (!collapsedGroupIds.has(group.id)) { - expanded.add(group.id); + const addGroups = (gs: StreamJsonGroup[]): void => { + for (const g of gs) { + if (!collapsedGroupIds.has(g.id)) expanded.add(g.id); + } + }; + for (const entry of entries) { + if (entry.type === 'group') { + if (!collapsedGroupIds.has(entry.group.id)) expanded.add(entry.group.id); + } else { + addGroups(entry.section.groups); } } return expanded; - }, [groups, collapsedGroupIds]); + }, [entries, collapsedGroupIds]); const computeShouldStickToEdge = useCallback( (el: HTMLDivElement): boolean => { @@ -235,7 +326,19 @@ export const CliLogsRichView = ({ }); }, []); - if (groups.length === 0) { + const handleSubagentToggle = useCallback((sectionId: string) => { + setExpandedSubagentIds((prev) => { + const next = new Set(prev); + if (next.has(sectionId)) { + next.delete(sectionId); + } else { + next.add(sectionId); + } + return next; + }); + }, []); + + if (entries.length === 0) { // cliLogsTail has data but no parseable assistant messages — show raw text fallback const hasContent = cliLogsTail.trim().length > 0; return ( @@ -271,7 +374,7 @@ export const CliLogsRichView = ({ ); } - const visibleGroups = order === 'newest-first' ? [...groups].reverse() : groups; + const visibleEntries = order === 'newest-first' ? [...entries].reverse() : entries; return (
- {visibleGroups.map((group) => - group.items.length === 1 ? ( - // Single item — render flat without collapsible group wrapper + {visibleEntries.map((entry) => + entry.type === 'subagent-section' ? ( + handleSubagentToggle(entry.section.id)} + collapsedGroupIds={collapsedGroupIds} + onGroupToggle={handleGroupToggle} + expandedItemIds={expandedItemIds} + onItemClick={handleItemClick} + searchQueryOverride={searchQueryOverride} + /> + ) : entry.group.items.length === 1 ? ( ) : ( handleGroupToggle(group.id)} + key={entry.group.id} + group={entry.group} + isExpanded={expandedGroupIds.has(entry.group.id)} + onToggle={() => handleGroupToggle(entry.group.id)} expandedItemIds={expandedItemIds} onItemClick={handleItemClick} searchQueryOverride={searchQueryOverride} diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 9633d7af..4b9ccf4b 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -553,17 +553,6 @@ export const TeamListView = (): React.JSX.Element => { > Create Team -
{!canCreate ? ( diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 9e247eed..bbfd6a1a 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -1,4 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +import { ChevronDown, ChevronUp } from 'lucide-react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; @@ -71,7 +73,9 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { const VIEWPORT_THRESHOLD = 0.15; const LIVE_WINDOW_MS = 5_000; +const COLLAPSED_THOUGHTS_HEIGHT = 200; const AUTO_SCROLL_THRESHOLD = 30; +const THOUGHT_HEIGHT_ANIMATION_MS = 220; interface LeadThoughtsGroupRowProps { group: LeadThoughtGroup; @@ -160,6 +164,178 @@ const ToolSummaryTooltipContent = ({ return {toolSummary ?? ''}; }; +interface LeadThoughtItemProps { + thought: InboxMessage; + showDivider: boolean; + shouldAnimate: boolean; +} + +const LeadThoughtItem = ({ + thought, + showDivider, + shouldAnimate, +}: LeadThoughtItemProps): JSX.Element => { + const wrapperRef = useRef(null); + const contentRef = useRef(null); + const previousHeightRef = useRef(null); + const animationFrameRef = useRef(null); + const cleanupTimerRef = useRef(null); + + const clearPendingAnimation = useCallback(() => { + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (cleanupTimerRef.current !== null) { + window.clearTimeout(cleanupTimerRef.current); + cleanupTimerRef.current = null; + } + }, []); + + const resetWrapperStyles = useCallback(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + wrapper.style.height = 'auto'; + wrapper.style.opacity = '1'; + wrapper.style.overflow = 'visible'; + wrapper.style.transition = ''; + }, []); + + useLayoutEffect(() => { + const wrapper = wrapperRef.current; + const content = contentRef.current; + if (!wrapper || !content) return; + + const animateHeight = ( + targetHeight: number, + startHeight: number, + startOpacity: number + ): void => { + clearPendingAnimation(); + wrapper.style.transition = 'none'; + wrapper.style.overflow = 'hidden'; + wrapper.style.height = `${Math.max(startHeight, 0)}px`; + wrapper.style.opacity = `${startOpacity}`; + void wrapper.offsetHeight; + + animationFrameRef.current = requestAnimationFrame(() => { + wrapper.style.transition = `height ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease, opacity ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease`; + wrapper.style.height = `${Math.max(targetHeight, 0)}px`; + wrapper.style.opacity = '1'; + }); + + cleanupTimerRef.current = window.setTimeout(() => { + resetWrapperStyles(); + cleanupTimerRef.current = null; + }, THOUGHT_HEIGHT_ANIMATION_MS + 40); + }; + + const syncHeight = (nextHeight: number, animateFromZero: boolean): void => { + const previousHeight = previousHeightRef.current; + previousHeightRef.current = nextHeight; + + if (!shouldAnimate) { + resetWrapperStyles(); + return; + } + + if (previousHeight === null) { + if (nextHeight > 0 && animateFromZero) { + animateHeight(nextHeight, 0, 0); + } else { + resetWrapperStyles(); + } + return; + } + + if (Math.abs(nextHeight - previousHeight) < 1) return; + + const renderedHeight = wrapper.getBoundingClientRect().height; + animateHeight(nextHeight, renderedHeight > 0 ? renderedHeight : previousHeight, 1); + }; + + syncHeight(content.getBoundingClientRect().height, true); + + const observer = new ResizeObserver((entries) => { + const nextHeight = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height; + syncHeight(nextHeight, false); + }); + observer.observe(content); + + return () => { + observer.disconnect(); + clearPendingAnimation(); + resetWrapperStyles(); + }; + }, [clearPendingAnimation, resetWrapperStyles, shouldAnimate]); + + useEffect( + () => () => { + clearPendingAnimation(); + }, + [clearPendingAnimation] + ); + + return ( +
+
+ {showDivider && ( +
+
+ + {formatTimeWithSec(thought.timestamp)} + +
+
+ )} +
+
+ +
+
+ {thought.toolSummary && ( + + +
+ 🔧 {thought.toolSummary} +
+
+ + + +
+ )} +
+
+ ); +}; + export const LeadThoughtsGroupRow = ({ group, memberColor, @@ -170,6 +346,7 @@ export const LeadThoughtsGroupRow = ({ }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); + const contentRef = useRef(null); const isUserScrolledUpRef = useRef(false); const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false); const leadActivity = useStore((s) => { @@ -227,6 +404,8 @@ export const LeadThoughtsGroupRow = ({ [canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp] ); const [isLive, setIsLive] = useState(computeIsLive); + const [expanded, setExpanded] = useState(false); + const [needsTruncation, setNeedsTruncation] = useState(false); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap @@ -258,18 +437,57 @@ export const LeadThoughtsGroupRow = ({ return () => observer.disconnect(); }, [onVisible, thoughts]); - // Auto-scroll when new thoughts arrive + const syncScrollableBody = useCallback( + (forceScrollToBottom = false) => { + const scrollEl = scrollRef.current; + const contentEl = contentRef.current; + if (!scrollEl || !contentEl) return; + + const nextNeedsTruncation = contentEl.scrollHeight > COLLAPSED_THOUGHTS_HEIGHT + 1; + setNeedsTruncation((prev) => (prev === nextNeedsTruncation ? prev : nextNeedsTruncation)); + + if (expanded) return; + if (!forceScrollToBottom && isUserScrolledUpRef.current) return; + scrollEl.scrollTop = scrollEl.scrollHeight; + }, + [expanded] + ); + useEffect(() => { - const el = scrollRef.current; - if (!el || isUserScrolledUpRef.current) return; - el.scrollTop = el.scrollHeight; - }, [chronologicalThoughts]); + const contentEl = contentRef.current; + if (!contentEl) return; + + syncScrollableBody(true); + + const observer = new ResizeObserver(() => { + syncScrollableBody(); + }); + observer.observe(contentEl); + + return () => observer.disconnect(); + }, [syncScrollableBody]); const handleScroll = useCallback(() => { + if (expanded) return; const el = scrollRef.current; if (!el) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD; + }, [expanded]); + + const handleCollapse = useCallback(() => { + isUserScrolledUpRef.current = false; + setExpanded(false); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const scrollEl = scrollRef.current; + if (scrollEl) { + scrollEl.scrollTop = scrollEl.scrollHeight; + } + ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }); + }); }, []); return ( @@ -323,80 +541,67 @@ export const LeadThoughtsGroupRow = ({ )} - {/* Scrollable body — fixed height, always visible */} + {/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
- {chronologicalThoughts.map((thought, idx) => ( -
- {idx > 0 && ( -
-
- - {formatTimeWithSec(thought.timestamp)} - -
-
- )} -
-
- -
-
- {thought.toolSummary && ( - - -
- 🔧 {thought.toolSummary} -
-
- - - -
- )} -
- ))} +
+ {chronologicalThoughts.map((thought, idx) => ( + 0} + shouldAnimate={isLive && idx === chronologicalThoughts.length - 1} + /> + ))} +
+ {!expanded && needsTruncation ? ( +
+ +
+ ) : null} + {expanded && needsTruncation ? ( +
+ +
+ ) : null} ); }; diff --git a/src/renderer/hooks/useCollapsedGroups.ts b/src/renderer/hooks/useCollapsedGroups.ts new file mode 100644 index 00000000..c2c582a6 --- /dev/null +++ b/src/renderer/hooks/useCollapsedGroups.ts @@ -0,0 +1,71 @@ +import { useCallback, useState } from 'react'; + +/** + * Manages collapsed/expanded state for group headers with localStorage persistence. + * Each grouping mode gets a unique prefix to avoid key collisions. + */ + +const STORAGE_PREFIX = 'taskGroupCollapsed'; + +function storageKey(prefix: string, groupKey: string): string { + return `${STORAGE_PREFIX}:${prefix}:${groupKey}`; +} + +function loadCollapsedSet(prefix: string, groupKeys: string[]): Set { + const set = new Set(); + try { + for (const key of groupKeys) { + if (localStorage.getItem(storageKey(prefix, key)) === '1') { + set.add(key); + } + } + } catch { + /* ignore storage errors */ + } + return set; +} + +export function useCollapsedGroups(prefix: string, groupKeys: string[]) { + // Re-initialize when prefix or keys change + const [collapsed, setCollapsed] = useState>(() => + loadCollapsedSet(prefix, groupKeys) + ); + + // Sync with new keys when they change (e.g. new projects appear) + // We use a key string to detect changes without deep comparison + const keysFingerprint = groupKeys.join('\0'); + const [prevFingerprint, setPrevFingerprint] = useState(keysFingerprint); + const [prevPrefix, setPrevPrefix] = useState(prefix); + + if (keysFingerprint !== prevFingerprint || prefix !== prevPrefix) { + setPrevFingerprint(keysFingerprint); + setPrevPrefix(prefix); + setCollapsed(loadCollapsedSet(prefix, groupKeys)); + } + + const isCollapsed = useCallback((groupKey: string) => collapsed.has(groupKey), [collapsed]); + + const toggle = useCallback( + (groupKey: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + const key = storageKey(prefix, groupKey); + try { + if (next.has(groupKey)) { + next.delete(groupKey); + localStorage.removeItem(key); + } else { + next.add(groupKey); + localStorage.setItem(key, '1'); + } + } catch { + /* ignore storage errors */ + } + return next; + }); + }, + [prefix] + ); + + return { isCollapsed, toggle } as const; +} diff --git a/src/renderer/utils/streamJsonParser.ts b/src/renderer/utils/streamJsonParser.ts index 8fd71bfb..040e8332 100644 --- a/src/renderer/utils/streamJsonParser.ts +++ b/src/renderer/utils/streamJsonParser.ts @@ -21,8 +21,26 @@ export interface StreamJsonGroup { summary: string; /** Timestamp of first message in group */ timestamp: Date; + /** If set, this group belongs to a subagent (not the lead). */ + agentId?: string; } +/** A subagent section wrapping consecutive groups from the same agentId. */ +export interface SubagentSection { + id: string; + agentId: string; + /** Human-readable description from the Agent tool_use that spawned this subagent */ + description: string; + groups: StreamJsonGroup[]; + toolCount: number; + timestamp: Date; +} + +/** Union type for the final render list after subagent grouping. */ +export type StreamJsonEntry = + | { type: 'group'; group: StreamJsonGroup } + | { type: 'subagent-section'; section: SubagentSection }; + interface ContentBlock { type: string; text?: string; @@ -182,6 +200,7 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] let currentItems: AIGroupDisplayItem[] = []; let currentTimestamp: Date | null = null; let currentGroupId: string | null = null; + let currentAgentId: string | undefined = undefined; // Track how many times each messageId has been seen to disambiguate duplicates const msgIdOccurrences = new Map(); @@ -193,10 +212,12 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] items: currentItems, summary: buildGroupSummary(currentItems), timestamp: currentTimestamp, + agentId: currentAgentId, }); currentItems = []; currentTimestamp = null; currentGroupId = null; + currentAgentId = undefined; } }; @@ -227,6 +248,12 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] continue; } + // Extract agentId from top-level (subagent messages have it, lead messages don't) + const lineAgentId = + typeof (parsed as Record).agentId === 'string' + ? ((parsed as Record).agentId as string) + : undefined; + if (!currentTimestamp) { // Use stable cached timestamp keyed by line content to survive re-parses let ts = lineTimestampCache.get(trimmed); @@ -242,6 +269,7 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] currentTimestamp = ts; } if (!currentGroupId) { + currentAgentId = lineAgentId; const msgId = extractAssistantMessageId(parsed); if (msgId) { const occurrence = msgIdOccurrences.get(msgId) ?? 0; @@ -262,3 +290,75 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] return groups; } + +/** + * Groups consecutive StreamJsonGroups by agentId into SubagentSections. + * Lead groups (no agentId) remain as individual entries. + * Must be called on chronological (oldest-first) groups. + */ +export function groupBySubagent(groups: StreamJsonGroup[]): StreamJsonEntry[] { + const result: StreamJsonEntry[] = []; + const pendingDescriptions: string[] = []; + const agentDescMap = new Map(); + const sectionCountByAgent = new Map(); + let currentRun: { agentId: string; groups: StreamJsonGroup[] } | null = null; + + const flushRun = (): void => { + if (!currentRun) return; + const desc = agentDescMap.get(currentRun.agentId) ?? 'Subagent'; + let toolCount = 0; + for (const g of currentRun.groups) { + for (const item of g.items) { + if (item.type === 'tool') toolCount++; + } + } + const count = sectionCountByAgent.get(currentRun.agentId) ?? 0; + sectionCountByAgent.set(currentRun.agentId, count + 1); + const idSuffix = count > 0 ? `-${count}` : ''; + result.push({ + type: 'subagent-section', + section: { + id: `subagent-section-${currentRun.agentId}${idSuffix}`, + agentId: currentRun.agentId, + description: desc, + groups: currentRun.groups, + toolCount, + timestamp: currentRun.groups[0].timestamp, + }, + }); + currentRun = null; + }; + + for (const group of groups) { + if (!group.agentId) { + // Lead group — check for Agent/Task tool_use and extract description + for (const item of group.items) { + if (item.type === 'tool' && (item.tool.name === 'Agent' || item.tool.name === 'Task')) { + const input = item.tool.input as Record | undefined; + const desc = + (typeof input?.description === 'string' && input.description) || + (typeof input?.prompt === 'string' && input.prompt.slice(0, 80)) || + 'Subagent'; + pendingDescriptions.push(desc); + } + } + flushRun(); + result.push({ type: 'group', group }); + } else { + // Subagent group + if (!agentDescMap.has(group.agentId)) { + agentDescMap.set(group.agentId, pendingDescriptions.shift() ?? 'Subagent'); + } + + if (currentRun && currentRun.agentId === group.agentId) { + currentRun.groups.push(group); + } else { + flushRun(); + currentRun = { agentId: group.agentId, groups: [group] }; + } + } + } + + flushRun(); + return result; +} diff --git a/src/renderer/utils/toolRendering/toolSummaryHelpers.ts b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts index a130e44e..2b06119b 100644 --- a/src/renderer/utils/toolRendering/toolSummaryHelpers.ts +++ b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts @@ -248,6 +248,11 @@ export function getToolSummary(toolName: string, input: Record) case 'TeamDelete': return 'Delete team'; + case 'Agent': { + const desc = input.description ?? input.prompt; + return typeof desc === 'string' ? truncate(desc, 60) : 'Subagent'; + } + default: { // For unknown tools, try to extract a meaningful summary const keys = Object.keys(input); From d92eb9b72c2868a30932c779644ba6f13e0ab7c9 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 6 Mar 2026 20:58:14 +0200 Subject: [PATCH 02/15] refactor: update package.json and enhance ClaudeLogsSection functionality - Renamed package from `@claude-team/mcp-server` to `agent-teams-mcp` and updated description for clarity. - Added `files` and `keywords` fields in package.json for better package management. - Introduced new loading logic in ClaudeLogsSection to handle older log retrieval and improved state management for loading indicators. - Implemented utility functions for appending older log lines and determining overlap, enhancing log display efficiency. - Updated ActivityItem and ActivityTimeline components to support message collapsing functionality, improving user experience in message handling. --- mcp-server/package.json | 26 ++- resources/pricing.json | 2 +- .../components/team/ClaudeLogsSection.tsx | 155 +++++++++++++++--- .../components/team/TeamDetailView.tsx | 26 +++ .../components/team/activity/ActivityItem.tsx | 29 +++- .../team/activity/ActivityTimeline.tsx | 7 + 6 files changed, 212 insertions(+), 33 deletions(-) diff --git a/mcp-server/package.json b/mcp-server/package.json index ac6f5a7d..b16663fa 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,18 +1,36 @@ { - "name": "@claude-team/mcp-server", + "name": "agent-teams-mcp", "version": "1.0.0", - "description": "MCP server for managing Claude Agent Teams kanban board and tasks", + "description": "MCP server for managing Claude Agent Teams kanban board and tasks via teamctl CLI", "type": "module", "main": "dist/index.js", "bin": { - "team-mcp-server": "dist/index.js" + "agent-teams-mcp": "dist/index.js" + }, + "files": [ + "dist" + ], + "keywords": [ + "mcp", + "mcp-server", + "claude", + "agent-teams", + "kanban", + "task-management", + "model-context-protocol" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/nickchernyy/agent-teams-mcp" }, "scripts": { "build": "tsup", "dev": "tsx src/index.ts", "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "prepublishOnly": "pnpm build" }, "dependencies": { "fastmcp": "^3.34.0", diff --git a/resources/pricing.json b/resources/pricing.json index 78856312..3f65c218 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -553,7 +553,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, - "apac.anthropic.claude-sonnet-4-6": { + "au.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, "cache_read_input_token_cost": 3.3e-7, diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index aa1c6ae8..10dd49d8 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; @@ -16,6 +16,7 @@ import type { TeamClaudeLogsResponse } from '@shared/types'; const PAGE_SIZE = 100; const POLL_MS = 2000; const ONLINE_WINDOW_MS = 10_000; +const LOAD_MORE_THRESHOLD_PX = 48; type StreamType = 'stdout' | 'stderr'; @@ -70,6 +71,40 @@ function normalizeToStreamJsonText(linesNewestFirst: string[]): string { return out.join('\n'); } +function getOverlapSize( + existingLinesNewestFirst: string[], + olderLinesNewestFirst: string[] +): number { + const maxOverlap = Math.min(existingLinesNewestFirst.length, olderLinesNewestFirst.length); + + for (let size = maxOverlap; size > 0; size -= 1) { + let matches = true; + for (let i = 0; i < size; i += 1) { + if ( + existingLinesNewestFirst[existingLinesNewestFirst.length - size + i] !== + olderLinesNewestFirst[i] + ) { + matches = false; + break; + } + } + if (matches) return size; + } + + return 0; +} + +function appendOlderLines( + existingLinesNewestFirst: string[], + olderLinesNewestFirst: string[] +): string[] { + if (existingLinesNewestFirst.length === 0) return olderLinesNewestFirst; + if (olderLinesNewestFirst.length === 0) return existingLinesNewestFirst; + + const overlapSize = getOverlapSize(existingLinesNewestFirst, olderLinesNewestFirst); + return existingLinesNewestFirst.concat(olderLinesNewestFirst.slice(overlapSize)); +} + type AssistantContentBlock = | { type: 'text'; text?: string } | { type: 'thinking'; thinking?: string } @@ -192,13 +227,16 @@ function filterStreamJsonText( export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => { const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false); - const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const [loadedCount, setLoadedCount] = useState(PAGE_SIZE); const [data, setData] = useState({ lines: [], total: 0, hasMore: false }); const [pending, setPending] = useState(null); const [pendingNewCount, setPendingNewCount] = useState(0); const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const inFlightRef = useRef(false); + const loadingMoreRef = useRef(false); + const applyingPendingRef = useRef(false); const atTopRef = useRef(true); const latestRef = useRef(null); const logContainerRef = useRef(null); @@ -210,9 +248,15 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), })); const [filterOpen, setFilterOpen] = useState(false); + const isNearBottom = useCallback( + (scrollTop: number, scrollHeight: number, clientHeight: number) => { + return scrollHeight - scrollTop - clientHeight <= LOAD_MORE_THRESHOLD_PX; + }, + [] + ); useEffect(() => { - setVisibleCount(PAGE_SIZE); + setLoadedCount(PAGE_SIZE); setData({ lines: [], total: 0, hasMore: false }); setPending(null); setPendingNewCount(0); @@ -255,7 +299,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J inFlightRef.current = true; try { setLoading(true); - const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount }); + const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: loadedCount }); if (cancelled) return; latestRef.current = next; if (atTopRef.current) { @@ -283,11 +327,55 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J cancelled = true; window.clearInterval(id); }; - }, [teamName, visibleCount]); + }, [teamName, loadedCount]); + + const loadOlderLogs = useCallback(async (): Promise => { + if (loadingMoreRef.current || inFlightRef.current) return; + + const current = committedRef.current; + if (!current.hasMore) return; + + loadingMoreRef.current = true; + setLoadingMore(true); + + try { + const older = await api.teams.getClaudeLogs(teamName, { + offset: current.lines.length + pendingCountRef.current, + limit: PAGE_SIZE, + }); + + setData((prev) => ({ + ...prev, + lines: appendOlderLines(prev.lines, older.lines), + total: older.total, + hasMore: older.hasMore, + updatedAt: older.updatedAt ?? prev.updatedAt, + })); + setLoadedCount((count) => count + PAGE_SIZE); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + loadingMoreRef.current = false; + setLoadingMore(false); + } + }, [teamName]); + + useEffect(() => { + const el = logContainerRef.current; + if (!el || loading || loadingMore || !data.hasMore || data.lines.length === 0) return; + + if ( + el.scrollHeight <= el.clientHeight || + isNearBottom(el.scrollTop, el.scrollHeight, el.clientHeight) + ) { + void loadOlderLogs(); + } + }, [data.hasMore, data.lines.length, isNearBottom, loadOlderLogs, loading, loadingMore]); const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]); const badge = data.total > 0 ? data.total : undefined; - const showMoreVisible = data.hasMore; + const showMoreVisible = data.hasMore || loadingMore; const headerExtra = online ? ( @@ -310,17 +398,34 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J return filterStreamJsonText(data.lines, searchQuery, filter); }, [data.lines, searchQuery, filter]); - const applyPending = (): void => { - const latest = latestRef.current ?? pending; - if (!latest) return; - setData(latest); - setPending(null); - setPendingNewCount(0); - // Jump to newest - if (logContainerRef.current) { - logContainerRef.current.scrollTop = 0; + const applyPending = useCallback(async (): Promise => { + if (applyingPendingRef.current) return; + + applyingPendingRef.current = true; + try { + let latest = latestRef.current ?? pending; + const expectedVisibleCount = latest ? Math.min(loadedCount, latest.total) : loadedCount; + + if (!latest || latest.lines.length < expectedVisibleCount) { + latest = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: loadedCount }); + latestRef.current = latest; + } + + setData(latest); + setPending(null); + setPendingNewCount(0); + setError(null); + + // Jump to newest + if (logContainerRef.current) { + logContainerRef.current.scrollTop = 0; + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + applyingPendingRef.current = false; } - }; + }, [loadedCount, pending, teamName]); return ( {data.total > 0 ? ( <> - Showing {Math.min(data.total, visibleCount)} of{' '} - {data.total} + Showing {Math.min(data.total, data.lines.length)}{' '} + of {data.total} ) : isAlive ? ( 'No logs yet.' @@ -389,9 +494,10 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J variant="ghost" size="sm" className="h-7 px-2 text-xs" - onClick={() => setVisibleCount((c) => c + PAGE_SIZE)} + onClick={() => void loadOlderLogs()} + disabled={loadingMore} > - Show more + {loadingMore ? 'Loading…' : 'Show more'} )} @@ -409,11 +515,16 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J containerRefCallback={(el) => { logContainerRef.current = el; }} - onScroll={({ scrollTop }) => { + onScroll={({ scrollTop, scrollHeight, clientHeight }) => { const atTop = scrollTop <= 8; atTopRef.current = atTop; if (atTop && pendingCountRef.current > 0) { - applyPending(); + void applyPending(); + return; + } + + if (isNearBottom(scrollTop, scrollHeight, clientHeight)) { + void loadOlderLogs(); } }} /> diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index f80056e7..4ba59754 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -32,6 +32,8 @@ import { AlertTriangle, Bell, CheckCheck, + ChevronsDownUp, + ChevronsUpDown, Code, Columns3, FolderOpen, @@ -306,6 +308,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele showNoise: false, }); const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); + const [messagesCollapsed, setMessagesCollapsed] = useState(false); // Open editor overlay when a file reveal is requested (e.g. from chip click) const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); @@ -1498,6 +1501,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onOpenChange={setMessagesFilterOpen} onApply={setMessagesFilter} /> + + + + + + {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} + + } > @@ -1536,6 +1561,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele teamName={teamName} members={data.members} readState={{ readSet, getMessageKey: toMessageKey }} + allCollapsed={messagesCollapsed} onMemberClick={setSelectedMember} onCreateTaskFromMessage={(subject, description) => { openCreateTaskDialog(subject, description); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 0b472ef5..cce545a8 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; @@ -52,6 +52,8 @@ interface ActivityItemProps { onRestartTeam?: () => void; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; + /** When true, collapse message body — show only header with expand chevron. */ + forceCollapsed?: boolean; } function getStringField(obj: StructuredMessage, key: string): string | null { @@ -213,6 +215,7 @@ export const ActivityItem = ({ onTaskIdClick, onRestartTeam, zebraShade, + forceCollapsed, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const formattedRole = formatAgentRole(memberRole); @@ -233,7 +236,17 @@ export const ActivityItem = ({ // System/automated messages start collapsed (but not rate limits) const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; - const [isExpanded, setIsExpanded] = useState(!systemLabel); + const [isExpanded, setIsExpanded] = useState(!systemLabel && !forceCollapsed); + + // Sync expand/collapse when the global collapse mode toggles (skip initial mount) + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + setIsExpanded(forceCollapsed ? false : !systemLabel); + }, [forceCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps -- systemLabel is stable (derived from message.text) // Strip agent-only blocks + normalize escape sequences (before linkification) const strippedText = useMemo(() => { @@ -282,7 +295,8 @@ export const ActivityItem = ({ onCreateTask?.(subject, description); }; - const isHeaderClickable = Boolean(systemLabel); + const isHeaderClickable = Boolean(systemLabel) || forceCollapsed === true; + const showChevron = Boolean(systemLabel) || forceCollapsed === true; const isUserSent = message.source === 'user_sent'; const isSystemMessage = message.from === 'system'; @@ -337,8 +351,8 @@ export const ActivityItem = ({ {isUnread ? ( ) : null} - {/* Chevron for collapsible system messages */} - {systemLabel ? ( + {/* Chevron for collapsible messages */} + {showChevron ? ( ) : parsedReply ? ( - + ) : displayText ? ( void; /** Called when the user clicks "Restart team" on an auth error message. */ onRestartTeam?: () => void; + /** When true, collapse all message bodies — show only headers with expand chevrons. */ + allCollapsed?: boolean; } const VIEWPORT_THRESHOLD = 0.15; @@ -47,6 +49,7 @@ const MessageRowWithObserver = ({ onVisible, onTaskIdClick, onRestartTeam, + forceCollapsed, }: { message: InboxMessage; teamName: string; @@ -63,6 +66,7 @@ const MessageRowWithObserver = ({ onVisible?: (message: InboxMessage) => void; onTaskIdClick?: (taskId: string) => void; onRestartTeam?: () => void; + forceCollapsed?: boolean; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -110,6 +114,7 @@ const MessageRowWithObserver = ({ onReply={onReply} onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} + forceCollapsed={forceCollapsed} /> ); @@ -126,6 +131,7 @@ export const ActivityTimeline = ({ onMessageVisible, onTaskIdClick, onRestartTeam, + allCollapsed, }: ActivityTimelineProps): React.JSX.Element => { const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); @@ -384,6 +390,7 @@ export const ActivityTimeline = ({ onVisible={onMessageVisible} onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} + forceCollapsed={allCollapsed} /> ); From 3e605622f78463bdc7b44eb643a47dba1e8640ae Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 6 Mar 2026 21:12:59 +0200 Subject: [PATCH 03/15] feat: implement tool approval protocol for team management - Added support for tool approval requests and responses in the team provisioning service. - Introduced IPC channels for handling tool approval events between main and renderer processes. - Enhanced the UI to display tool approval requests and allow user responses through new components. - Updated team launch and creation dialogs to include an option for skipping permissions, integrating with the new tool approval logic. - Implemented state management for pending approvals in the store, ensuring a seamless user experience during tool interactions. --- src/main/index.ts | 7 + src/main/ipc/teams.ts | 39 +++ .../services/team/TeamProvisioningService.ts | 133 +++++++++- src/preload/constants/ipcChannels.ts | 6 + src/preload/index.ts | 33 +++ src/renderer/App.tsx | 2 + src/renderer/api/httpClient.ts | 6 + .../components/team/ToolApprovalSheet.tsx | 237 ++++++++++++++++++ .../team/activity/ActivityTimeline.tsx | 2 + .../team/activity/LeadThoughtsGroup.tsx | 103 +++++--- .../team/dialogs/CreateTeamDialog.tsx | 18 ++ .../team/dialogs/LaunchTeamDialog.tsx | 15 ++ .../team/dialogs/SkipPermissionsCheckbox.tsx | 65 +++++ src/renderer/store/index.ts | 26 ++ src/renderer/store/slices/teamSlice.ts | 25 ++ src/shared/types/api.ts | 9 + src/shared/types/team.ts | 35 +++ 17 files changed, 730 insertions(+), 31 deletions(-) create mode 100644 src/renderer/components/team/ToolApprovalSheet.tsx create mode 100644 src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx diff --git a/src/main/index.ts b/src/main/index.ts index 40db5d09..1f15f9c4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -24,6 +24,7 @@ import { CONTEXT_CHANGED, SSH_STATUS, TEAM_CHANGE, + TEAM_TOOL_APPROVAL_EVENT, WINDOW_FULLSCREEN_CHANGED, // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload } from '@preload/constants/ipcChannels'; @@ -634,6 +635,12 @@ function initializeServices(): void { }; teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter); + teamProvisioningService.setToolApprovalEventEmitter((event) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(TEAM_TOOL_APPROVAL_EVENT, event); + } + }); + // startProcessHealthPolling() is deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index b855253b..ff6034b3 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -48,6 +48,7 @@ import { TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, TEAM_STOP, + TEAM_TOOL_APPROVAL_RESPOND, TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, TEAM_UPDATE_KANBAN_COLUMN_ORDER, @@ -247,6 +248,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_SAVE_TASK_ATTACHMENT, handleSaveTaskAttachment); ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment); ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment); + ipcMain.handle(TEAM_TOOL_APPROVAL_RESPOND, handleToolApprovalRespond); logger.info('Team handlers registered'); } @@ -301,6 +303,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_SAVE_TASK_ATTACHMENT); ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT); ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT); + ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND); } function getTeamDataService(): TeamDataService { @@ -642,6 +645,8 @@ async function validateProvisioningRequest( cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, + skipPermissions: + typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, }, }; } @@ -747,6 +752,8 @@ async function handleLaunchTeam( prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, clearContext: payload.clearContext === true ? true : undefined, + skipPermissions: + typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, }, (progress) => { try { @@ -2239,3 +2246,35 @@ async function handleDeleteTaskAttachment( await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId); }); } + +async function handleToolApprovalRespond( + _event: IpcMainInvokeEvent, + teamName: unknown, + runId: unknown, + requestId: unknown, + allow: unknown, + message?: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + if (typeof runId !== 'string' || runId.trim().length === 0) { + return { success: false, error: 'runId must be a non-empty string' }; + } + if (typeof requestId !== 'string' || requestId.trim().length === 0) { + return { success: false, error: 'requestId must be a non-empty string' }; + } + if (typeof allow !== 'boolean') { + return { success: false, error: 'allow must be a boolean' }; + } + return wrapTeamHandler('toolApprovalRespond', () => + getTeamProvisioningService().respondToToolApproval( + validated.value!, + runId, + requestId, + allow, + typeof message === 'string' ? message : undefined + ) + ); +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index e80a3342..cb6e82ff 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -50,6 +50,8 @@ import type { TeamProvisioningProgress, TeamProvisioningState, TeamTask, + ToolApprovalEvent, + ToolApprovalRequest, ToolCallMeta, } from '@shared/types'; @@ -201,6 +203,8 @@ interface ProvisioningRun { env: NodeJS.ProcessEnv; prompt: string; } | null; + /** Pending tool approval requests awaiting user response (control_request protocol). */ + pendingApprovals: Map; } type LeadActivityState = 'active' | 'idle' | 'offline'; @@ -1163,6 +1167,16 @@ export class TeamProvisioningService { this.teamChangeEmitter = emitter; } + private toolApprovalEventEmitter: ((event: ToolApprovalEvent) => void) | null = null; + + setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void { + this.toolApprovalEventEmitter = emitter; + } + + private emitToolApprovalEvent(event: ToolApprovalEvent): void { + this.toolApprovalEventEmitter?.(event); + } + getLiveLeadProcessMessages(teamName: string): InboxMessage[] { return [...(this.liveLeadProcessMessages.get(teamName) ?? [])]; } @@ -1759,6 +1773,7 @@ export class TeamProvisioningService { authFailureRetried: false, authRetryInProgress: false, spawnContext: null, + pendingApprovals: new Map(), progress: { runId, teamName: request.teamName, @@ -1787,7 +1802,7 @@ export class TeamProvisioningService { 'user,project,local', '--disallowedTools', 'TeamDelete,TodoWrite', - '--dangerously-skip-permissions', + ...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []), ...(request.model ? ['--model', request.model] : []), ]; try { @@ -2018,6 +2033,7 @@ export class TeamProvisioningService { teamName: request.teamName, members: expectedMemberSpecs, cwd: request.cwd, + skipPermissions: request.skipPermissions, }; const run: ProvisioningRun = { @@ -2059,6 +2075,7 @@ export class TeamProvisioningService { authFailureRetried: false, authRetryInProgress: false, spawnContext: null, + pendingApprovals: new Map(), progress: { runId, teamName: request.teamName, @@ -2109,7 +2126,7 @@ export class TeamProvisioningService { 'user,project,local', '--disallowedTools', 'TeamDelete,TodoWrite', - '--dangerously-skip-permissions', + ...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []), ]; if (previousSessionId) { launchArgs.push('--resume', previousSessionId); @@ -3033,6 +3050,12 @@ export class TeamProvisioningService { } } + // Handle control_request — tool approval protocol (only when --dangerously-skip-permissions is NOT set) + if (msg.type === 'control_request') { + this.handleControlRequest(run, msg); + return; + } + if (msg.type === 'result') { const subtype = typeof msg.subtype === 'string' @@ -3197,6 +3220,107 @@ export class TeamProvisioningService { } } + /** + * Handles a control_request message from CLI stream-json output. + * Only `can_use_tool` subtype is processed — others are logged and ignored. + */ + private handleControlRequest(run: ProvisioningRun, msg: Record): void { + const requestId = typeof msg.request_id === 'string' ? msg.request_id : null; + if (!requestId) { + logger.warn(`[${run.teamName}] control_request missing request_id, ignoring`); + return; + } + + const request = msg.request as Record | undefined; + const subtype = request?.subtype; + if (subtype !== 'can_use_tool') { + logger.debug( + `[${run.teamName}] control_request subtype=${String(subtype)}, ignoring (only can_use_tool supported)` + ); + return; + } + + const toolName = typeof request?.tool_name === 'string' ? request.tool_name : 'Unknown'; + const toolInput = (request?.input ?? {}) as Record; + + const approval: ToolApprovalRequest = { + requestId, + runId: run.runId, + teamName: run.teamName, + source: 'lead', + toolName, + toolInput, + receivedAt: new Date().toISOString(), + }; + + run.pendingApprovals.set(requestId, approval); + this.emitToolApprovalEvent(approval); + } + + /** + * Respond to a pending tool approval — sends control_response to CLI stdin. + * Validates runId match and requestId existence before writing. + */ + async respondToToolApproval( + teamName: string, + runId: string, + requestId: string, + allow: boolean, + message?: string + ): Promise { + const currentRunId = this.activeByTeam.get(teamName); + if (!currentRunId) throw new Error(`No active process for team "${teamName}"`); + const run = this.runs.get(currentRunId); + if (!run) throw new Error(`Run not found for team "${teamName}"`); + + if (run.runId !== runId) { + throw new Error(`Stale approval: runId mismatch (expected ${run.runId}, got ${runId})`); + } + + if (!run.pendingApprovals.has(requestId)) { + throw new Error(`No pending approval with requestId "${requestId}"`); + } + + if (!run.child?.stdin?.writable) { + throw new Error(`Team "${teamName}" process stdin is not writable`); + } + + // IMPORTANT: request_id is NESTED inside response, NOT top-level + // (asymmetry with control_request — confirmed by Python SDK, Elixir SDK and issue #29991) + const response = allow + ? { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { behavior: 'allow' }, + }, + } + : { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { behavior: 'deny', message: message ?? 'User denied' }, + }, + }; + + const stdin = run.child.stdin; + await new Promise((resolve, reject) => { + stdin.write(JSON.stringify(response) + '\n', (err) => { + if (err) { + logger.error(`[${teamName}] Failed to write control_response: ${err.message}`); + reject(err); + } else { + resolve(); + } + }); + }); + + // Only delete AFTER successful write + run.pendingApprovals.delete(requestId); + } + /** * Called when the first stream-json turn completes successfully. * Verifies provisioning files exist and marks as ready. @@ -3402,6 +3526,11 @@ export class TeamProvisioningService { this.relayedLeadInboxMessageIds.delete(run.teamName); this.relayedLeadInboxFallbackKeys.delete(run.teamName); this.liveLeadProcessMessages.delete(run.teamName); + // Dismiss any pending tool approvals for this run + if (run.pendingApprovals.size > 0) { + this.emitToolApprovalEvent({ dismissed: true, teamName: run.teamName, runId: run.runId }); + run.pendingApprovals.clear(); + } // Remove from runs Map to free memory (stdoutBuffer, stderrBuffer, claudeLogLines) this.runs.delete(run.runId); } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 72722ee0..690be05b 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -358,6 +358,12 @@ export const TEAM_GET_TASK_ATTACHMENT = 'team:getTaskAttachment'; /** Delete an attachment from a task */ export const TEAM_DELETE_TASK_ATTACHMENT = 'team:deleteTaskAttachment'; +/** Push event: tool approval request or dismissal (main → renderer) */ +export const TEAM_TOOL_APPROVAL_EVENT = 'team:toolApprovalEvent'; + +/** Invoke: respond to a tool approval request (renderer → main) */ +export const TEAM_TOOL_APPROVAL_RESPOND = 'team:toolApprovalRespond'; + // ============================================================================= // CLI Installer API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 9f361333..c67e5c02 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -103,6 +103,8 @@ import { TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, TEAM_STOP, + TEAM_TOOL_APPROVAL_EVENT, + TEAM_TOOL_APPROVAL_RESPOND, TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, TEAM_UPDATE_KANBAN_COLUMN_ORDER, @@ -214,6 +216,7 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + ToolApprovalEvent, TriggerTestResult, UpdateKanbanPatch, WslClaudeRootCandidate, @@ -975,6 +978,36 @@ const electronAPI: ElectronAPI = { ); }; }, + respondToToolApproval: async ( + teamName: string, + runId: string, + requestId: string, + allow: boolean, + message?: string + ) => { + return invokeIpcWithResult( + TEAM_TOOL_APPROVAL_RESPOND, + teamName, + runId, + requestId, + allow, + message + ); + }, + onToolApprovalEvent: ( + callback: (event: unknown, data: ToolApprovalEvent) => void + ): (() => void) => { + ipcRenderer.on( + TEAM_TOOL_APPROVAL_EVENT, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + TEAM_TOOL_APPROVAL_EVENT, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, }, // ===== Review API ===== diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a66f4a40..9469af07 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6,6 +6,7 @@ import { ConfirmDialog } from './components/common/ConfirmDialog'; import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay'; import { ErrorBoundary } from './components/common/ErrorBoundary'; import { TabbedLayout } from './components/layout/TabbedLayout'; +import { ToolApprovalSheet } from './components/team/ToolApprovalSheet'; import { useTheme } from './hooks/useTheme'; import { api } from './api'; import { useStore } from './store'; @@ -40,6 +41,7 @@ export const App = (): React.JSX.Element => { + ); diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 033bebe5..58bb724e 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -884,6 +884,12 @@ export class HttpAPIClient implements ElectronAPI { ): (() => void) => { return () => {}; }, + respondToToolApproval: async (): Promise => { + throw new Error('Tool approval not available in browser mode'); + }, + onToolApprovalEvent: (): (() => void) => { + return () => {}; + }, }; // Review API stubs diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx new file mode 100644 index 00000000..25d2a975 --- /dev/null +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -0,0 +1,237 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useStore } from '@renderer/store'; +import { FileText, Search, Terminal } from 'lucide-react'; + +import type { ToolApprovalRequest } from '@shared/types'; + +// --------------------------------------------------------------------------- +// Tool icon mapping +// --------------------------------------------------------------------------- + +function getToolIcon(toolName: string): React.ReactNode { + const cls = 'size-4 shrink-0'; + switch (toolName) { + case 'Bash': + return ; + case 'Read': + case 'Edit': + case 'Write': + case 'NotebookEdit': + return ; + case 'Grep': + case 'Glob': + return ; + default: + return ; + } +} + +// --------------------------------------------------------------------------- +// Smart input preview +// --------------------------------------------------------------------------- + +function renderToolInput(toolName: string, input: Record): string { + switch (toolName) { + case 'Bash': + return typeof input.command === 'string' ? input.command : JSON.stringify(input, null, 2); + case 'Edit': + case 'Read': + case 'Write': + case 'NotebookEdit': + return typeof input.file_path === 'string' ? input.file_path : JSON.stringify(input, null, 2); + case 'Grep': + case 'Glob': + return typeof input.pattern === 'string' ? input.pattern : JSON.stringify(input, null, 2); + default: + return JSON.stringify(input, null, 2); + } +} + +// --------------------------------------------------------------------------- +// Elapsed timer hook +// --------------------------------------------------------------------------- + +function useElapsed(receivedAt: string): number { + const [elapsed, setElapsed] = useState(() => + Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000)) + ); + + useEffect(() => { + setElapsed(Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000))); + const id = setInterval(() => { + setElapsed(Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000))); + }, 1000); + return () => clearInterval(id); + }, [receivedAt]); + + return elapsed; +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export const ToolApprovalSheet: React.FC = () => { + const pendingApprovals = useStore((s) => s.pendingApprovals); + const respondToToolApproval = useStore((s) => s.respondToToolApproval); + const teams = useStore((s) => s.teams); + + const current: ToolApprovalRequest | undefined = pendingApprovals[0]; + const containerRef = useRef(null); + const [disabled, setDisabled] = useState(false); + + // Auto-focus when new approval arrives + useEffect(() => { + if (current && containerRef.current) { + containerRef.current.focus(); + } + }, [current?.requestId]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleRespond = useCallback( + (allow: boolean) => { + if (!current || disabled) return; + setDisabled(true); + void respondToToolApproval(current.teamName, current.runId, current.requestId, allow).finally( + () => { + setTimeout(() => setDisabled(false), 200); + } + ); + }, + [current, disabled, respondToToolApproval] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleRespond(true); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleRespond(false); + } + }, + [handleRespond] + ); + + if (!current) return null; + + const teamSummary = teams.find((t) => t.teamName === current.teamName); + const teamColor = teamSummary?.color ? getTeamColorSet(teamSummary.color) : null; + + return ( +
+ {/* Header */} +
+
+ {getToolIcon(current.toolName)} + + {current.toolName} + +
+
+ {teamColor ? ( + + {teamSummary?.displayName ?? current.teamName} + + ) : ( + {current.teamName} + )} + +
+
+ + {/* Tool input preview */} +
+
+          {renderToolInput(current.toolName, current.toolInput)}
+        
+
+ + {/* Actions */} +
+
+ + +
+ {pendingApprovals.length > 1 && ( + + {pendingApprovals.length - 1} pending + + )} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Elapsed display sub-component (uses hook) +// --------------------------------------------------------------------------- + +function ElapsedDisplay({ receivedAt }: { receivedAt: string }): React.JSX.Element { + const elapsed = useElapsed(receivedAt); + return ( + {elapsed}s + ); +} diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 056ae1e9..1f94c3fa 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -315,6 +315,7 @@ export const ActivityTimeline = ({ isNew={newItemKeys.has(itemKey)} onVisible={onMessageVisible} zebraShade={zebraShadeSet.has(0)} + forceCollapsed={allCollapsed} /> ); })()} @@ -357,6 +358,7 @@ export const ActivityTimeline = ({ isNew={newItemKeys.has(itemKey)} onVisible={onMessageVisible} zebraShade={zebraShadeSet.has(realIndex)} + forceCollapsed={allCollapsed} /> ); diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index bbfd6a1a..12997803 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { ChevronDown, ChevronUp } from 'lucide-react'; +import { ChevronDown, ChevronRight, ChevronUp } from 'lucide-react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; @@ -86,6 +86,8 @@ interface LeadThoughtsGroupRowProps { canBeLive?: boolean; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; + /** When true, collapse the thought body — show only the header with expand chevron. */ + forceCollapsed?: boolean; } function formatTime(timestamp: string): string { @@ -343,6 +345,7 @@ export const LeadThoughtsGroupRow = ({ onVisible, canBeLive, zebraShade, + forceCollapsed, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); @@ -406,6 +409,17 @@ export const LeadThoughtsGroupRow = ({ const [isLive, setIsLive] = useState(computeIsLive); const [expanded, setExpanded] = useState(false); const [needsTruncation, setNeedsTruncation] = useState(false); + const [isBodyVisible, setIsBodyVisible] = useState(!forceCollapsed); + + // Sync body visibility when the global collapse mode toggles (skip initial mount) + const isFirstRenderRef = useRef(false); + useEffect(() => { + if (!isFirstRenderRef.current) { + isFirstRenderRef.current = true; + return; + } + setIsBodyVisible(!forceCollapsed); + }, [forceCollapsed]); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap @@ -505,7 +519,36 @@ export const LeadThoughtsGroupRow = ({ }} > {/* Header */} -
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */} +
setIsBodyVisible((v) => !v) : undefined} + onKeyDown={ + forceCollapsed === true + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsBodyVisible((v) => !v); + } + } + : undefined + } + > + {/* Chevron for collapse mode */} + {forceCollapsed === true ? ( + + ) : null} {/* Live / offline indicator */} {isLive ? ( @@ -542,34 +585,36 @@ export const LeadThoughtsGroupRow = ({
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */} -
-
- {chronologicalThoughts.map((thought, idx) => ( - 0} - shouldAnimate={isLive && idx === chronologicalThoughts.length - 1} - /> - ))} + {isBodyVisible ? ( +
+
+ {chronologicalThoughts.map((thought, idx) => ( + 0} + shouldAnimate={isLive && idx === chronologicalThoughts.length - 1} + /> + ))} +
-
+ ) : null} - {!expanded && needsTruncation ? ( + {isBodyVisible && !expanded && needsTruncation ? (
) : null} - {expanded && needsTruncation ? ( + {isBodyVisible && expanded && needsTruncation ? (
localStorage.getItem('team:lastExtendedContext') === 'true' ); + const [skipPermissions, setSkipPermissionsRaw] = useState( + () => localStorage.getItem('team:lastSkipPermissions') !== 'false' + ); const setSelectedModel = (value: string): void => { setSelectedModelRaw(value); @@ -244,6 +248,11 @@ export const CreateTeamDialog = ({ localStorage.setItem('team:lastExtendedContext', String(value)); }; + const setSkipPermissions = (value: boolean): void => { + setSkipPermissionsRaw(value); + localStorage.setItem('team:lastSkipPermissions', String(value)); + }; + const resetUIState = (): void => { setLocalError(null); setFieldErrors({}); @@ -473,6 +482,7 @@ export const CreateTeamDialog = ({ cwd: effectiveCwd, prompt: prompt.trim() || undefined, model: effectiveModel, + skipPermissions, }), [ sanitizedTeamName, @@ -483,6 +493,7 @@ export const CreateTeamDialog = ({ effectiveCwd, prompt, effectiveModel, + skipPermissions, ] ); @@ -801,6 +812,13 @@ export const CreateTeamDialog = ({ onCheckedChange={setExtendedContext} disabled={selectedModel === 'haiku'} /> + {launchTeam && ( + + )}
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? ( diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index adfadeec..693ca2e2 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox'; +import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { @@ -78,6 +79,9 @@ export const LaunchTeamDialog = ({ const [extendedContext, setExtendedContextRaw] = useState( () => localStorage.getItem('team:lastExtendedContext') === 'true' ); + const [skipPermissions, setSkipPermissionsRaw] = useState( + () => localStorage.getItem('team:lastSkipPermissions') !== 'false' + ); const [clearContext, setClearContext] = useState(false); const [conflictDismissed, setConflictDismissed] = useState(false); @@ -91,6 +95,11 @@ export const LaunchTeamDialog = ({ localStorage.setItem('team:lastExtendedContext', String(value)); }; + const setSkipPermissions = (value: boolean): void => { + setSkipPermissionsRaw(value); + localStorage.setItem('team:lastSkipPermissions', String(value)); + }; + const resetFormState = (): void => { setLocalError(null); setIsSubmitting(false); @@ -283,6 +292,7 @@ export const LaunchTeamDialog = ({ prompt: promptDraft.value.trim() || undefined, model: computeEffectiveTeamModel(selectedModel, extendedContext), clearContext: clearContext || undefined, + skipPermissions, }); resetFormState(); onClose(); @@ -431,6 +441,11 @@ export const LaunchTeamDialog = ({ onCheckedChange={setExtendedContext} disabled={selectedModel === 'haiku'} /> +
diff --git a/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx new file mode 100644 index 00000000..8c6e8c80 --- /dev/null +++ b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import { Checkbox } from '@renderer/components/ui/checkbox'; +import { Label } from '@renderer/components/ui/label'; +import { AlertTriangle, Info } from 'lucide-react'; + +interface SkipPermissionsCheckboxProps { + id: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +} + +export const SkipPermissionsCheckbox: React.FC = ({ + id, + checked, + onCheckedChange, +}) => ( + <> +
+ onCheckedChange(value === true)} + /> + +
+ {checked ? ( +
+
+ +

+ Autonomous mode — all tools execute without confirmation. Be cautious with untrusted + code. +

+
+
+ ) : ( +
+
+ +

Manual mode — you'll approve or deny each tool call in real-time.

+
+
+ )} + +); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 7ddff600..62e5fedc 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -32,6 +32,9 @@ import type { CliInstallerProgress, LeadContextUsage, TeamChangeEvent, + ToolApprovalDismiss, + ToolApprovalEvent, + ToolApprovalRequest, UpdaterStatus, } from '@shared/types'; @@ -444,6 +447,29 @@ export function initializeNotificationListeners(): () => void { } } + // Tool approval events from CLI control_request protocol + if (api.teams?.onToolApprovalEvent) { + const cleanup = api.teams.onToolApprovalEvent((_event: unknown, data: unknown) => { + const event = data as ToolApprovalEvent; + if ('dismissed' in event && event.dismissed) { + const dismiss = event as ToolApprovalDismiss; + useStore.setState((s) => ({ + pendingApprovals: s.pendingApprovals.filter( + (a) => !(a.teamName === dismiss.teamName && a.runId === dismiss.runId) + ), + })); + } else { + const request = event as ToolApprovalRequest; + useStore.setState((s) => ({ + pendingApprovals: [...s.pendingApprovals, request], + })); + } + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + // Listen for editor file change events (chokidar watcher → renderer) if (api.editor?.onEditorChange) { const cleanup = api.editor.onEditorChange((event) => { diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 204f0fe2..21370d07 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -81,6 +81,7 @@ import type { TeamSummary, TeamTask, TeamTaskStatus, + ToolApprovalRequest, UpdateKanbanPatch, } from '@shared/types'; import type { StateCreator } from 'zustand'; @@ -353,6 +354,14 @@ export interface TeamSlice { onProvisioningProgress: (progress: TeamProvisioningProgress) => void; subscribeProvisioningProgress: () => void; unsubscribeProvisioningProgress: () => void; + pendingApprovals: ToolApprovalRequest[]; + respondToToolApproval: ( + teamName: string, + runId: string, + requestId: string, + allow: boolean, + message?: string + ) => Promise; } export const createTeamSlice: StateCreator = (set, get) => ({ @@ -394,6 +403,7 @@ export const createTeamSlice: StateCreator = (set, provisioningProgressUnsubscribe: null, deletedTasks: [], deletedTasksLoading: false, + pendingApprovals: [], fetchBranches: async (paths: string[]) => { const results: Record = {}; @@ -1158,6 +1168,21 @@ export const createTeamSlice: StateCreator = (set, set({ provisioningProgressUnsubscribe: unsubscribe }); }, + respondToToolApproval: async (teamName, runId, requestId, allow, message) => { + try { + await api.teams.respondToToolApproval(teamName, runId, requestId, allow, message); + // Remove ONLY after successful IPC, by runId+requestId pair + set((s) => ({ + pendingApprovals: s.pendingApprovals.filter( + (a) => !(a.runId === runId && a.requestId === requestId) + ), + })); + } catch { + // IPC failed — approval stays in UI, user can retry + // Do NOT modify pendingApprovals — nothing was removed, nothing to rollback + } + }, + unsubscribeProvisioningProgress: () => { const unsubscribe = get().provisioningProgressUnsubscribe; if (unsubscribe) { diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 2b43e502..616ef726 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -60,6 +60,7 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + ToolApprovalEvent, UpdateKanbanPatch, } from './team'; import type { TerminalAPI } from './terminal'; @@ -510,6 +511,14 @@ export interface TeamsAPI { onProvisioningProgress: ( callback: (event: unknown, data: TeamProvisioningProgress) => void ) => () => void; + respondToToolApproval: ( + teamName: string, + runId: string, + requestId: string, + allow: boolean, + message?: string + ) => Promise; + onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void; } // ============================================================================= diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 660eafb4..d87cc48f 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -303,6 +303,8 @@ export interface TeamLaunchRequest { model?: string; /** When true, skip --resume and start a fresh session (clears context memory). */ clearContext?: boolean; + /** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */ + skipPermissions?: boolean; } export interface TeamLaunchResponse { @@ -383,6 +385,8 @@ export interface TeamCreateRequest { cwd: string; prompt?: string; model?: string; + /** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */ + skipPermissions?: boolean; } export interface TeamCreateConfigRequest { @@ -519,3 +523,34 @@ export interface TeamMessageNotificationData { /** Optional sender color for visual context. */ color?: string; } + +// ============================================================================= +// Tool Approval (control_request / control_response protocol) +// ============================================================================= + +/** A pending tool approval request from the CLI control_request protocol. */ +export interface ToolApprovalRequest { + requestId: string; + /** Run ID — prevents stale approvals after stop→launch race. */ + runId: string; + teamName: string; + /** Which process sent this (e.g. 'lead'). */ + source: string; + /** Tool name: 'Bash', 'Edit', 'Write', 'Read', etc. */ + toolName: string; + /** Tool input parameters (e.g. { command: "ls" } for Bash). */ + toolInput: Record; + /** ISO timestamp when the request was received. */ + receivedAt: string; +} + +/** Dismissal event — process died, all pending approvals for this team+run should be removed. */ +export interface ToolApprovalDismiss { + dismissed: true; + teamName: string; + /** Only dismiss approvals from this specific run. */ + runId: string; +} + +/** Union of approval events pushed from main to renderer. */ +export type ToolApprovalEvent = ToolApprovalRequest | ToolApprovalDismiss; From 964a8772ea0720fd47d70be57e887c1bc3bb047b Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 6 Mar 2026 21:28:12 +0200 Subject: [PATCH 04/15] feat: add effort level management to team provisioning and dialogs - Introduced EffortLevel type to define valid effort levels ('low', 'medium', 'high'). - Updated TeamCreateRequest and TeamLaunchRequest interfaces to include effort parameter. - Enhanced CreateTeamDialog and LaunchTeamDialog to allow users to select effort levels. - Integrated effort level handling in provisioning requests and responses within TeamProvisioningService. - Removed auto-focus effect in ToolApprovalSheet for improved user experience. --- src/main/ipc/teams.ts | 9 ++++ .../services/team/TeamProvisioningService.ts | 41 ++++++++++++++- .../components/team/ToolApprovalSheet.tsx | 7 --- .../components/team/activity/ActivityItem.tsx | 8 ++- .../team/dialogs/CreateTeamDialog.tsx | 17 +++++++ .../team/dialogs/EffortLevelSelector.tsx | 51 +++++++++++++++++++ .../team/dialogs/LaunchTeamDialog.tsx | 16 ++++++ src/renderer/hooks/useTeamMessagesExpanded.ts | 34 +++++++++++++ .../utils/teamMessageExpandStorage.ts | 39 ++++++++++++++ src/shared/types/team.ts | 4 ++ 10 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 src/renderer/components/team/dialogs/EffortLevelSelector.tsx create mode 100644 src/renderer/hooks/useTeamMessagesExpanded.ts create mode 100644 src/renderer/utils/teamMessageExpandStorage.ts diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index ff6034b3..434f761b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -96,6 +96,7 @@ import type { AttachmentMeta, AttachmentPayload, CreateTaskRequest, + EffortLevel, GlobalTask, IpcResult, KanbanColumnId, @@ -548,6 +549,12 @@ function isProvisioningTeamName(teamName: string): boolean { return parts.every((p) => /^[a-z0-9]+$/.test(p)); } +const VALID_EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high']; + +function isValidEffort(value: unknown): value is EffortLevel { + return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value); +} + async function validateProvisioningRequest( request: unknown ): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> { @@ -645,6 +652,7 @@ async function validateProvisioningRequest( cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, + effort: isValidEffort(payload.effort) ? payload.effort : undefined, skipPermissions: typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, }, @@ -751,6 +759,7 @@ async function handleLaunchTeam( cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, + effort: isValidEffort(payload.effort) ? payload.effort : undefined, clearContext: payload.clearContext === true ? true : undefined, skipPermissions: typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index cb6e82ff..292ce178 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1804,6 +1804,7 @@ export class TeamProvisioningService { 'TeamDelete,TodoWrite', ...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []), ...(request.model ? ['--model', request.model] : []), + ...(request.effort ? ['--effort', request.effort] : []), ]; try { child = spawnCli(claudePath, spawnArgs, { @@ -2137,6 +2138,9 @@ export class TeamProvisioningService { if (request.model) { launchArgs.push('--model', request.model); } + if (request.effort) { + launchArgs.push('--effort', request.effort); + } // New sessions: CLI creates its own ID. No --resume with synthetic name — docs say // --resume is for existing sessions and may show an interactive picker if not found. @@ -3222,7 +3226,8 @@ export class TeamProvisioningService { /** * Handles a control_request message from CLI stream-json output. - * Only `can_use_tool` subtype is processed — others are logged and ignored. + * `can_use_tool` → emits to renderer for manual approval. + * All other subtypes (hook_callback, etc.) → auto-allowed to prevent deadlock. */ private handleControlRequest(run: ProvisioningRun, msg: Record): void { const requestId = typeof msg.request_id === 'string' ? msg.request_id : null; @@ -3233,10 +3238,14 @@ export class TeamProvisioningService { const request = msg.request as Record | undefined; const subtype = request?.subtype; + + // Non-`can_use_tool` subtypes (hook_callback, etc.) are auto-allowed to prevent + // CLI deadlock — hooks are user-configured and should not block on manual approval. if (subtype !== 'can_use_tool') { logger.debug( - `[${run.teamName}] control_request subtype=${String(subtype)}, ignoring (only can_use_tool supported)` + `[${run.teamName}] control_request subtype=${String(subtype)}, auto-allowing to prevent deadlock` ); + this.autoAllowControlRequest(run, requestId); return; } @@ -3257,6 +3266,34 @@ export class TeamProvisioningService { this.emitToolApprovalEvent(approval); } + /** + * Immediately sends an "allow" control_response for a non-tool control_request. + * Prevents CLI deadlock for hook_callback and other non-`can_use_tool` subtypes. + */ + private autoAllowControlRequest(run: ProvisioningRun, requestId: string): void { + if (!run.child?.stdin?.writable) { + logger.warn(`[${run.teamName}] Cannot auto-allow control_request: stdin not writable`); + return; + } + + const response = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { behavior: 'allow' }, + }, + }; + + run.child.stdin.write(JSON.stringify(response) + '\n', (err) => { + if (err) { + logger.error( + `[${run.teamName}] Failed to auto-allow control_request ${requestId}: ${err.message}` + ); + } + }); + } + /** * Respond to a pending tool approval — sends control_response to CLI stdin. * Validates runId match and requestId existence before writing. diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 25d2a975..4894939a 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -82,13 +82,6 @@ export const ToolApprovalSheet: React.FC = () => { const containerRef = useRef(null); const [disabled, setDisabled] = useState(false); - // Auto-focus when new approval arrives - useEffect(() => { - if (current && containerRef.current) { - containerRef.current.focus(); - } - }, [current?.requestId]); // eslint-disable-line react-hooks/exhaustive-deps - const handleRespond = useCallback( (allow: boolean) => { if (!current || disabled) return; diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index cce545a8..b8093241 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -54,6 +54,8 @@ interface ActivityItemProps { zebraShade?: boolean; /** When true, collapse message body — show only header with expand chevron. */ forceCollapsed?: boolean; + /** Called when user toggles expand/collapse in collapsed mode. Presence enables chevron. */ + onCollapseToggle?: () => void; } function getStringField(obj: StructuredMessage, key: string): string | null { @@ -216,6 +218,7 @@ export const ActivityItem = ({ onRestartTeam, zebraShade, forceCollapsed, + onCollapseToggle, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const formattedRole = formatAgentRole(memberRole); @@ -295,8 +298,9 @@ export const ActivityItem = ({ onCreateTask?.(subject, description); }; - const isHeaderClickable = Boolean(systemLabel) || forceCollapsed === true; - const showChevron = Boolean(systemLabel) || forceCollapsed === true; + const isHeaderClickable = + Boolean(systemLabel) || forceCollapsed === true || onCollapseToggle != null; + const showChevron = isHeaderClickable; const isUserSent = message.source === 'user_sent'; const isSystemMessage = message.from === 'system'; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 0b8993c5..94b5bc88 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -30,6 +30,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { getMemberColor } from '@shared/constants/memberColors'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; +import { EffortLevelSelector } from './EffortLevelSelector'; import { ExtendedContextCheckbox } from './ExtendedContextCheckbox'; import { ProjectPathSelector } from './ProjectPathSelector'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; @@ -50,6 +51,7 @@ const TEAM_COLOR_NAMES = [ import type { MentionSuggestion } from '@renderer/types/mention'; import type { + EffortLevel, Project, TeamCreateRequest, TeamProvisioningMemberInput, @@ -237,6 +239,9 @@ export const CreateTeamDialog = ({ const [skipPermissions, setSkipPermissionsRaw] = useState( () => localStorage.getItem('team:lastSkipPermissions') !== 'false' ); + const [selectedEffort, setSelectedEffortRaw] = useState( + () => localStorage.getItem('team:lastSelectedEffort') ?? '' + ); const setSelectedModel = (value: string): void => { setSelectedModelRaw(value); @@ -253,6 +258,11 @@ export const CreateTeamDialog = ({ localStorage.setItem('team:lastSkipPermissions', String(value)); }; + const setSelectedEffort = (value: string): void => { + setSelectedEffortRaw(value); + localStorage.setItem('team:lastSelectedEffort', value); + }; + const resetUIState = (): void => { setLocalError(null); setFieldErrors({}); @@ -482,6 +492,7 @@ export const CreateTeamDialog = ({ cwd: effectiveCwd, prompt: prompt.trim() || undefined, model: effectiveModel, + effort: (selectedEffort as EffortLevel) || undefined, skipPermissions, }), [ @@ -493,6 +504,7 @@ export const CreateTeamDialog = ({ effectiveCwd, prompt, effectiveModel, + selectedEffort, skipPermissions, ] ); @@ -806,6 +818,11 @@ export const CreateTeamDialog = ({ onValueChange={setSelectedModel} id="create-model" /> + void; + id?: string; +} + +export const EffortLevelSelector: React.FC = ({ + value, + onValueChange, + id, +}) => ( +
+ +
+ {EFFORT_OPTIONS.map((opt) => ( + + ))} +
+

+ Controls how much reasoning Claude invests before responding. Default uses Claude's + standard behavior. +

+
+); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 693ca2e2..37160b57 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -24,12 +24,14 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, X } from 'lucide-react'; +import { EffortLevelSelector } from './EffortLevelSelector'; import { ProjectPathSelector } from './ProjectPathSelector'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import type { ActiveTeamRef } from './CreateTeamDialog'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { + EffortLevel, Project, ResolvedTeamMember, TeamLaunchRequest, @@ -82,6 +84,9 @@ export const LaunchTeamDialog = ({ const [skipPermissions, setSkipPermissionsRaw] = useState( () => localStorage.getItem('team:lastSkipPermissions') !== 'false' ); + const [selectedEffort, setSelectedEffortRaw] = useState( + () => localStorage.getItem('team:lastSelectedEffort') ?? '' + ); const [clearContext, setClearContext] = useState(false); const [conflictDismissed, setConflictDismissed] = useState(false); @@ -100,6 +105,11 @@ export const LaunchTeamDialog = ({ localStorage.setItem('team:lastSkipPermissions', String(value)); }; + const setSelectedEffort = (value: string): void => { + setSelectedEffortRaw(value); + localStorage.setItem('team:lastSelectedEffort', value); + }; + const resetFormState = (): void => { setLocalError(null); setIsSubmitting(false); @@ -291,6 +301,7 @@ export const LaunchTeamDialog = ({ cwd: effectiveCwd, prompt: promptDraft.value.trim() || undefined, model: computeEffectiveTeamModel(selectedModel, extendedContext), + effort: (selectedEffort as EffortLevel) || undefined, clearContext: clearContext || undefined, skipPermissions, }); @@ -435,6 +446,11 @@ export const LaunchTeamDialog = ({ onValueChange={setSelectedModel} id="launch-model" /> + ; + toggle: (messageKey: string) => void; +} { + const [version, setVersion] = useState(0); + const expandedSet = useMemo(() => { + if (version < 0) return new Set(); + return teamName ? getExpandedOverrides(teamName) : new Set(); + }, [teamName, version]); + + const toggle = useCallback( + (messageKey: string) => { + if (!teamName) return; + const existing = getExpandedOverrides(teamName); + if (existing.has(messageKey)) { + removeExpanded(teamName, messageKey); + } else { + addExpanded(teamName, messageKey); + } + setVersion((v) => v + 1); + }, + [teamName] + ); + + return { expandedSet, toggle }; +} diff --git a/src/renderer/utils/teamMessageExpandStorage.ts b/src/renderer/utils/teamMessageExpandStorage.ts new file mode 100644 index 00000000..d97626e1 --- /dev/null +++ b/src/renderer/utils/teamMessageExpandStorage.ts @@ -0,0 +1,39 @@ +const STORAGE_PREFIX = 'team-msg-expanded:'; + +function storageKey(teamName: string): string { + return `${STORAGE_PREFIX}${teamName}`; +} + +export function getExpandedOverrides(teamName: string): Set { + try { + const raw = localStorage.getItem(storageKey(teamName)); + if (!raw) return new Set(); + const arr = JSON.parse(raw) as unknown; + if (!Array.isArray(arr)) return new Set(); + return new Set(arr.filter((x): x is string => typeof x === 'string')); + } catch { + return new Set(); + } +} + +export function addExpanded(teamName: string, messageKey: string): void { + try { + const set = getExpandedOverrides(teamName); + if (set.has(messageKey)) return; + set.add(messageKey); + localStorage.setItem(storageKey(teamName), JSON.stringify([...set])); + } catch { + // quota or disabled + } +} + +export function removeExpanded(teamName: string, messageKey: string): void { + try { + const set = getExpandedOverrides(teamName); + if (!set.has(messageKey)) return; + set.delete(messageKey); + localStorage.setItem(storageKey(teamName), JSON.stringify([...set])); + } catch { + // quota or disabled + } +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index d87cc48f..5ce1986e 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -296,11 +296,14 @@ export interface TeamData { isAlive?: boolean; } +export type EffortLevel = 'low' | 'medium' | 'high'; + export interface TeamLaunchRequest { teamName: string; cwd: string; prompt?: string; model?: string; + effort?: EffortLevel; /** When true, skip --resume and start a fresh session (clears context memory). */ clearContext?: boolean; /** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */ @@ -385,6 +388,7 @@ export interface TeamCreateRequest { cwd: string; prompt?: string; model?: string; + effort?: EffortLevel; /** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */ skipPermissions?: boolean; } From c09ab76d43fcb8bd701a410b9304828da37ba199 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 6 Mar 2026 21:45:20 +0200 Subject: [PATCH 05/15] feat: enhance message collapsing functionality in team activity components - Added support for user-controlled message expansion in ActivityTimeline and LeadThoughtsGroup components. - Integrated expand/collapse overrides to allow users to manually expand messages even in collapsed mode. - Updated ActivityItem to trigger collapse toggle callbacks on click and key events for improved accessibility. - Refactored related components to accommodate new expand/collapse logic, enhancing user experience in message handling. --- .../components/team/TeamDetailView.tsx | 4 ++ .../components/team/activity/ActivityItem.tsx | 10 ++- .../team/activity/ActivityTimeline.tsx | 71 +++++++++++++++++-- .../team/activity/LeadThoughtsGroup.tsx | 23 ++++-- 4 files changed, 97 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 4ba59754..afcdc0bf 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -17,6 +17,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; +import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; @@ -627,6 +628,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele }, [data, timeWindow, messagesFilter, messagesSearchQuery, leadMemberName]); const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? ''); + const { expandedSet, toggle: toggleExpandOverride } = useTeamMessagesExpanded(teamName ?? ''); const messagesUnreadCount = useMemo( () => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length, [filteredMessages, readSet] @@ -1562,6 +1564,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele members={data.members} readState={{ readSet, getMessageKey: toMessageKey }} allCollapsed={messagesCollapsed} + expandOverrides={expandedSet} + onToggleExpandOverride={toggleExpandOverride} onMemberClick={setSelectedMember} onCreateTaskFromMessage={(subject, description) => { openCreateTaskDialog(subject, description); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index b8093241..0cd1d3dc 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -340,13 +340,21 @@ export const ActivityItem = ({ 'flex items-center gap-2 px-3 py-2', isHeaderClickable ? 'cursor-pointer select-none' : '', ].join(' ')} - onClick={isHeaderClickable ? () => setIsExpanded((v) => !v) : undefined} + onClick={ + isHeaderClickable + ? () => { + setIsExpanded((v) => !v); + onCollapseToggle?.(); + } + : undefined + } onKeyDown={ isHeaderClickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setIsExpanded((v) => !v); + onCollapseToggle?.(); } } : undefined diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 1f94c3fa..f65c9248 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; @@ -28,6 +29,10 @@ interface ActivityTimelineProps { onRestartTeam?: () => void; /** When true, collapse all message bodies — show only headers with expand chevrons. */ allCollapsed?: boolean; + /** Set of stable message keys that the user has manually expanded in collapsed mode. */ + expandOverrides?: Set; + /** Called when user toggles expand/collapse override on a specific message. */ + onToggleExpandOverride?: (key: string) => void; } const VIEWPORT_THRESHOLD = 0.15; @@ -50,6 +55,7 @@ const MessageRowWithObserver = ({ onTaskIdClick, onRestartTeam, forceCollapsed, + onCollapseToggle, }: { message: InboxMessage; teamName: string; @@ -67,6 +73,7 @@ const MessageRowWithObserver = ({ onTaskIdClick?: (taskId: string) => void; onRestartTeam?: () => void; forceCollapsed?: boolean; + onCollapseToggle?: () => void; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -115,6 +122,7 @@ const MessageRowWithObserver = ({ onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} forceCollapsed={forceCollapsed} + onCollapseToggle={onCollapseToggle} />
); @@ -132,6 +140,8 @@ export const ActivityTimeline = ({ onTaskIdClick, onRestartTeam, allCollapsed, + expandOverrides, + onToggleExpandOverride, }: ActivityTimelineProps): React.JSX.Element => { const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); @@ -297,6 +307,50 @@ export const ActivityTimeline = ({ const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null; const startIndex = pinnedThoughtGroup ? 1 : 0; + // Determine the index of the "newest" non-thought timeline item (for auto-expand). + // Pinned thought group is always at index 0 when present, so newest message is the + // first non-thought item in the remaining list. + const newestMessageIndex = useMemo(() => { + for (let i = startIndex; i < timelineItems.length; i++) { + if (timelineItems[i].type !== 'lead-thoughts') return i; + } + return -1; + }, [timelineItems, startIndex]); + + /** + * Compute per-item forceCollapsed + onCollapseToggle based on: + * - allCollapsed mode enabled/disabled + * - Whether this is the newest message (auto-expanded, no chevron) + * - Whether user has manually expanded this item (override in localStorage) + * + * | allCollapsed | isNewest | inOverrides | forceCollapsed | onCollapseToggle | + * |-------------|----------|-------------|----------------|------------------| + * | false | any | any | undefined | undefined | + * | true | yes | any | undefined | undefined | + * | true | no | yes | false | fn | + * | true | no | no | true | fn | + */ + const getItemCollapseProps = useCallback( + ( + stableKey: string, + itemIndex: number + ): { forceCollapsed?: boolean; onCollapseToggle?: () => void } => { + if (!allCollapsed) return {}; + if (itemIndex === newestMessageIndex) return {}; + // Pinned thought group (index 0) is always the newest thought → expanded + if (itemIndex === 0 && pinnedThoughtGroup) return {}; + + const isOverridden = expandOverrides?.has(stableKey) ?? false; + return { + forceCollapsed: !isOverridden, + onCollapseToggle: onToggleExpandOverride + ? () => onToggleExpandOverride(stableKey) + : undefined, + }; + }, + [allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride] + ); + return (
{/* Pinned (newest) thought group — always at top */} @@ -306,6 +360,8 @@ export const ActivityTimeline = ({ const firstThought = group.thoughts[0]; const info = memberInfo.get(firstThought.from); const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`; + const stableKey = toMessageKey(firstThought); + const collapseProps = getItemCollapseProps(stableKey, 0); return ( ); })()} @@ -348,6 +405,8 @@ export const ActivityTimeline = ({ const firstThought = group.thoughts[0]; const info = memberInfo.get(firstThought.from); const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`; + const stableKey = toMessageKey(firstThought); + const collapseProps = getItemCollapseProps(stableKey, realIndex); return ( {sessionSeparator} @@ -358,7 +417,8 @@ export const ActivityTimeline = ({ isNew={newItemKeys.has(itemKey)} onVisible={onMessageVisible} zebraShade={zebraShadeSet.has(realIndex)} - forceCollapsed={allCollapsed} + forceCollapsed={collapseProps.forceCollapsed} + onCollapseToggle={collapseProps.onCollapseToggle} /> ); @@ -370,6 +430,8 @@ export const ActivityTimeline = ({ const recipientColor = recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined); const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`; + const stableKey = toMessageKey(message); + const collapseProps = getItemCollapseProps(stableKey, realIndex); const isUnread = readState ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) : !message.read; @@ -392,7 +454,8 @@ export const ActivityTimeline = ({ onVisible={onMessageVisible} onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} - forceCollapsed={allCollapsed} + forceCollapsed={collapseProps.forceCollapsed} + onCollapseToggle={collapseProps.onCollapseToggle} /> ); diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 12997803..83690d93 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -88,6 +88,8 @@ interface LeadThoughtsGroupRowProps { zebraShade?: boolean; /** When true, collapse the thought body — show only the header with expand chevron. */ forceCollapsed?: boolean; + /** Called when user toggles expand/collapse in collapsed mode. Presence enables chevron. */ + onCollapseToggle?: () => void; } function formatTime(timestamp: string): string { @@ -346,6 +348,7 @@ export const LeadThoughtsGroupRow = ({ canBeLive, zebraShade, forceCollapsed, + onCollapseToggle, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); @@ -521,26 +524,34 @@ export const LeadThoughtsGroupRow = ({ {/* Header */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
setIsBodyVisible((v) => !v) : undefined} + onClick={ + forceCollapsed === true || onCollapseToggle != null + ? () => { + setIsBodyVisible((v) => !v); + onCollapseToggle?.(); + } + : undefined + } onKeyDown={ - forceCollapsed === true + forceCollapsed === true || onCollapseToggle != null ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setIsBodyVisible((v) => !v); + onCollapseToggle?.(); } } : undefined } > {/* Chevron for collapse mode */} - {forceCollapsed === true ? ( + {forceCollapsed === true || onCollapseToggle != null ? ( Date: Fri, 6 Mar 2026 23:21:56 +0200 Subject: [PATCH 06/15] feat: enhance UI and functionality in team dialogs and components - Improved lightbox toolbar button hit targets for better accessibility. - Updated ActivityItem and ActivityTimeline components to support managed collapse states for messages. - Refactored message collapsing logic to allow for user-controlled expansion in various components. - Enhanced CreateTeamDialog and LaunchTeamDialog with improved loading indicators and layout adjustments. - Increased maximum message length in SendMessageDialog to accommodate larger inputs. - Added icons and visual enhancements in ProjectPathSelector and EffortLevelSelector for better user experience. --- .../components/team/TeamDetailView.tsx | 2 +- .../components/team/activity/ActivityItem.tsx | 48 +- .../team/activity/ActivityTimeline.tsx | 75 +-- .../team/activity/LeadThoughtsGroup.tsx | 146 ++++-- .../components/team/activity/collapseState.ts | 66 +++ .../team/attachments/ImageLightbox.tsx | 1 + .../team/dialogs/CreateTeamDialog.tsx | 138 +++--- .../team/dialogs/EffortLevelSelector.tsx | 38 +- .../team/dialogs/ExtendedContextCheckbox.tsx | 2 +- .../team/dialogs/LaunchTeamDialog.tsx | 114 ++--- .../team/dialogs/ProjectPathSelector.tsx | 71 +-- .../team/dialogs/SendMessageDialog.tsx | 2 +- .../team/dialogs/SkipPermissionsCheckbox.tsx | 4 +- .../team/dialogs/TaskDetailDialog.tsx | 14 +- .../components/team/members/MemberLogsTab.tsx | 40 +- .../members/SubagentRecentMessagesPreview.tsx | 89 ++-- .../team/messages/MessageComposer.tsx | 74 ++- src/renderer/hooks/useComposerDraft.ts | 448 ++++++++++++++++++ src/renderer/index.css | 11 + src/renderer/services/composerDraftStorage.ts | 270 +++++++++++ .../team/activity/collapseState.test.ts | 116 +++++ test/renderer/hooks/useComposerDraft.test.ts | 437 +++++++++++++++++ .../utils/teamMessageExpandStorage.test.ts | 43 ++ 23 files changed, 1864 insertions(+), 385 deletions(-) create mode 100644 src/renderer/components/team/activity/collapseState.ts create mode 100644 src/renderer/hooks/useComposerDraft.ts create mode 100644 src/renderer/services/composerDraftStorage.ts create mode 100644 test/renderer/components/team/activity/collapseState.test.ts create mode 100644 test/renderer/hooks/useComposerDraft.test.ts create mode 100644 test/renderer/utils/teamMessageExpandStorage.test.ts diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index afcdc0bf..307f9181 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -309,7 +309,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele showNoise: false, }); const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); - const [messagesCollapsed, setMessagesCollapsed] = useState(false); + const [messagesCollapsed, setMessagesCollapsed] = useState(true); // Open editor overlay when a file reveal is requested (e.g. from chip click) const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 0cd1d3dc..2d935969 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; @@ -26,8 +26,10 @@ import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { AlertTriangle, ChevronRight, ListPlus, RefreshCw, Reply } from 'lucide-react'; +import { isManagedCollapseState } from './collapseState'; import { ReplyQuoteBlock } from './ReplyQuoteBlock'; +import type { ActivityCollapseState } from './collapseState'; import type { TeamColorSet } from '@renderer/constants/teamColors'; import type { InboxMessage } from '@shared/types'; @@ -52,10 +54,8 @@ interface ActivityItemProps { onRestartTeam?: () => void; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; - /** When true, collapse message body — show only header with expand chevron. */ - forceCollapsed?: boolean; - /** Called when user toggles expand/collapse in collapsed mode. Presence enables chevron. */ - onCollapseToggle?: () => void; + /** Explicit collapse state for timeline-controlled collapsed mode. */ + collapseState?: ActivityCollapseState; } function getStringField(obj: StructuredMessage, key: string): string | null { @@ -217,8 +217,7 @@ export const ActivityItem = ({ onTaskIdClick, onRestartTeam, zebraShade, - forceCollapsed, - onCollapseToggle, + collapseState, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const formattedRole = formatAgentRole(memberRole); @@ -237,19 +236,9 @@ export const ActivityItem = ({ // Never collapse rate limit messages as noise — they must be visible const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null; - // System/automated messages start collapsed (but not rate limits) const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; - const [isExpanded, setIsExpanded] = useState(!systemLabel && !forceCollapsed); - - // Sync expand/collapse when the global collapse mode toggles (skip initial mount) - const isFirstRender = useRef(true); - useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false; - return; - } - setIsExpanded(forceCollapsed ? false : !systemLabel); - }, [forceCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps -- systemLabel is stable (derived from message.text) + const isManaged = isManagedCollapseState(collapseState); + const isExpanded = isManaged ? !collapseState.isCollapsed : true; // Strip agent-only blocks + normalize escape sequences (before linkification) const strippedText = useMemo(() => { @@ -298,11 +287,16 @@ export const ActivityItem = ({ onCreateTask?.(subject, description); }; - const isHeaderClickable = - Boolean(systemLabel) || forceCollapsed === true || onCollapseToggle != null; + const isHeaderClickable = isManaged ? collapseState.canToggle : false; const showChevron = isHeaderClickable; const isUserSent = message.source === 'user_sent'; const isSystemMessage = message.from === 'system'; + const onManagedToggle = isManaged ? collapseState.onToggle : undefined; + const handleHeaderToggle = isHeaderClickable + ? (): void => { + onManagedToggle?.(); + } + : undefined; return (
{ - setIsExpanded((v) => !v); - onCollapseToggle?.(); - } - : undefined - } + onClick={handleHeaderToggle} onKeyDown={ isHeaderClickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - setIsExpanded((v) => !v); - onCollapseToggle?.(); + handleHeaderToggle?.(); } } : undefined diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index f65c9248..bc8eeff3 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -4,9 +4,11 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; +import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState'; import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; import type { TimelineItem } from './LeadThoughtsGroup'; +import type { ActivityCollapseState } from './collapseState'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; interface ActivityTimelineProps { @@ -54,8 +56,7 @@ const MessageRowWithObserver = ({ onVisible, onTaskIdClick, onRestartTeam, - forceCollapsed, - onCollapseToggle, + collapseState, }: { message: InboxMessage; teamName: string; @@ -72,8 +73,7 @@ const MessageRowWithObserver = ({ onVisible?: (message: InboxMessage) => void; onTaskIdClick?: (taskId: string) => void; onRestartTeam?: () => void; - forceCollapsed?: boolean; - onCollapseToggle?: () => void; + collapseState?: ActivityCollapseState; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -121,8 +121,7 @@ const MessageRowWithObserver = ({ onReply={onReply} onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} - forceCollapsed={forceCollapsed} - onCollapseToggle={onCollapseToggle} + collapseState={collapseState} />
); @@ -308,46 +307,27 @@ export const ActivityTimeline = ({ const startIndex = pinnedThoughtGroup ? 1 : 0; // Determine the index of the "newest" non-thought timeline item (for auto-expand). - // Pinned thought group is always at index 0 when present, so newest message is the - // first non-thought item in the remaining list. const newestMessageIndex = useMemo(() => { - for (let i = startIndex; i < timelineItems.length; i++) { - if (timelineItems[i].type !== 'lead-thoughts') return i; - } - return -1; - }, [timelineItems, startIndex]); + return findNewestMessageIndex(timelineItems); + }, [timelineItems]); /** - * Compute per-item forceCollapsed + onCollapseToggle based on: - * - allCollapsed mode enabled/disabled - * - Whether this is the newest message (auto-expanded, no chevron) - * - Whether user has manually expanded this item (override in localStorage) - * - * | allCollapsed | isNewest | inOverrides | forceCollapsed | onCollapseToggle | - * |-------------|----------|-------------|----------------|------------------| - * | false | any | any | undefined | undefined | - * | true | yes | any | undefined | undefined | - * | true | no | yes | false | fn | - * | true | no | no | true | fn | + * Compute the externally managed collapse state for an item in the timeline. + * In collapsed mode we always keep the newest real message open, keep the pinned + * thought group open, and let localStorage overrides reopen older items. */ - const getItemCollapseProps = useCallback( - ( - stableKey: string, - itemIndex: number - ): { forceCollapsed?: boolean; onCollapseToggle?: () => void } => { - if (!allCollapsed) return {}; - if (itemIndex === newestMessageIndex) return {}; - // Pinned thought group (index 0) is always the newest thought → expanded - if (itemIndex === 0 && pinnedThoughtGroup) return {}; - - const isOverridden = expandOverrides?.has(stableKey) ?? false; - return { - forceCollapsed: !isOverridden, - onCollapseToggle: onToggleExpandOverride + const getItemCollapseState = useCallback( + (stableKey: string, itemIndex: number): ActivityCollapseState => + resolveTimelineCollapseState({ + allCollapsed, + itemIndex, + newestMessageIndex, + isPinnedThoughtGroup: itemIndex === 0 && pinnedThoughtGroup != null, + isExpandedOverride: expandOverrides?.has(stableKey) ?? false, + onToggleOverride: onToggleExpandOverride ? () => onToggleExpandOverride(stableKey) : undefined, - }; - }, + }), [allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride] ); @@ -361,7 +341,7 @@ export const ActivityTimeline = ({ const info = memberInfo.get(firstThought.from); const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`; const stableKey = toMessageKey(firstThought); - const collapseProps = getItemCollapseProps(stableKey, 0); + const collapseState = getItemCollapseState(stableKey, 0); return ( ); })()} @@ -406,7 +385,7 @@ export const ActivityTimeline = ({ const info = memberInfo.get(firstThought.from); const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`; const stableKey = toMessageKey(firstThought); - const collapseProps = getItemCollapseProps(stableKey, realIndex); + const collapseState = getItemCollapseState(stableKey, realIndex); return ( {sessionSeparator} @@ -417,8 +396,7 @@ export const ActivityTimeline = ({ isNew={newItemKeys.has(itemKey)} onVisible={onMessageVisible} zebraShade={zebraShadeSet.has(realIndex)} - forceCollapsed={collapseProps.forceCollapsed} - onCollapseToggle={collapseProps.onCollapseToggle} + collapseState={collapseState} /> ); @@ -431,7 +409,7 @@ export const ActivityTimeline = ({ recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined); const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`; const stableKey = toMessageKey(message); - const collapseProps = getItemCollapseProps(stableKey, realIndex); + const collapseState = getItemCollapseState(stableKey, realIndex); const isUnread = readState ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) : !message.read; @@ -454,8 +432,7 @@ export const ActivityTimeline = ({ onVisible={onMessageVisible} onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} - forceCollapsed={collapseProps.forceCollapsed} - onCollapseToggle={collapseProps.onCollapseToggle} + collapseState={collapseState} /> ); diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 83690d93..e8c964dd 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -16,6 +16,9 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; +import { isManagedCollapseState } from './collapseState'; + +import type { ActivityCollapseState } from './collapseState'; import type { InboxMessage, ToolCallMeta } from '@shared/types'; export interface LeadThoughtGroup { @@ -45,6 +48,8 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { const result: TimelineItem[] = []; let pendingThoughts: InboxMessage[] = []; let pendingIndices: number[] = []; + const hasSameLeadSession = (a: InboxMessage, b: InboxMessage): boolean => + (a.leadSessionId ?? null) === (b.leadSessionId ?? null); const flushThoughts = (): void => { if (pendingThoughts.length === 0) return; @@ -60,6 +65,10 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { for (let i = 0; i < messages.length; i++) { const msg = messages[i]; if (isLeadThought(msg)) { + const previousThought = pendingThoughts[pendingThoughts.length - 1]; + if (previousThought && !hasSameLeadSession(previousThought, msg)) { + flushThoughts(); + } pendingThoughts.push(msg); pendingIndices.push(i); } else { @@ -86,10 +95,8 @@ interface LeadThoughtsGroupRowProps { canBeLive?: boolean; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; - /** When true, collapse the thought body — show only the header with expand chevron. */ - forceCollapsed?: boolean; - /** Called when user toggles expand/collapse in collapsed mode. Presence enables chevron. */ - onCollapseToggle?: () => void; + /** Explicit collapse state for timeline-controlled collapsed mode. */ + collapseState?: ActivityCollapseState; } function formatTime(timestamp: string): string { @@ -347,13 +354,14 @@ export const LeadThoughtsGroupRow = ({ onVisible, canBeLive, zebraShade, - forceCollapsed, - onCollapseToggle, + collapseState, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); const contentRef = useRef(null); const isUserScrolledUpRef = useRef(false); + const distanceFromBottomRef = useRef(0); + const scrollSyncFrameRef = useRef(null); const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false); const leadActivity = useStore((s) => { const teamName = s.selectedTeamName; @@ -412,17 +420,14 @@ export const LeadThoughtsGroupRow = ({ const [isLive, setIsLive] = useState(computeIsLive); const [expanded, setExpanded] = useState(false); const [needsTruncation, setNeedsTruncation] = useState(false); - const [isBodyVisible, setIsBodyVisible] = useState(!forceCollapsed); - - // Sync body visibility when the global collapse mode toggles (skip initial mount) - const isFirstRenderRef = useRef(false); - useEffect(() => { - if (!isFirstRenderRef.current) { - isFirstRenderRef.current = true; - return; - } - setIsBodyVisible(!forceCollapsed); - }, [forceCollapsed]); + const isManaged = isManagedCollapseState(collapseState); + const isBodyVisible = isManaged ? !collapseState.isCollapsed : true; + const canToggleBodyVisibility = isManaged && collapseState.canToggle; + const handleBodyToggle = canToggleBodyVisibility + ? (): void => { + collapseState.onToggle?.(); + } + : undefined; useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap @@ -454,6 +459,41 @@ export const LeadThoughtsGroupRow = ({ return () => observer.disconnect(); }, [onVisible, thoughts]); + const clearPendingScrollSync = useCallback(() => { + if (scrollSyncFrameRef.current !== null) { + cancelAnimationFrame(scrollSyncFrameRef.current); + scrollSyncFrameRef.current = null; + } + }, []); + + const queueScrollSync = useCallback( + (mode: 'bottom' | 'preserve') => { + clearPendingScrollSync(); + scrollSyncFrameRef.current = requestAnimationFrame(() => { + scrollSyncFrameRef.current = requestAnimationFrame(() => { + const scrollEl = scrollRef.current; + if (!scrollEl || expanded || !isBodyVisible) { + scrollSyncFrameRef.current = null; + return; + } + + const nextScrollTop = + mode === 'bottom' + ? scrollEl.scrollHeight - scrollEl.clientHeight + : scrollEl.scrollHeight - scrollEl.clientHeight - distanceFromBottomRef.current; + + scrollEl.scrollTop = Math.max(0, nextScrollTop); + if (mode === 'bottom') { + distanceFromBottomRef.current = 0; + isUserScrolledUpRef.current = false; + } + scrollSyncFrameRef.current = null; + }); + }); + }, + [clearPendingScrollSync, expanded, isBodyVisible] + ); + const syncScrollableBody = useCallback( (forceScrollToBottom = false) => { const scrollEl = scrollRef.current; @@ -463,14 +503,26 @@ export const LeadThoughtsGroupRow = ({ 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; + if (expanded || !isBodyVisible) return; + if (!nextNeedsTruncation) { + clearPendingScrollSync(); + distanceFromBottomRef.current = 0; + isUserScrolledUpRef.current = false; + return; + } + + if (forceScrollToBottom || !isUserScrolledUpRef.current) { + queueScrollSync('bottom'); + return; + } + + queueScrollSync('preserve'); }, - [expanded] + [clearPendingScrollSync, expanded, isBodyVisible, queueScrollSync] ); useEffect(() => { + if (!isBodyVisible) return; const contentEl = contentRef.current; if (!contentEl) return; @@ -482,18 +534,32 @@ export const LeadThoughtsGroupRow = ({ observer.observe(contentEl); return () => observer.disconnect(); - }, [syncScrollableBody]); + }, [isBodyVisible, syncScrollableBody]); + + useEffect( + () => () => { + clearPendingScrollSync(); + }, + [clearPendingScrollSync] + ); + + useEffect(() => { + if (isBodyVisible) return; + clearPendingScrollSync(); + }, [clearPendingScrollSync, isBodyVisible]); const handleScroll = useCallback(() => { if (expanded) return; const el = scrollRef.current; if (!el) return; - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + const distanceFromBottom = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight); + distanceFromBottomRef.current = distanceFromBottom; isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD; }, [expanded]); const handleCollapse = useCallback(() => { isUserScrolledUpRef.current = false; + distanceFromBottomRef.current = 0; setExpanded(false); requestAnimationFrame(() => { @@ -524,34 +590,26 @@ export const LeadThoughtsGroupRow = ({ {/* Header */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
{ - setIsBodyVisible((v) => !v); - onCollapseToggle?.(); - } - : undefined - } + onClick={handleBodyToggle} onKeyDown={ - forceCollapsed === true || onCollapseToggle != null + canToggleBodyVisibility ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - setIsBodyVisible((v) => !v); - onCollapseToggle?.(); + handleBodyToggle?.(); } } : undefined } > {/* Chevron for collapse mode */} - {forceCollapsed === true || onCollapseToggle != null ? ( + {canToggleBodyVisibility ? ( @@ -626,10 +683,13 @@ export const LeadThoughtsGroupRow = ({ ) : null} {isBodyVisible && !expanded && needsTruncation ? ( -
+
- - {canCreate && (prepareState === 'idle' || prepareState === 'loading') ? ( -
- - - {prepareMessage ?? - (prepareState === 'idle' - ? 'Warming up CLI environment...' - : 'Preparing environment...')} - -
- ) : null} - - {canCreate && prepareState === 'ready' ? ( -
-
- - - {prepareWarnings.length > 0 - ? 'CLI environment ready (with notes)' - : 'CLI environment ready'} - -
- {prepareMessage ? ( -

{prepareMessage}

- ) : null} - {prepareWarnings.length > 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} -
- ) : null} -
- ) : null}
) : null}
@@ -934,36 +897,79 @@ export const CreateTeamDialog = ({

) : null} - - {canOpenExistingTeam ? ( - + ) : null} + - ) : null} - - + +
diff --git a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx index e72c4f2b..6ffe6773 100644 --- a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx +++ b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Label } from '@renderer/components/ui/label'; import { cn } from '@renderer/lib/utils'; +import { Brain } from 'lucide-react'; const EFFORT_OPTIONS = [ { value: '', label: 'Default' }, @@ -25,23 +26,26 @@ export const EffortLevelSelector: React.FC = ({ -
- {EFFORT_OPTIONS.map((opt) => ( - - ))} +
+ +
+ {EFFORT_OPTIONS.map((opt) => ( + + ))} +

Controls how much reasoning Claude invests before responding. Default uses Claude's diff --git a/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx b/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx index 7a93ac04..839fad49 100644 --- a/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx +++ b/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx @@ -18,7 +18,7 @@ export const ExtendedContextCheckbox: React.FC = ( disabled = false, }) => ( <> -

+
) : null} - {prepareState === 'idle' || prepareState === 'loading' ? ( -
- - - {prepareMessage ?? - (prepareState === 'idle' - ? 'Warming up CLI environment...' - : 'Preparing environment...')} - -
- ) : null} - - {prepareState === 'ready' ? ( -
-
- - - {prepareWarnings.length > 0 - ? 'CLI environment ready (with notes)' - : 'CLI environment ready'} - -
- {prepareMessage ? ( -

{prepareMessage}

- ) : null} - {prepareWarnings.length > 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} + +
+ {prepareState === 'idle' || prepareState === 'loading' ? ( +
+ + + {prepareMessage ?? + (prepareState === 'idle' + ? 'Warming up CLI environment...' + : 'Preparing environment...')} +
) : null} -
- ) : null} - - - + {prepareState === 'ready' ? ( +
+
+ + + {prepareWarnings.length > 0 + ? 'CLI environment ready (with notes)' + : 'CLI environment ready'} + +
+ {prepareMessage ? ( +

+ {prepareMessage} +

+ ) : null} + {prepareWarnings.length > 0 ? ( +
+ {prepareWarnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} +
+ ) : null} + + {prepareState === 'failed' ?
: null} +
+ +
+ + +
diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index 62759c8d..60a49eb9 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -6,7 +6,7 @@ import { Combobox } from '@renderer/components/ui/combobox'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { cn } from '@renderer/lib/utils'; -import { Check } from 'lucide-react'; +import { Check, FolderOpen } from 'lucide-react'; import type { Project } from '@shared/types'; @@ -102,34 +102,40 @@ export const ProjectPathSelector = ({ {cwdMode === 'project' ? (
- ({ - value: project.path, - label: project.name, - description: project.path, - }))} - value={selectedProjectPath} - onValueChange={onSelectedProjectPathChange} - placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} - searchPlaceholder="Search project by name or path" - emptyMessage="Nothing found" - disabled={projectsLoading || projects.length === 0} - renderOption={(option, isSelected, query) => ( - <> - -
-

- {renderHighlightedText(option.label, query)} -

-

- {renderHighlightedText(option.description ?? '', query)} -

-
- - )} - /> +
+ + ({ + value: project.path, + label: project.name, + description: project.path, + }))} + value={selectedProjectPath} + onValueChange={onSelectedProjectPathChange} + placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} + searchPlaceholder="Search project by name or path" + emptyMessage="Nothing found" + disabled={projectsLoading || projects.length === 0} + renderOption={(option, isSelected, query) => ( + <> + +
+

+ {renderHighlightedText(option.label, query)} +

+

+ {renderHighlightedText(option.description ?? '', query)} +

+
+ + )} + /> +
{!selectedProjectPath ? (

Select a project from the list @@ -137,12 +143,15 @@ export const ProjectPathSelector = ({ ) : null} {projectsError ?

{projectsError}

: null} {!projectsLoading && projects.length === 0 ? ( -

No projects found, switch to custom path.

+

+ No projects found, switch to custom path. +

) : null}
) : (
-
+
+ = (

- Autonomous mode — all tools execute without confirmation. Be cautious with untrusted - code. + Unleash Claude's full power — no interruptions asking for permission. Autonomous + mode — all tools execute without confirmation. Be cautious with untrusted code.

diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 27244c0f..568aa085 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection'; +import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; import { Badge } from '@renderer/components/ui/badge'; @@ -10,7 +11,6 @@ import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; @@ -35,7 +35,6 @@ import { Check, Clock, Eye, - FileCode, FileDiff, GitCompareArrows, HelpCircle, @@ -589,7 +588,10 @@ export const TaskDetailDialog = ({ key={file.filePath} className="group flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]" > - + - ); diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 3701a851..fed6403f 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -46,6 +46,8 @@ interface MemberLogsTabProps { onPreviewOnlineChange?: (isOnline: boolean) => void; } +const PREVIEW_PAGE_SIZE = 4; + export const MemberLogsTab = ({ teamName, memberName, @@ -78,6 +80,7 @@ export const MemberLogsTab = ({ const [detailChunks, setDetailChunks] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [previewChunks, setPreviewChunks] = useState(null); + const [previewVisibleCount, setPreviewVisibleCount] = useState(PREVIEW_PAGE_SIZE); useEffect(() => { return () => { @@ -189,11 +192,17 @@ export const MemberLogsTab = ({ return null; }, [shouldShowPreview, showLeadPreview, showSubagentPreview, sortedLogs, taskOwner]); - const previewMessages = useMemo((): SubagentPreviewMessage[] => { + const allPreviewMessages = useMemo((): SubagentPreviewMessage[] => { if (!previewChunks || previewChunks.length === 0) return []; - return extractSubagentPreviewMessages(previewChunks, 4); + return extractSubagentPreviewMessages(previewChunks); }, [previewChunks]); + const previewMessages = useMemo((): SubagentPreviewMessage[] => { + return allPreviewMessages.slice(0, previewVisibleCount); + }, [allPreviewMessages, previewVisibleCount]); + + const previewHasMore = allPreviewMessages.length > previewVisibleCount; + const previewOnline = useMemo((): boolean => { const newest = previewMessages[0]; if (!newest) return false; @@ -214,6 +223,20 @@ export const MemberLogsTab = ({ onPreviewOnlineChange?.(previewOnline); }, [onPreviewOnlineChange, previewOnline]); + useEffect(() => { + setPreviewVisibleCount(PREVIEW_PAGE_SIZE); + }, [previewLog?.kind, previewLog?.sessionId]); + + useEffect(() => { + if (allPreviewMessages.length === 0) { + setPreviewVisibleCount(PREVIEW_PAGE_SIZE); + return; + } + setPreviewVisibleCount((prev) => + Math.max(PREVIEW_PAGE_SIZE, Math.min(prev, allPreviewMessages.length)) + ); + }, [allPreviewMessages.length]); + useEffect(() => { return () => onPreviewOnlineChange?.(false); }, [onPreviewOnlineChange]); @@ -493,6 +516,8 @@ export const MemberLogsTab = ({ setPreviewVisibleCount((prev) => prev + PREVIEW_PAGE_SIZE)} /> ) : null} {sortedLogs.map((log) => ( @@ -605,21 +630,18 @@ function formatRelativeTime(isoString: string): string { return date.toLocaleDateString(); } -function extractSubagentPreviewMessages( - chunks: EnhancedChunk[], - limit: number -): SubagentPreviewMessage[] { +function extractSubagentPreviewMessages(chunks: EnhancedChunk[]): SubagentPreviewMessage[] { const conversation = transformChunksToConversation(chunks, [], false); const out: SubagentPreviewMessage[] = []; - // Collect newest-first and stop as soon as we have enough. - for (let i = conversation.items.length - 1; i >= 0 && out.length < limit; i--) { + // Collect newest-first. + for (let i = conversation.items.length - 1; i >= 0; i--) { const item = conversation.items[i]; if (item.type === 'ai') { const enhanced = enhanceAIGroup(item.group); const items = enhanced.displayItems ?? []; - for (let j = items.length - 1; j >= 0 && out.length < limit; j--) { + for (let j = items.length - 1; j >= 0; j--) { const di = items[j]; if (di.type === 'output' && di.content.trim()) { out.push({ diff --git a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx index fba5b10a..a49f2d15 100644 --- a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx +++ b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx @@ -1,4 +1,7 @@ +import { useState } from 'react'; + import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { ChevronDown, ChevronUp } from 'lucide-react'; import { format } from 'date-fns'; export type SubagentPreviewMessageKind = @@ -23,57 +26,85 @@ export interface SubagentPreviewMessage { interface SubagentRecentMessagesPreviewProps { messages: SubagentPreviewMessage[]; memberName?: string; + hasMore?: boolean; + onLoadMore?: () => void; } export const SubagentRecentMessagesPreview = ({ messages, memberName, + hasMore = false, + onLoadMore, }: SubagentRecentMessagesPreviewProps): React.JSX.Element | null => { + const [expandedAll, setExpandedAll] = useState(false); + if (!messages.length) return null; return (
-
+
Latest messages{memberName ? ` — ${memberName}` : ''}
-
- {format(messages[0].timestamp, 'h:mm:ss a')} -
-
- {messages.map((m) => ( -
-
-
- {m.label ? ( - - {m.label} - - ) : ( - {m.kind} - )} +
+ {messages.map((m, index) => ( +
+
+
+
-
+
{format(m.timestamp, 'h:mm:ss a')}
- {m.kind === 'tool_result' ? ( -
-                {m.content}
-              
- ) : ( -
- -
- )} + {index < messages.length - 1 ? ( +
+ ) : null}
))} + + {hasMore && onLoadMore ? ( +
+ +
+ ) : null} +
+ +
+ {!expandedAll ? ( + + ) : ( + + )}
); diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index ec12000b..a624b777 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -6,9 +6,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { useAttachments } from '@renderer/hooks/useAttachments'; -import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; -import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useComposerDraft } from '@renderer/hooks/useComposerDraft'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; @@ -33,7 +31,7 @@ interface MessageComposerProps { ) => void; } -const MAX_MESSAGE_LENGTH = 4000; +const MAX_MESSAGE_LENGTH = 50_000; /** Circular progress indicator for lead context usage. */ const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => { @@ -119,19 +117,7 @@ export const MessageComposer = ({ }, [members, recipient]); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); - const draft = useDraftPersistence({ key: `compose:${teamName}` }); - const chipDraft = useChipDraftPersistence(`compose:${teamName}:chips`); - const { - attachments, - error: attachmentError, - canAddMore, - addFiles, - removeAttachment, - clearAttachments, - clearError: clearAttachmentError, - handlePaste, - handleDrop, - } = useAttachments({ persistenceKey: `compose:${teamName}:attachments` }); + const draft = useComposerDraft(teamName); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); @@ -146,7 +132,7 @@ export const MessageComposer = ({ [members, colorMap] ); - const trimmed = draft.value.trim(); + const trimmed = draft.text.trim(); const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; @@ -157,8 +143,8 @@ export const MessageComposer = ({ // isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined // ); const supportsAttachments = isLeadRecipient; - const canAttach = supportsAttachments && canAddMore; - const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; + const canAttach = supportsAttachments && draft.canAddMore; + const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments; const canSend = recipient.length > 0 && trimmed.length > 0 && @@ -172,10 +158,15 @@ export const MessageComposer = ({ const handleSend = useCallback(() => { if (!canSend) return; pendingSendRef.current = true; - const serialized = serializeChipsWithText(trimmed, chipDraft.chips); + const serialized = serializeChipsWithText(trimmed, draft.chips); // Summary should stay compact (no expanded chip markdown) - onSend(recipient, serialized, trimmed, attachments.length > 0 ? attachments : undefined); - }, [canSend, recipient, trimmed, onSend, attachments, chipDraft.chips]); + onSend( + recipient, + serialized, + trimmed, + draft.attachments.length > 0 ? draft.attachments : undefined + ); + }, [canSend, recipient, trimmed, onSend, draft.attachments, draft.chips]); // Clear draft only after send completes successfully (sending: true → false, no error) useEffect(() => { @@ -183,12 +174,9 @@ export const MessageComposer = ({ pendingSendRef.current = false; if (!sendError) { draft.clearDraft(); - chipDraft.clearChipDraft(); - clearAttachments(); } } - // eslint-disable-next-line react-hooks/exhaustive-deps -- clearChipDraft is stable (useCallback with []) - }, [sending, sendError, draft, clearAttachments, chipDraft.clearChipDraft]); + }, [sending, sendError, draft]); const handleKeyDownCapture = useCallback( (e: React.KeyboardEvent) => { @@ -205,11 +193,11 @@ export const MessageComposer = ({ (e: React.ChangeEvent) => { const input = e.target; if (input.files?.length) { - void addFiles(input.files); + void draft.addFiles(input.files); } input.value = ''; }, - [addFiles] + [draft.addFiles] ); const handleDragEnter = useCallback((e: React.DragEvent) => { @@ -235,16 +223,16 @@ export const MessageComposer = ({ (e: React.DragEvent) => { dragCounterRef.current = 0; setIsDragOver(false); - if (canAttach) handleDrop(e); + if (canAttach) draft.handleDrop(e); }, - [canAttach, handleDrop] + [canAttach, draft.handleDrop] ); const handlePasteWrapper = useCallback( (e: React.ClipboardEvent) => { - if (canAttach) handlePaste(e); + if (canAttach) draft.handlePaste(e); }, - [canAttach, handlePaste] + [canAttach, draft.handlePaste] ); const remaining = MAX_MESSAGE_LENGTH - trimmed.length; @@ -292,7 +280,7 @@ export const MessageComposer = ({ {!isTeamAlive ? 'Team must be online to attach images' - : !canAddMore + : !draft.canAddMore ? 'Maximum attachments reached' : 'Attach images (paste or drag & drop)'} @@ -408,10 +396,10 @@ export const MessageComposer = ({
@@ -419,13 +407,13 @@ export const MessageComposer = ({ `), no TTL. + * - Race-safe: late async load never overwrites fresh user input. + * - Debounced writes with immediate flush on unmount and lifecycle transitions. + * - Legacy migration from three-key format on first load. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + composerDraftStorage, + type ComposerDraftSnapshot, +} from '@renderer/services/composerDraftStorage'; +import { + fileToAttachmentPayload, + MAX_FILES, + MAX_TOTAL_SIZE, + validateAttachment, +} from '@renderer/utils/attachmentUtils'; + +import type { InlineChip } from '@renderer/types/inlineChip'; +import type { AttachmentPayload } from '@shared/types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface UseComposerDraftResult { + // Text + text: string; + setText: (v: string) => void; + + // Chips + chips: InlineChip[]; + addChip: (chip: InlineChip) => void; + removeChip: (chipId: string) => void; + + // Attachments + attachments: AttachmentPayload[]; + attachmentError: string | null; + canAddMore: boolean; + addFiles: (files: FileList | File[]) => Promise; + removeAttachment: (id: string) => void; + clearAttachments: () => void; + clearAttachmentError: () => void; + handlePaste: (event: React.ClipboardEvent) => void; + handleDrop: (event: React.DragEvent) => void; + + // Status + isSaved: boolean; + isLoaded: boolean; + + // Clear all + clearDraft: () => void; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEBOUNCE_MS = 400; + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useComposerDraft(teamName: string): UseComposerDraftResult { + const [text, setTextState] = useState(''); + const [chips, setChipsState] = useState([]); + const [attachments, setAttachmentsState] = useState([]); + const [attachmentError, setAttachmentError] = useState(null); + const [isSaved, setIsSaved] = useState(false); + const [isLoaded, setIsLoaded] = useState(false); + + // Refs for latest values — avoids stale closures in callbacks + const textRef = useRef(''); + const chipsRef = useRef([]); + const attachmentsRef = useRef([]); + const teamNameRef = useRef(teamName); + const mountedRef = useRef(true); + + // Track whether user has interacted since last load to prevent race + const userTouchedRef = useRef(false); + + // Debounce timer + const timerRef = useRef | null>(null); + const pendingRef = useRef<{ teamName: string; snapshot: ComposerDraftSnapshot } | null>(null); + + // Keep teamNameRef in sync + useEffect(() => { + teamNameRef.current = teamName; + }, [teamName]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // --------------------------------------------------------------------------- + // Persist helpers + // --------------------------------------------------------------------------- + + const buildSnapshot = useCallback((): ComposerDraftSnapshot => { + return { + version: 1, + teamName: teamNameRef.current, + text: textRef.current, + chips: chipsRef.current, + attachments: attachmentsRef.current, + updatedAt: Date.now(), + }; + }, []); + + const flushPending = useCallback(() => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (pendingRef.current != null) { + const pending = pendingRef.current; + pendingRef.current = null; + const isEmpty = + pending.snapshot.text.length === 0 && + pending.snapshot.chips.length === 0 && + pending.snapshot.attachments.length === 0; + if (isEmpty) { + void composerDraftStorage.deleteSnapshot(pending.teamName); + } else { + void composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot); + } + } + }, []); + + const scheduleSave = useCallback(() => { + const snapshot = buildSnapshot(); + pendingRef.current = { teamName: teamNameRef.current, snapshot }; + + if (timerRef.current != null) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + timerRef.current = null; + const pending = pendingRef.current; + pendingRef.current = null; + if (pending == null) return; + + const isEmpty = + pending.snapshot.text.length === 0 && + pending.snapshot.chips.length === 0 && + pending.snapshot.attachments.length === 0; + if (isEmpty) { + void composerDraftStorage.deleteSnapshot(pending.teamName); + if (mountedRef.current) setIsSaved(true); + } else { + void composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot).then(() => { + if (mountedRef.current) setIsSaved(true); + }); + } + }, DEBOUNCE_MS); + }, [buildSnapshot]); + + // --------------------------------------------------------------------------- + // Apply snapshot to state + // --------------------------------------------------------------------------- + + const applySnapshot = useCallback((snap: ComposerDraftSnapshot) => { + textRef.current = snap.text; + chipsRef.current = snap.chips; + attachmentsRef.current = snap.attachments; + setTextState(snap.text); + setChipsState(snap.chips); + setAttachmentsState(snap.attachments); + }, []); + + // --------------------------------------------------------------------------- + // Load on mount / teamName change + // --------------------------------------------------------------------------- + + useEffect(() => { + let cancelled = false; + flushPending(); + userTouchedRef.current = false; + + // Reset to empty immediately for the new teamName + const empty = composerDraftStorage.emptySnapshot(teamName); + applySnapshot(empty); + setIsSaved(false); + setIsLoaded(false); + setAttachmentError(null); + + void (async () => { + // Try loading unified snapshot first + let snapshot = await composerDraftStorage.loadSnapshot(teamName); + + // If none found, try legacy migration + if (snapshot == null) { + snapshot = await composerDraftStorage.migrateLegacy(teamName); + } + + if (cancelled) return; + + // Race protection: if user already started typing, don't overwrite + if (userTouchedRef.current) { + if (mountedRef.current) setIsLoaded(true); + return; + } + + if (snapshot != null) { + // Validate attachment limits + const totalSize = snapshot.attachments.reduce((sum, a) => sum + a.size, 0); + if (totalSize > MAX_TOTAL_SIZE || snapshot.attachments.length > MAX_FILES) { + snapshot = { ...snapshot, attachments: [] }; + } + + applySnapshot(snapshot); + setIsSaved(true); + } + + if (mountedRef.current) setIsLoaded(true); + })(); + + return () => { + cancelled = true; + }; + }, [teamName, flushPending, applySnapshot]); + + // Flush on unmount + useEffect(() => { + return () => { + flushPending(); + }; + }, [flushPending]); + + // --------------------------------------------------------------------------- + // Text + // --------------------------------------------------------------------------- + + const setText = useCallback( + (v: string) => { + userTouchedRef.current = true; + textRef.current = v; + setTextState(v); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + // --------------------------------------------------------------------------- + // Chips + // --------------------------------------------------------------------------- + + const addChip = useCallback( + (chip: InlineChip) => { + userTouchedRef.current = true; + const next = [...chipsRef.current, chip]; + chipsRef.current = next; + setChipsState(next); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + const removeChip = useCallback( + (chipId: string) => { + userTouchedRef.current = true; + const next = chipsRef.current.filter((c) => c.id !== chipId); + chipsRef.current = next; + setChipsState(next); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + // --------------------------------------------------------------------------- + // Attachments + // --------------------------------------------------------------------------- + + const totalSize = attachments.reduce((sum, a) => sum + a.size, 0); + const canAddMore = attachments.length < MAX_FILES && totalSize < MAX_TOTAL_SIZE; + + const addFiles = useCallback( + async (files: FileList | File[]) => { + userTouchedRef.current = true; + setAttachmentError(null); + const fileArray = Array.from(files); + if (fileArray.length === 0) return; + + let batchSize = 0; + for (const file of fileArray) { + const validation = validateAttachment(file); + if (!validation.valid) { + setAttachmentError(validation.error); + return; + } + batchSize += file.size; + } + + const newPayloads: AttachmentPayload[] = []; + for (const file of fileArray) { + try { + const payload = await fileToAttachmentPayload(file); + newPayloads.push(payload); + } catch { + setAttachmentError(`Failed to read file: ${file.name}`); + return; + } + } + + const prev = attachmentsRef.current; + if (prev.length + newPayloads.length > MAX_FILES) { + setAttachmentError(`Maximum ${MAX_FILES} attachments allowed`); + return; + } + const currentTotal = prev.reduce((sum, a) => sum + a.size, 0); + if (currentTotal + batchSize > MAX_TOTAL_SIZE) { + setAttachmentError('Total attachment size exceeds 20MB limit'); + return; + } + + const next = [...prev, ...newPayloads]; + attachmentsRef.current = next; + setAttachmentsState(next); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + const removeAttachment = useCallback( + (id: string) => { + userTouchedRef.current = true; + const next = attachmentsRef.current.filter((a) => a.id !== id); + attachmentsRef.current = next; + setAttachmentsState(next); + setAttachmentError(null); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + const clearAttachments = useCallback(() => { + userTouchedRef.current = true; + attachmentsRef.current = []; + setAttachmentsState([]); + setAttachmentError(null); + setIsSaved(false); + scheduleSave(); + }, [scheduleSave]); + + const clearAttachmentError = useCallback(() => { + setAttachmentError(null); + }, []); + + const handlePaste = useCallback( + (event: React.ClipboardEvent) => { + const items = event.clipboardData?.items; + if (!items) return; + + const imageFiles: File[] = []; + for (const item of Array.from(items)) { + if (item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) imageFiles.push(file); + } + } + + if (imageFiles.length > 0) { + event.preventDefault(); + void addFiles(imageFiles); + } + }, + [addFiles] + ); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + const files = event.dataTransfer?.files; + if (!files?.length) return; + + const allFiles = Array.from(files); + const imageFiles = allFiles.filter((f) => f.type.startsWith('image/')); + if (imageFiles.length > 0) { + void addFiles(imageFiles); + } else if (allFiles.length > 0) { + setAttachmentError('Only image files are supported'); + } + }, + [addFiles] + ); + + // --------------------------------------------------------------------------- + // Clear all + // --------------------------------------------------------------------------- + + const clearDraft = useCallback(() => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + pendingRef.current = null; + + textRef.current = ''; + chipsRef.current = []; + attachmentsRef.current = []; + + setTextState(''); + setChipsState([]); + setAttachmentsState([]); + setAttachmentError(null); + setIsSaved(false); + + void composerDraftStorage.deleteSnapshot(teamNameRef.current); + }, []); + + return { + text, + setText, + chips, + addChip, + removeChip, + attachments, + attachmentError, + canAddMore, + addFiles, + removeAttachment, + clearAttachments, + clearAttachmentError, + handlePaste, + handleDrop, + isSaved, + isLoaded, + clearDraft, + }; +} diff --git a/src/renderer/index.css b/src/renderer/index.css index fec49dd6..b7a1c4d0 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -773,3 +773,14 @@ body { linear-gradient(-45deg, transparent 75%, #e2e8f0 75%); background-color: #ffffff; } + +/* Lightbox toolbar buttons — enlarge hit targets and fix SVG dead zones */ +.yarl__toolbar .yarl__button { + min-width: 44px; + min-height: 44px; + position: relative; +} + +.yarl__toolbar .yarl__button > svg { + pointer-events: none; +} diff --git a/src/renderer/services/composerDraftStorage.ts b/src/renderer/services/composerDraftStorage.ts new file mode 100644 index 00000000..4c33d1a4 --- /dev/null +++ b/src/renderer/services/composerDraftStorage.ts @@ -0,0 +1,270 @@ +/** + * Atomic draft storage for MessageComposer snapshots. + * + * Unlike `draftStorage.ts` (text-only with TTL), this stores a unified + * snapshot of text + chips + attachments under a single key — no TTL. + * Drafts persist until explicitly cleared (on send or manual action). + */ + +import { del, get, set } from 'idb-keyval'; + +import type { InlineChip } from '@renderer/types/inlineChip'; +import type { AttachmentPayload } from '@shared/types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Current snapshot schema version. Bump when shape changes. */ +const SNAPSHOT_VERSION = 1; + +export interface ComposerDraftSnapshot { + version: number; + teamName: string; + text: string; + chips: InlineChip[]; + attachments: AttachmentPayload[]; + updatedAt: number; +} + +// --------------------------------------------------------------------------- +// Key helpers +// --------------------------------------------------------------------------- + +const KEY_PREFIX = 'composer:'; + +function storageKey(teamName: string): string { + return `${KEY_PREFIX}${teamName}`; +} + +/** Legacy keys used by the old three-key approach. */ +function legacyKeys(teamName: string) { + return { + text: `draft:compose:${teamName}`, + chips: `draft:compose:${teamName}:chips`, + attachments: `draft:compose:${teamName}:attachments`, + } as const; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +function isValidSnapshot(data: unknown): data is ComposerDraftSnapshot { + if (typeof data !== 'object' || data === null) return false; + const obj = data as Record; + return ( + typeof obj.version === 'number' && + typeof obj.teamName === 'string' && + typeof obj.text === 'string' && + Array.isArray(obj.chips) && + Array.isArray(obj.attachments) && + typeof obj.updatedAt === 'number' + ); +} + +// --------------------------------------------------------------------------- +// IDB availability tracking (same pattern as draftStorage.ts) +// --------------------------------------------------------------------------- + +let idbUnavailable = false; +let idbUnavailableLogged = false; +const fallbackStore = new Map(); + +function markIdbUnavailable(): void { + if (!idbUnavailableLogged) { + idbUnavailableLogged = true; + console.warn( + '[composerDraftStorage] IndexedDB unavailable, using in-memory storage for this session.' + ); + } + idbUnavailable = true; +} + +// --------------------------------------------------------------------------- +// Core API +// --------------------------------------------------------------------------- + +async function saveSnapshot(teamName: string, snapshot: ComposerDraftSnapshot): Promise { + const key = storageKey(teamName); + if (idbUnavailable) { + fallbackStore.set(key, snapshot); + return; + } + try { + await set(key, snapshot); + } catch { + markIdbUnavailable(); + fallbackStore.set(key, snapshot); + } +} + +async function loadSnapshot(teamName: string): Promise { + const key = storageKey(teamName); + if (idbUnavailable) { + return fallbackStore.get(key) ?? null; + } + try { + const data = await get(key); + if (data == null) return null; + if (isValidSnapshot(data)) return data; + // Invalid shape — discard silently + void del(key); + return null; + } catch { + markIdbUnavailable(); + return fallbackStore.get(key) ?? null; + } +} + +async function deleteSnapshot(teamName: string): Promise { + const key = storageKey(teamName); + if (idbUnavailable) { + fallbackStore.delete(key); + return; + } + try { + await del(key); + } catch { + markIdbUnavailable(); + fallbackStore.delete(key); + } +} + +// --------------------------------------------------------------------------- +// Legacy migration +// --------------------------------------------------------------------------- + +interface LegacyTextDraft { + value: string; + timestamp: number; +} + +function isLegacyTextDraft(d: unknown): d is LegacyTextDraft { + if (typeof d !== 'object' || d === null) return false; + const obj = d as Record; + return typeof obj.value === 'string' && typeof obj.timestamp === 'number'; +} + +function isValidChipArray(data: unknown): data is InlineChip[] { + if (!Array.isArray(data)) return false; + return data.every((raw) => { + if (typeof raw !== 'object' || raw === null) return false; + const item = raw as Record; + return typeof item.id === 'string' && typeof item.filePath === 'string'; + }); +} + +function isValidAttachmentArray(data: unknown): data is AttachmentPayload[] { + if (!Array.isArray(data)) return false; + return data.every((raw) => { + if (typeof raw !== 'object' || raw === null) return false; + const item = raw as Record; + return ( + typeof item.id === 'string' && + typeof item.filename === 'string' && + typeof item.data === 'string' + ); + }); +} + +/** + * Attempts to migrate legacy three-key drafts into a unified snapshot. + * Returns the migrated snapshot or null if no legacy data found. + * Deletes legacy keys on success. + */ +async function migrateLegacy(teamName: string): Promise { + if (idbUnavailable) return null; + + const keys = legacyKeys(teamName); + + try { + const [rawText, rawChips, rawAttachments] = await Promise.all([ + get(keys.text), + get(keys.chips), + get(keys.attachments), + ]); + + // Nothing to migrate + if (rawText == null && rawChips == null && rawAttachments == null) return null; + + let text = ''; + if (isLegacyTextDraft(rawText)) { + text = rawText.value; + } + + let chips: InlineChip[] = []; + if (rawChips != null) { + const chipsData = typeof rawChips === 'string' ? (JSON.parse(rawChips) as unknown) : rawChips; + // Legacy text draft wraps value in {value, timestamp} + const unwrapped = isLegacyTextDraft(chipsData) ? chipsData.value : chipsData; + const toParse = + typeof unwrapped === 'string' ? (JSON.parse(unwrapped) as unknown) : unwrapped; + if (isValidChipArray(toParse)) chips = toParse; + } + + let attachments: AttachmentPayload[] = []; + if (rawAttachments != null) { + const attData = + typeof rawAttachments === 'string' + ? (JSON.parse(rawAttachments) as unknown) + : rawAttachments; + const unwrapped = isLegacyTextDraft(attData) ? attData.value : attData; + const toParse = + typeof unwrapped === 'string' ? (JSON.parse(unwrapped) as unknown) : unwrapped; + if (isValidAttachmentArray(toParse)) attachments = toParse; + } + + // Only create snapshot if there's actual content + if (text.length === 0 && chips.length === 0 && attachments.length === 0) { + // Clean up empty legacy keys + await Promise.all([del(keys.text), del(keys.chips), del(keys.attachments)]); + return null; + } + + const snapshot: ComposerDraftSnapshot = { + version: SNAPSHOT_VERSION, + teamName, + text, + chips, + attachments, + updatedAt: Date.now(), + }; + + // Save new snapshot and delete legacy keys atomically-ish + await saveSnapshot(teamName, snapshot); + await Promise.all([del(keys.text), del(keys.chips), del(keys.attachments)]); + + return snapshot; + } catch { + // Migration is best-effort — don't block the composer + return null; + } +} + +// --------------------------------------------------------------------------- +// Factory for empty snapshot +// --------------------------------------------------------------------------- + +function emptySnapshot(teamName: string): ComposerDraftSnapshot { + return { + version: SNAPSHOT_VERSION, + teamName, + text: '', + chips: [], + attachments: [], + updatedAt: Date.now(), + }; +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +export const composerDraftStorage = { + saveSnapshot, + loadSnapshot, + deleteSnapshot, + migrateLegacy, + emptySnapshot, +}; diff --git a/test/renderer/components/team/activity/collapseState.test.ts b/test/renderer/components/team/activity/collapseState.test.ts new file mode 100644 index 00000000..b1adb07c --- /dev/null +++ b/test/renderer/components/team/activity/collapseState.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + findNewestMessageIndex, + resolveTimelineCollapseState, +} from '@renderer/components/team/activity/collapseState'; + +describe('team activity collapse state', () => { + describe('findNewestMessageIndex', () => { + it('skips a pinned thought group and returns the first real message', () => { + expect( + findNewestMessageIndex([ + { type: 'lead-thoughts' }, + { type: 'message' }, + { type: 'lead-thoughts' }, + { type: 'message' }, + ]) + ).toBe(1); + }); + + it('returns -1 when there are no real messages', () => { + expect(findNewestMessageIndex([{ type: 'lead-thoughts' }, { type: 'lead-thoughts' }])).toBe( + -1 + ); + }); + }); + + describe('resolveTimelineCollapseState', () => { + it('falls back to default mode when global collapsed mode is off', () => { + expect( + resolveTimelineCollapseState({ + allCollapsed: false, + itemIndex: 3, + newestMessageIndex: 1, + isPinnedThoughtGroup: false, + isExpandedOverride: false, + }) + ).toEqual({ mode: 'default' }); + }); + + it('keeps the newest message open and non-toggleable in collapsed mode', () => { + expect( + resolveTimelineCollapseState({ + allCollapsed: true, + itemIndex: 1, + newestMessageIndex: 1, + isPinnedThoughtGroup: false, + isExpandedOverride: false, + }) + ).toEqual({ + mode: 'managed', + isCollapsed: false, + canToggle: false, + }); + }); + + it('keeps the pinned thought group open and non-toggleable', () => { + expect( + resolveTimelineCollapseState({ + allCollapsed: true, + itemIndex: 0, + newestMessageIndex: 2, + isPinnedThoughtGroup: true, + isExpandedOverride: false, + }) + ).toEqual({ + mode: 'managed', + isCollapsed: false, + canToggle: false, + }); + }); + + it('collapses an older item when it is no longer the newest message', () => { + const onToggleOverride = vi.fn(); + const state = resolveTimelineCollapseState({ + allCollapsed: true, + itemIndex: 2, + newestMessageIndex: 1, + isPinnedThoughtGroup: false, + isExpandedOverride: false, + onToggleOverride, + }); + + expect(state).toMatchObject({ + mode: 'managed', + isCollapsed: true, + canToggle: true, + }); + + if (state.mode !== 'managed') { + throw new Error('Expected managed collapse state'); + } + + state.onToggle?.(); + expect(onToggleOverride).toHaveBeenCalledTimes(1); + }); + + it('reopens older items that have a persisted expand override', () => { + expect( + resolveTimelineCollapseState({ + allCollapsed: true, + itemIndex: 4, + newestMessageIndex: 1, + isPinnedThoughtGroup: false, + isExpandedOverride: true, + onToggleOverride: () => undefined, + }) + ).toEqual({ + mode: 'managed', + isCollapsed: false, + canToggle: true, + onToggle: expect.any(Function), + }); + }); + }); +}); diff --git a/test/renderer/hooks/useComposerDraft.test.ts b/test/renderer/hooks/useComposerDraft.test.ts new file mode 100644 index 00000000..20592bf6 --- /dev/null +++ b/test/renderer/hooks/useComposerDraft.test.ts @@ -0,0 +1,437 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock idb-keyval before importing composerDraftStorage +const store = new Map(); + +vi.mock('idb-keyval', () => ({ + get: vi.fn((key: string) => Promise.resolve(store.get(key) ?? undefined)), + set: vi.fn((key: string, value: unknown) => { + store.set(key, value); + return Promise.resolve(); + }), + del: vi.fn((key: string) => { + store.delete(key); + return Promise.resolve(); + }), + keys: vi.fn(() => Promise.resolve([...store.keys()])), +})); + +import { + composerDraftStorage, + type ComposerDraftSnapshot, +} from '@renderer/services/composerDraftStorage'; + +function makeSnapshot( + teamName: string, + overrides?: Partial +): ComposerDraftSnapshot { + return { + version: 1, + teamName, + text: 'hello', + chips: [], + attachments: [], + updatedAt: Date.now(), + ...overrides, + }; +} + +describe('composerDraftStorage', () => { + beforeEach(() => { + store.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('saveSnapshot / loadSnapshot', () => { + it('should save and load a snapshot', async () => { + const snap = makeSnapshot('team-a'); + await composerDraftStorage.saveSnapshot('team-a', snap); + const result = await composerDraftStorage.loadSnapshot('team-a'); + expect(result).toEqual(snap); + }); + + it('should return null for non-existent snapshot', async () => { + const result = await composerDraftStorage.loadSnapshot('nonexistent'); + expect(result).toBeNull(); + }); + + it('should overwrite existing snapshot', async () => { + const snap1 = makeSnapshot('team-a', { text: 'first' }); + const snap2 = makeSnapshot('team-a', { text: 'second' }); + await composerDraftStorage.saveSnapshot('team-a', snap1); + await composerDraftStorage.saveSnapshot('team-a', snap2); + const result = await composerDraftStorage.loadSnapshot('team-a'); + expect(result?.text).toBe('second'); + }); + + it('should NOT have TTL — drafts persist indefinitely', async () => { + const snap = makeSnapshot('team-a', { + updatedAt: Date.now() - 7 * 24 * 60 * 60 * 1000, // 7 days ago + }); + await composerDraftStorage.saveSnapshot('team-a', snap); + const result = await composerDraftStorage.loadSnapshot('team-a'); + expect(result).toEqual(snap); + }); + }); + + describe('deleteSnapshot', () => { + it('should delete a snapshot', async () => { + const snap = makeSnapshot('team-a'); + await composerDraftStorage.saveSnapshot('team-a', snap); + await composerDraftStorage.deleteSnapshot('team-a'); + const result = await composerDraftStorage.loadSnapshot('team-a'); + expect(result).toBeNull(); + }); + + it('should not throw when deleting non-existent snapshot', async () => { + await expect(composerDraftStorage.deleteSnapshot('nonexistent')).resolves.toBeUndefined(); + }); + }); + + describe('team isolation', () => { + it('should isolate drafts by teamName', async () => { + const snapA = makeSnapshot('team-a', { text: 'from team A' }); + const snapB = makeSnapshot('team-b', { text: 'from team B' }); + await composerDraftStorage.saveSnapshot('team-a', snapA); + await composerDraftStorage.saveSnapshot('team-b', snapB); + + const resultA = await composerDraftStorage.loadSnapshot('team-a'); + const resultB = await composerDraftStorage.loadSnapshot('team-b'); + expect(resultA?.text).toBe('from team A'); + expect(resultB?.text).toBe('from team B'); + }); + + it('deleting one team draft should not affect another', async () => { + await composerDraftStorage.saveSnapshot('team-a', makeSnapshot('team-a')); + await composerDraftStorage.saveSnapshot('team-b', makeSnapshot('team-b')); + await composerDraftStorage.deleteSnapshot('team-a'); + + expect(await composerDraftStorage.loadSnapshot('team-a')).toBeNull(); + expect(await composerDraftStorage.loadSnapshot('team-b')).not.toBeNull(); + }); + }); + + describe('legacy migration', () => { + it('should migrate text from old draft:compose: key', async () => { + // Simulate old storage format + store.set('draft:compose:my-team', { value: 'old text', timestamp: Date.now() }); + + const result = await composerDraftStorage.migrateLegacy('my-team'); + expect(result).not.toBeNull(); + expect(result!.text).toBe('old text'); + expect(result!.teamName).toBe('my-team'); + + // Legacy keys should be deleted + expect(store.has('draft:compose:my-team')).toBe(false); + + // New snapshot key should exist + const loaded = await composerDraftStorage.loadSnapshot('my-team'); + expect(loaded?.text).toBe('old text'); + }); + + it('should migrate chips from old draft:compose::chips key', async () => { + const chips = [ + { + id: 'c1', + filePath: '/test/file.ts', + fileName: 'file.ts', + fromLine: 1, + toLine: 10, + codeText: 'code', + language: 'typescript', + }, + ]; + store.set('draft:compose:my-team:chips', { + value: JSON.stringify(chips), + timestamp: Date.now(), + }); + + const result = await composerDraftStorage.migrateLegacy('my-team'); + expect(result).not.toBeNull(); + expect(result!.chips).toHaveLength(1); + expect(result!.chips[0].id).toBe('c1'); + + // Legacy key should be cleaned up + expect(store.has('draft:compose:my-team:chips')).toBe(false); + }); + + it('should migrate attachments from old draft:compose::attachments key', async () => { + const attachments = [ + { + id: 'a1', + filename: 'test.png', + mimeType: 'image/png', + size: 1024, + data: 'base64data', + }, + ]; + store.set('draft:compose:my-team:attachments', { + value: JSON.stringify(attachments), + timestamp: Date.now(), + }); + + const result = await composerDraftStorage.migrateLegacy('my-team'); + expect(result).not.toBeNull(); + expect(result!.attachments).toHaveLength(1); + expect(result!.attachments[0].id).toBe('a1'); + }); + + it('should return null when no legacy data exists', async () => { + const result = await composerDraftStorage.migrateLegacy('nonexistent'); + expect(result).toBeNull(); + }); + + it('should combine all three legacy sources into one snapshot', async () => { + store.set('draft:compose:my-team', { value: 'combined text', timestamp: Date.now() }); + store.set('draft:compose:my-team:chips', { + value: JSON.stringify([ + { + id: 'c1', + filePath: '/f.ts', + fileName: 'f.ts', + fromLine: 1, + toLine: 2, + codeText: 'x', + language: 'ts', + }, + ]), + timestamp: Date.now(), + }); + store.set('draft:compose:my-team:attachments', { + value: JSON.stringify([ + { id: 'a1', filename: 'img.png', mimeType: 'image/png', size: 512, data: 'b64' }, + ]), + timestamp: Date.now(), + }); + + const result = await composerDraftStorage.migrateLegacy('my-team'); + expect(result).not.toBeNull(); + expect(result!.text).toBe('combined text'); + expect(result!.chips).toHaveLength(1); + expect(result!.attachments).toHaveLength(1); + + // All legacy keys cleaned up + expect(store.has('draft:compose:my-team')).toBe(false); + expect(store.has('draft:compose:my-team:chips')).toBe(false); + expect(store.has('draft:compose:my-team:attachments')).toBe(false); + }); + + it('should clean up empty legacy keys without creating a snapshot', async () => { + store.set('draft:compose:my-team', { value: '', timestamp: Date.now() }); + + const result = await composerDraftStorage.migrateLegacy('my-team'); + expect(result).toBeNull(); + expect(store.has('draft:compose:my-team')).toBe(false); + }); + }); + + describe('emptySnapshot', () => { + it('should create an empty snapshot for given teamName', () => { + const snap = composerDraftStorage.emptySnapshot('test-team'); + expect(snap.teamName).toBe('test-team'); + expect(snap.text).toBe(''); + expect(snap.chips).toEqual([]); + expect(snap.attachments).toEqual([]); + expect(snap.version).toBe(1); + }); + }); + + describe('invalid data handling', () => { + it('should return null and discard invalid snapshot data', async () => { + store.set('composer:bad-team', { garbage: true }); + const result = await composerDraftStorage.loadSnapshot('bad-team'); + expect(result).toBeNull(); + // Invalid data should be deleted + expect(store.has('composer:bad-team')).toBe(false); + }); + + it('should discard snapshot missing required fields', async () => { + store.set('composer:partial', { version: 1, teamName: 'partial', text: 'hi' }); + const result = await composerDraftStorage.loadSnapshot('partial'); + expect(result).toBeNull(); + expect(store.has('composer:partial')).toBe(false); + }); + }); + + describe('clear-on-send flow', () => { + it('should delete snapshot and return null on next load', async () => { + const snap = makeSnapshot('team-send', { text: 'about to send' }); + await composerDraftStorage.saveSnapshot('team-send', snap); + + // Simulate clear-on-send + await composerDraftStorage.deleteSnapshot('team-send'); + const afterClear = await composerDraftStorage.loadSnapshot('team-send'); + expect(afterClear).toBeNull(); + }); + + it('should allow saving a new draft after clear', async () => { + const snap1 = makeSnapshot('team-send', { text: 'first message' }); + await composerDraftStorage.saveSnapshot('team-send', snap1); + await composerDraftStorage.deleteSnapshot('team-send'); + + // New draft after clear + const snap2 = makeSnapshot('team-send', { text: 'second draft' }); + await composerDraftStorage.saveSnapshot('team-send', snap2); + const result = await composerDraftStorage.loadSnapshot('team-send'); + expect(result?.text).toBe('second draft'); + }); + }); + + describe('concurrent / rapid saves', () => { + it('should resolve to the last written snapshot', async () => { + const snaps = Array.from({ length: 5 }, (_, i) => + makeSnapshot('team-rapid', { text: `iteration-${i}` }) + ); + + // Fire all saves concurrently + await Promise.all(snaps.map((s) => composerDraftStorage.saveSnapshot('team-rapid', s))); + + const result = await composerDraftStorage.loadSnapshot('team-rapid'); + // Last save wins — the mock store is synchronous, so the last set() call wins + expect(result?.text).toBe('iteration-4'); + }); + + it('should handle interleaved save and delete', async () => { + await composerDraftStorage.saveSnapshot('team-x', makeSnapshot('team-x', { text: 'v1' })); + // Delete then immediately save again + await composerDraftStorage.deleteSnapshot('team-x'); + await composerDraftStorage.saveSnapshot('team-x', makeSnapshot('team-x', { text: 'v2' })); + + const result = await composerDraftStorage.loadSnapshot('team-x'); + expect(result?.text).toBe('v2'); + }); + }); + + describe('full data roundtrip', () => { + it('should preserve text, chips, and attachments together', async () => { + const snap = makeSnapshot('team-full', { + text: 'Hello @alice', + chips: [ + { + id: 'chip-1', + filePath: '/src/index.ts', + fileName: 'index.ts', + fromLine: 1, + toLine: 10, + codeText: 'const x = 1;', + language: 'typescript', + }, + ], + attachments: [ + { + id: 'att-1', + filename: 'screenshot.png', + mimeType: 'image/png', + size: 2048, + data: 'iVBORw0KGgo=', + }, + ], + }); + await composerDraftStorage.saveSnapshot('team-full', snap); + const result = await composerDraftStorage.loadSnapshot('team-full'); + + expect(result).not.toBeNull(); + expect(result!.text).toBe('Hello @alice'); + expect(result!.chips).toHaveLength(1); + expect(result!.chips[0].filePath).toBe('/src/index.ts'); + expect(result!.attachments).toHaveLength(1); + expect(result!.attachments[0].filename).toBe('screenshot.png'); + expect(result!.attachments[0].size).toBe(2048); + }); + }); + + describe('recovery after restart', () => { + it('should load draft saved in a previous session (simulated)', async () => { + // Simulate saving in "session 1" + const snap = makeSnapshot('team-persist', { + text: 'Unsent message from last session', + updatedAt: Date.now() - 3600_000, // 1 hour ago + }); + await composerDraftStorage.saveSnapshot('team-persist', snap); + + // Simulate "session 2" — load the same key + const result = await composerDraftStorage.loadSnapshot('team-persist'); + expect(result).not.toBeNull(); + expect(result!.text).toBe('Unsent message from last session'); + }); + + it('should recover draft saved 30 days ago (no TTL)', async () => { + const snap = makeSnapshot('team-old', { + text: 'Ancient draft', + updatedAt: Date.now() - 30 * 24 * 3600_000, + }); + await composerDraftStorage.saveSnapshot('team-old', snap); + const result = await composerDraftStorage.loadSnapshot('team-old'); + expect(result).not.toBeNull(); + expect(result!.text).toBe('Ancient draft'); + }); + }); +}); + +describe('composerDraftStorage — IDB failure fallback', () => { + beforeEach(() => { + vi.resetModules(); + store.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fall back to in-memory store when IDB set throws', async () => { + // Make idb set throw to trigger fallback + const { set: idbSet } = await import('idb-keyval'); + const mockSet = vi.mocked(idbSet); + mockSet.mockRejectedValueOnce(new Error('QuotaExceeded')); + + // Re-import to get a fresh module with idbUnavailable = false + const { composerDraftStorage: freshStorage } = await import( + '@renderer/services/composerDraftStorage' + ); + + const snap: ComposerDraftSnapshot = { + version: 1, + teamName: 'fallback-team', + text: 'saved to memory', + chips: [], + attachments: [], + updatedAt: Date.now(), + }; + + // First save triggers the error → fallback kicks in + await freshStorage.saveSnapshot('fallback-team', snap); + + // Subsequent load uses in-memory fallback + const result = await freshStorage.loadSnapshot('fallback-team'); + expect(result).not.toBeNull(); + expect(result!.text).toBe('saved to memory'); + }); + + it('should allow delete from in-memory fallback', async () => { + const { set: idbSet } = await import('idb-keyval'); + const mockSet = vi.mocked(idbSet); + mockSet.mockRejectedValueOnce(new Error('IDB broken')); + + const { composerDraftStorage: freshStorage } = await import( + '@renderer/services/composerDraftStorage' + ); + + const snap: ComposerDraftSnapshot = { + version: 1, + teamName: 'del-team', + text: 'to delete', + chips: [], + attachments: [], + updatedAt: Date.now(), + }; + + await freshStorage.saveSnapshot('del-team', snap); + await freshStorage.deleteSnapshot('del-team'); + + const result = await freshStorage.loadSnapshot('del-team'); + expect(result).toBeNull(); + }); +}); diff --git a/test/renderer/utils/teamMessageExpandStorage.test.ts b/test/renderer/utils/teamMessageExpandStorage.test.ts new file mode 100644 index 00000000..1cbad969 --- /dev/null +++ b/test/renderer/utils/teamMessageExpandStorage.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + addExpanded, + getExpandedOverrides, + removeExpanded, +} from '@renderer/utils/teamMessageExpandStorage'; + +describe('teamMessageExpandStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('stores overrides per team', () => { + addExpanded('alpha', 'msg-1'); + addExpanded('beta', 'msg-2'); + + expect([...getExpandedOverrides('alpha')]).toEqual(['msg-1']); + expect([...getExpandedOverrides('beta')]).toEqual(['msg-2']); + }); + + it('deduplicates repeated expansions', () => { + addExpanded('alpha', 'msg-1'); + addExpanded('alpha', 'msg-1'); + + expect([...getExpandedOverrides('alpha')]).toEqual(['msg-1']); + }); + + it('removes only the requested override', () => { + addExpanded('alpha', 'msg-1'); + addExpanded('alpha', 'msg-2'); + + removeExpanded('alpha', 'msg-1'); + + expect([...getExpandedOverrides('alpha')]).toEqual(['msg-2']); + }); + + it('returns an empty set for malformed stored data', () => { + localStorage.setItem('team-msg-expanded:alpha', '{bad json'); + + expect(getExpandedOverrides('alpha')).toEqual(new Set()); + }); +}); From dba2d989235dcbbc3d8c02f0cdb7077e0bf8981c Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 7 Mar 2026 00:05:38 +0200 Subject: [PATCH 07/15] feat: enhance session and subagent routes with cache bypass functionality - Updated session and subagent route handlers to support an optional `bypassCache` query parameter, allowing clients to bypass cached responses. - Enhanced README to include a Discord link for community engagement. - Improved CSS for lightbox toolbar buttons to address macOS hit-testing issues. - Refactored task ID linkification in markdown to ensure accurate matching and improved functionality in various components. --- README.md | 3 +- src/main/http/sessions.ts | 132 +++--- src/main/http/subagents.ts | 111 ++--- .../services/team/TeamProvisioningService.ts | 401 +++++++++++++----- src/renderer/api/httpClient.ts | 24 +- .../components/team/activity/ActivityItem.tsx | 11 +- .../team/activity/ActivityTimeline.tsx | 4 + .../team/activity/LeadThoughtsGroup.tsx | 46 +- .../attachments/AttachmentPreviewItem.tsx | 4 +- .../attachments/AttachmentPreviewList.tsx | 21 +- .../team/dialogs/TaskCommentsSection.tsx | 12 +- .../components/team/members/MemberLogsTab.tsx | 41 +- .../team/messages/MessageComposer.tsx | 36 +- src/renderer/index.css | 11 +- 14 files changed, 548 insertions(+), 309 deletions(-) diff --git a/README.md b/README.md index 32936bd9..e3be5726 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@

Latest Release  - CI Status + CI Status  + Discord

diff --git a/src/main/http/sessions.ts b/src/main/http/sessions.ts index 9697d43c..a3bc267c 100644 --- a/src/main/http/sessions.ts +++ b/src/main/http/sessions.ts @@ -137,78 +137,76 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic ); // Session detail - app.get<{ Params: { projectId: string; sessionId: string } }>( - '/api/projects/:projectId/sessions/:sessionId', - async (request) => { - try { - const validatedProject = validateProjectId(request.params.projectId); - const validatedSession = validateSessionId(request.params.sessionId); - if (!validatedProject.valid || !validatedSession.valid) { - logger.error( - `GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}` - ); - return null; - } - - const safeProjectId = validatedProject.value!; - const safeSessionId = validatedSession.value!; - const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId); - - // Check cache first - let sessionDetail = services.dataCache.get(cacheKey); - if (sessionDetail) { - return sessionDetail; - } - - const fsType = services.projectScanner.getFileSystemProvider().type; - // In SSH mode, avoid an extra deep metadata scan before full parse. - const session = await services.projectScanner.getSessionWithOptions( - safeProjectId, - safeSessionId, - { - metadataLevel: fsType === 'ssh' ? 'light' : 'deep', - } - ); - if (!session) { - logger.error(`Session not found: ${safeSessionId}`); - return null; - } - - // Parse session messages - const parsedSession = await services.sessionParser.parseSession( - safeProjectId, - safeSessionId - ); - - // Resolve subagents - const subagents = await services.subagentResolver.resolveSubagents( - safeProjectId, - safeSessionId, - parsedSession.taskCalls, - parsedSession.messages - ); - session.hasSubagents = subagents.length > 0; - - // Build session detail with chunks - sessionDetail = services.chunkBuilder.buildSessionDetail( - session, - parsedSession.messages, - subagents - ); - - // Cache the result - services.dataCache.set(cacheKey, sessionDetail); - - return sessionDetail; - } catch (error) { + app.get<{ + Params: { projectId: string; sessionId: string }; + Querystring: { bypassCache?: string }; + }>('/api/projects/:projectId/sessions/:sessionId', async (request) => { + try { + const validatedProject = validateProjectId(request.params.projectId); + const validatedSession = validateSessionId(request.params.sessionId); + if (!validatedProject.valid || !validatedSession.valid) { logger.error( - `Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`, - error + `GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}` ); return null; } + + const safeProjectId = validatedProject.value!; + const safeSessionId = validatedSession.value!; + const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId); + const bypassCache = request.query?.bypassCache === 'true'; + + // Check cache first + let sessionDetail = services.dataCache.get(cacheKey); + if (sessionDetail && !bypassCache) { + return sessionDetail; + } + + const fsType = services.projectScanner.getFileSystemProvider().type; + // In SSH mode, avoid an extra deep metadata scan before full parse. + const session = await services.projectScanner.getSessionWithOptions( + safeProjectId, + safeSessionId, + { + metadataLevel: fsType === 'ssh' ? 'light' : 'deep', + } + ); + if (!session) { + logger.error(`Session not found: ${safeSessionId}`); + return null; + } + + // Parse session messages + const parsedSession = await services.sessionParser.parseSession(safeProjectId, safeSessionId); + + // Resolve subagents + const subagents = await services.subagentResolver.resolveSubagents( + safeProjectId, + safeSessionId, + parsedSession.taskCalls, + parsedSession.messages + ); + session.hasSubagents = subagents.length > 0; + + // Build session detail with chunks + sessionDetail = services.chunkBuilder.buildSessionDetail( + session, + parsedSession.messages, + subagents + ); + + // Cache the result + services.dataCache.set(cacheKey, sessionDetail); + + return sessionDetail; + } catch (error) { + logger.error( + `Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`, + error + ); + return null; } - ); + }); // Conversation groups app.get<{ Params: { projectId: string; sessionId: string } }>( diff --git a/src/main/http/subagents.ts b/src/main/http/subagents.ts index 8b66c3d4..27d4241d 100644 --- a/src/main/http/subagents.ts +++ b/src/main/http/subagents.ts @@ -15,63 +15,64 @@ import type { FastifyInstance } from 'fastify'; const logger = createLogger('HTTP:subagents'); export function registerSubagentRoutes(app: FastifyInstance, services: HttpServices): void { - app.get<{ Params: { projectId: string; sessionId: string; subagentId: string } }>( - '/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId', - async (request) => { - try { - const validatedProject = validateProjectId(request.params.projectId); - const validatedSession = validateSessionId(request.params.sessionId); - const validatedSubagent = validateSubagentId(request.params.subagentId); - if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) { - logger.error( - `GET subagent-detail rejected: ${ - validatedProject.error ?? - validatedSession.error ?? - validatedSubagent.error ?? - 'Invalid parameters' - }` - ); - return null; - } - - const safeProjectId = validatedProject.value!; - const safeSessionId = validatedSession.value!; - const safeSubagentId = validatedSubagent.value!; - - const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`; - - // Check cache first - let subagentDetail = services.dataCache.getSubagent(cacheKey); - if (subagentDetail) { - return subagentDetail; - } - - const fsProvider = services.projectScanner.getFileSystemProvider(); - const projectsDir = services.projectScanner.getProjectsDir(); - - const builtDetail = await services.chunkBuilder.buildSubagentDetail( - safeProjectId, - safeSessionId, - safeSubagentId, - services.sessionParser, - services.subagentResolver, - fsProvider, - projectsDir + app.get<{ + Params: { projectId: string; sessionId: string; subagentId: string }; + Querystring: { bypassCache?: string }; + }>('/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId', async (request) => { + try { + const validatedProject = validateProjectId(request.params.projectId); + const validatedSession = validateSessionId(request.params.sessionId); + const validatedSubagent = validateSubagentId(request.params.subagentId); + if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) { + logger.error( + `GET subagent-detail rejected: ${ + validatedProject.error ?? + validatedSession.error ?? + validatedSubagent.error ?? + 'Invalid parameters' + }` ); - - if (!builtDetail) { - logger.error(`Subagent not found: ${safeSubagentId}`); - return null; - } - - subagentDetail = builtDetail; - services.dataCache.setSubagent(cacheKey, subagentDetail); - - return subagentDetail; - } catch (error) { - logger.error(`Error in GET subagent-detail for ${request.params.subagentId}:`, error); return null; } + + const safeProjectId = validatedProject.value!; + const safeSessionId = validatedSession.value!; + const safeSubagentId = validatedSubagent.value!; + const bypassCache = request.query?.bypassCache === 'true'; + + const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`; + + // Check cache first + let subagentDetail = services.dataCache.getSubagent(cacheKey); + if (subagentDetail && !bypassCache) { + return subagentDetail; + } + + const fsProvider = services.projectScanner.getFileSystemProvider(); + const projectsDir = services.projectScanner.getProjectsDir(); + + const builtDetail = await services.chunkBuilder.buildSubagentDetail( + safeProjectId, + safeSessionId, + safeSubagentId, + services.sessionParser, + services.subagentResolver, + fsProvider, + projectsDir + ); + + if (!builtDetail) { + logger.error(`Subagent not found: ${safeSubagentId}`); + return null; + } + + subagentDetail = builtDetail; + services.dataCache.setSubagent(cacheKey, subagentDetail); + + return subagentDetail; + } catch (error) { + logger.error(`Error in GET subagent-detail for ${request.params.subagentId}:`, error); + return null; } - ); + }); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 292ce178..02894f3d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -205,6 +205,16 @@ interface ProvisioningRun { } | null; /** Pending tool approval requests awaiting user response (control_request protocol). */ pendingApprovals: Map; + /** + * Post-compact context reinjection lifecycle. + * - pendingPostCompactReminder: compact_boundary was received; waiting for idle to inject. + * - postCompactReminderInFlight: the reminder turn has been injected via stdin, waiting for result. + * - suppressPostCompactReminderOutput: true while processing a reminder turn — suppress + * low-value acknowledgement text so the user doesn't see "OK, I'll remember that." + */ + pendingPostCompactReminder: boolean; + postCompactReminderInFlight: boolean; + suppressPostCompactReminderOutput: boolean; } type LeadActivityState = 'active' | 'idle' | 'offline'; @@ -553,6 +563,76 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string ); } +/** + * Builds the durable lead context — constraints, communication protocol, teamctl ops, + * and agent block policy — that must survive context compaction. + * + * Used by: buildProvisioningPrompt, buildLaunchPrompt, and post-compact reinjection. + */ +function buildPersistentLeadContext(opts: { + teamName: string; + leadName: string; + isSolo: boolean; + members: TeamCreateRequest['members']; +}): string { + const { teamName, leadName, isSolo, members } = opts; + const languageInstruction = getAgentLanguageInstruction(); + const agentBlockPolicy = buildAgentBlockUsagePolicy(); + const teamCtlOps = buildTeamCtlOpsInstructions(teamName, leadName); + + const soloConstraint = isSolo + ? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` + + `\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` + + `\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` + + `\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` + + `\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + + `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` + + `\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` + + `\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` + + `\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` + + `\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` + + `\n - TASK STATUS DISCIPLINE (MANDATORY):` + + `\n - Only move a task to in_progress when you are actively starting work on it.` + + `\n - Only move a task to completed when it is truly finished.` + + `\n - Never bulk-move many tasks at the end — update status incrementally as you work.` + + `\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` + + `\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.` + : ''; + + const membersBlock = buildMembersPrompt(members); + const membersFooter = membersBlock + ? `Members:\n${membersBlock}` + : 'Members: (none — solo team lead)'; + + return `${languageInstruction} + +Constraints: +- Do NOT call TeamDelete under any circumstances. +- Do NOT use TodoWrite. +- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). +- Do NOT shut down, terminate, or clean up the team or its members. +- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate. +- Keep assistant text minimal. +- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. +- Keep the task board high-signal: avoid creating tasks for trivial micro-items. +- Use the team task board for assigned/substantial work. +- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates). +- TaskCreate is optional for private planning only; do NOT use it for team-board tasks. +- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint} + +${teamCtlOps} + +Communication protocol (CRITICAL — you are running headless, no one sees your text output): +- When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient. +- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox. +- Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply"). + +Message formatting: +${agentBlockPolicy} + +${membersFooter}`; +} + function buildAgentBlockUsagePolicy(): string { return `Agent-only formatting policy (applies to ALL messages you write): - Humans can see teammate inbox messages and coordination text in the UI. @@ -638,42 +718,20 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string { function buildProvisioningPrompt(request: TeamCreateRequest): string { const displayName = request.displayName?.trim() || request.teamName; const description = request.description?.trim() || 'No description'; - const members = buildMembersPrompt(request.members); const taskProtocol = buildTaskStatusProtocol(request.teamName); const processRegistration = buildProcessRegistrationProtocol(request.teamName); - const languageInstruction = getAgentLanguageInstruction(); - const agentBlockPolicy = buildAgentBlockUsagePolicy(); const userPromptBlock = request.prompt?.trim() ? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n` : ''; const leadName = request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; - const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName); const projectName = path.basename(request.cwd); const isSolo = request.members.length === 0; - const soloConstraint = isSolo - ? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` + - `\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` + - `\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` + - `\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` + - `\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + - `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` + - `\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` + - `\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` + - `\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` + - `\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` + - `\n - TASK STATUS DISCIPLINE (MANDATORY):` + - `\n - Only move a task to in_progress when you are actively starting work on it.` + - `\n - Only move a task to completed when it is truly finished.` + - `\n - Never bulk-move many tasks at the end — update status incrementally as you work.` + - `\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` + - `\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.` - : ''; const step3Block = isSolo - ? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself ("${leadName}") as owner.\n` + + ? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself (“${leadName}”) as owner.\n` + ` - Prefer fewer, broader tasks over many micro-tasks.\n` + ` - CRITICAL: Do NOT start working on the tasks now. Provisioning is ONLY for setting up the team structure.\n` + ` - The tasks will be executed after the team is launched separately.` @@ -689,7 +747,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { - When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by. - If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress. - Review guidance: - - Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X. + - Prefer NOT creating a separate “review task”. Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X. - If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task: - Use --related to connect it to #X (non-blocking link). - If the review truly cannot start until #X is done, ALSO add --blocked-by #X. @@ -703,12 +761,12 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { // NOTE: taskProtocol & processRegistration are deliberately inlined into EACH member's spawn prompt // below, even though the text is identical across members. This duplicates ~4K chars per member // in the lead's context, but ensures the lead passes the EXACT protocol verbatim via Task tool. -// Extracting them once and telling the lead to "insert the protocol block" risks hallucination +// Extracting them once and telling the lead to “insert the protocol block” risks hallucination // or omission — the lead may rephrase rules, skip items, or forget to include them. // Cost: ~1K tokens per extra member. At 200K context window this is negligible. ${request.members .map( - (m) => ` For "${m.name}": + (m) => ` For “${m.name}”: - prompt: ${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, processRegistration) .split('\n') @@ -717,53 +775,32 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, process ) .join('\n\n')}`; - const membersFooter = members ? `Members:\n${members}` : 'Members: (none — solo team lead)'; + const persistentContext = buildPersistentLeadContext({ + teamName: request.teamName, + leadName, + isSolo, + members: request.members, + }); - return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] + return `Team Start [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. -You are "${leadName}", the team lead. +You are “${leadName}”, the team lead. Goal: Provision a Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}. ${userPromptBlock} -${languageInstruction} - -Constraints: -- Do NOT call TeamDelete under any circumstances. -- Do NOT use TodoWrite. -- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). -- Do NOT shut down, terminate, or clean up the team or its members. -- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate. -- Keep assistant text minimal. -- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. -- Keep the task board high-signal: avoid creating tasks for trivial micro-items. -- Use the team task board for assigned/substantial work. -- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates). -- TaskCreate is optional for private planning only; do NOT use it for team-board tasks. -- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint} - -${teamCtlOps} - -Communication protocol (CRITICAL — you are running headless, no one sees your text output): -- When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient. -- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox. -- Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply"). - -Message formatting: -${agentBlockPolicy} +${persistentContext} Steps (execute in this exact order): -1) TeamCreate — create team "${request.teamName}": - - description: "${description}" +1) TeamCreate — create team “${request.teamName}”: + - description: “${description}” ${step2Block} ${step3Block} 4) After all steps, output a short summary. - -${membersFooter} `; } @@ -773,39 +810,18 @@ function buildLaunchPrompt( tasks: TeamTask[], isResume: boolean ): string { - const membersBlock = buildMembersPrompt(members); const userPromptBlock = request.prompt?.trim() ? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n` : ''; const taskProtocol = buildTaskStatusProtocol(request.teamName); const processRegistration = buildProcessRegistrationProtocol(request.teamName); const languageInstruction = getAgentLanguageInstruction(); - const agentBlockPolicy = buildAgentBlockUsagePolicy(); const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; - const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName); const projectName = path.basename(request.cwd); const isSolo = members.length === 0; - const soloConstraint = isSolo - ? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` + - `\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` + - `\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` + - `\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` + - `\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + - `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` + - `\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` + - `\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` + - `\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` + - `\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` + - `\n - TASK STATUS DISCIPLINE (MANDATORY):` + - `\n - Only move a task to in_progress when you are actively starting work on it.` + - `\n - Only move a task to completed when it is truly finished.` + - `\n - Never bulk-move many tasks at the end — update status incrementally as you work.` + - `\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` + - `\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.` - : ''; let step2And3Block: string; if (isSolo) { @@ -876,9 +892,12 @@ ${memberSpawnInstructions} 3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.`; } - const membersFooter = membersBlock - ? `Members:\n${membersBlock}` - : 'Members: (none — solo team lead)'; + const persistentContext = buildPersistentLeadContext({ + teamName: request.teamName, + leadName, + isSolo, + members, + }); const startLabel = isResume ? 'Team Start (resume)' : 'Team Start'; @@ -889,31 +908,8 @@ You are "${leadName}", the team lead. Goal: Reconnect with existing team "${request.teamName}" and resume pending work. ${userPromptBlock} -${languageInstruction} ${taskBoardSnapshot} -Constraints: -- Do NOT call TeamDelete under any circumstances. -- Do NOT use TodoWrite. -- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). -- Do NOT shut down, terminate, or clean up the team or its members. -- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate. -- Keep assistant text minimal. -- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. -- Keep the task board high-signal: avoid creating tasks for trivial micro-items. -- Use the team task board for assigned/substantial work. -- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates). -- TaskCreate is optional for private planning only; do NOT use it for team-board tasks. -- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint} - -${teamCtlOps} - -Communication protocol (CRITICAL — you are running headless, no one sees your text output): -- When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient. -- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox. -- Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply"). - -Message formatting: -${agentBlockPolicy} +${persistentContext} Steps (execute in this exact order): @@ -922,11 +918,19 @@ Steps (execute in this exact order): ${step2And3Block} 4) After all steps, output a short summary of reconnected members and what happens next. - -${membersFooter} `; } +/** + * Unconditionally clears all post-compact reminder state on a run. + * Called from cleanupRun, cancel, and error paths. + */ +function clearPostCompactReminderState(run: ProvisioningRun): void { + run.pendingPostCompactReminder = false; + run.postCompactReminderInFlight = false; + run.suppressPostCompactReminderOutput = false; +} + function updateProgress( run: ProvisioningRun, state: Exclude, @@ -1774,6 +1778,9 @@ export class TeamProvisioningService { authRetryInProgress: false, spawnContext: null, pendingApprovals: new Map(), + pendingPostCompactReminder: false, + postCompactReminderInFlight: false, + suppressPostCompactReminderOutput: false, progress: { runId, teamName: request.teamName, @@ -2077,6 +2084,9 @@ export class TeamProvisioningService { authRetryInProgress: false, spawnContext: null, pendingApprovals: new Map(), + pendingPostCompactReminder: false, + postCompactReminderInFlight: false, + suppressPostCompactReminderOutput: false, progress: { runId, teamName: request.teamName, @@ -2938,7 +2948,11 @@ export class TeamProvisioningService { // Push each assistant text block as a separate live message (per-message pattern). // When the same assistant message includes SendMessage(to:"user"), skip text — // captureSendMessageToUser() handles it separately. - if (!run.silentUserDmForward && !hasSendMessageToUser) { + if ( + !run.silentUserDmForward && + !run.suppressPostCompactReminderOutput && + !hasSendMessageToUser + ) { const cleanText = stripAgentBlocks(text).trim(); if (cleanText.length > 0) { run.leadMsgSeq += 1; @@ -3004,7 +3018,11 @@ export class TeamProvisioningService { // (e.g., after session resume when teamContext is lost). We intercept the tool calls // from stdout and persist them to sentMessages.json under the correct team name, // ensuring the UI and notifications show the right team. - if (run.provisioningComplete && !run.silentUserDmForward) { + if ( + run.provisioningComplete && + !run.silentUserDmForward && + !run.suppressPostCompactReminderOutput + ) { this.captureSendMessageToUser(run, content ?? []); } @@ -3136,7 +3154,18 @@ export class TeamProvisioningService { } if (run.provisioningComplete) { + // If this was a post-compact reminder turn completing, clear in-flight and suppress flags. + if (run.postCompactReminderInFlight) { + clearPostCompactReminderState(run); + logger.info(`[${run.teamName}] post-compact reminder turn completed`); + } + this.setLeadActivity(run, 'idle'); + + // Deferred post-compact context reinjection: inject durable rules on first idle after compact. + if (run.pendingPostCompactReminder && !run.postCompactReminderInFlight) { + void this.injectPostCompactReminder(run); + } } if (run.leadRelayCapture) { const capture = run.leadRelayCapture; @@ -3182,7 +3211,14 @@ export class TeamProvisioningService { killProcessTree(run.child); this.cleanupRun(run); } else if (run.provisioningComplete) { - // Post-provisioning error: process alive, waiting for input + // Post-provisioning error: process alive, waiting for input. + // Drop post-compact reminder on error (strict drop-after-attempt policy). + if (run.postCompactReminderInFlight) { + clearPostCompactReminderState(run); + logger.warn( + `[${run.teamName}] post-compact reminder turn errored — dropping (strict policy)` + ); + } this.setLeadActivity(run, 'idle'); } } @@ -3220,10 +3256,152 @@ export class TeamProvisioningService { logger.info( `[${run.teamName}] compact_boundary — context will refresh on next turn${tokenInfo}` ); + + // Schedule post-compact context reinjection on next idle. + // Guard: only set if provisioning is complete and no reminder is already pending/in-flight. + if ( + run.provisioningComplete && + !run.pendingPostCompactReminder && + !run.postCompactReminderInFlight + ) { + run.pendingPostCompactReminder = true; + logger.info(`[${run.teamName}] post-compact reminder scheduled for next idle`); + } } } } + /** + * Injects a post-compact context reminder into the lead process via stdin. + * Reinjects durable lead rules (constraints, communication protocol, teamctl ops) + * plus a fresh task board snapshot so the lead recovers full operational context + * after context compaction. + * + * Policy: strict drop-after-attempt — one compact cycle gives at most one reminder turn. + * If the injection fails (stdin not writable, process killed), we do not retry. + */ + private async injectPostCompactReminder(run: ProvisioningRun): Promise { + // Consume the pending flag immediately — strict one-shot policy. + run.pendingPostCompactReminder = false; + + // Guard: process must be alive and writable. + if (!run.child?.stdin?.writable || run.processKilled || run.cancelRequested) { + logger.warn( + `[${run.teamName}] post-compact reminder skipped — process not writable or killed` + ); + return; + } + + // Guard: don't inject if another turn is actively processing (race with user send / inbox relay). + if (run.leadActivityState !== 'idle') { + logger.info( + `[${run.teamName}] post-compact reminder deferred — lead is ${run.leadActivityState}, not idle` + ); + // Re-arm so it triggers on next idle. + run.pendingPostCompactReminder = true; + return; + } + + // Guard: don't inject while a relay capture is in-flight. + if (run.leadRelayCapture) { + logger.info(`[${run.teamName}] post-compact reminder deferred — relay capture in-flight`); + run.pendingPostCompactReminder = true; + return; + } + + // Guard: don't inject while a silent DM forward is in progress. + if (run.silentUserDmForward) { + logger.info( + `[${run.teamName}] post-compact reminder deferred — silent DM forward in progress` + ); + run.pendingPostCompactReminder = true; + return; + } + + const leadName = + run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; + const isSolo = run.request.members.length === 0; + + // Build persistent lead context. + const persistentContext = buildPersistentLeadContext({ + teamName: run.teamName, + leadName, + isSolo, + members: run.request.members, + }); + + // Best-effort: fetch fresh task board snapshot. + let taskBoardBlock = ''; + try { + const taskReader = new TeamTaskReader(); + const tasks = await taskReader.getTasks(run.teamName); + taskBoardBlock = buildTaskBoardSnapshot(tasks); + } catch { + // If tasks can't be read, inject without the snapshot. + logger.warn(`[${run.teamName}] post-compact reminder: task board snapshot unavailable`); + } + + // Re-check guards after async work. + if (!run.child?.stdin?.writable || run.processKilled || run.cancelRequested) { + logger.warn( + `[${run.teamName}] post-compact reminder aborted — process state changed during preparation` + ); + return; + } + if (run.leadActivityState !== 'idle') { + logger.info( + `[${run.teamName}] post-compact reminder aborted — lead activity changed to ${run.leadActivityState}` + ); + return; + } + + const message = [ + `Context reminder (post-compaction) — your context was compacted. Here are your standing rules and current state:`, + ``, + `You are "${leadName}", the team lead of team "${run.teamName}".`, + `You are running in a non-interactive CLI session. Do not ask questions.`, + ``, + persistentContext, + taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '', + ``, + `Acknowledge briefly (1 sentence max) and continue with any pending work.`, + ] + .filter(Boolean) + .join('\n'); + + const payload = JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: message }], + }, + }); + + run.postCompactReminderInFlight = true; + run.suppressPostCompactReminderOutput = true; + this.setLeadActivity(run, 'active'); + + try { + const stdin = run.child.stdin; + await new Promise((resolve, reject) => { + stdin.write(payload + '\n', (err) => { + if (err) reject(err); + else resolve(); + }); + }); + logger.info(`[${run.teamName}] post-compact reminder injected`); + } catch (error) { + // Strict drop-after-attempt — do not re-arm. + clearPostCompactReminderState(run); + this.setLeadActivity(run, 'idle'); + logger.warn( + `[${run.teamName}] post-compact reminder injection failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + /** * Handles a control_request message from CLI stream-json output. * `can_use_tool` → emits to renderer for manual approval. @@ -3552,6 +3730,7 @@ export class TeamProvisioningService { clearTimeout(run.silentUserDmForwardClearHandle); run.silentUserDmForwardClearHandle = null; } + clearPostCompactReminderState(run); this.stopFilesystemMonitor(run); // Remove stream listeners to prevent data handlers firing on a cleaned-up run if (run.child) { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 58bb724e..30cb0f65 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -249,11 +249,15 @@ export class HttpAPIClient implements ElectronAPI { getSessionDetail = ( projectId: string, sessionId: string, - _options?: { bypassCache?: boolean } - ): Promise => - this.get( - `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}` + options?: { bypassCache?: boolean } + ): Promise => { + const params = new URLSearchParams(); + if (options?.bypassCache) params.set('bypassCache', 'true'); + const qs = params.toString(); + return this.get( + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}${qs ? `?${qs}` : ''}` ); + }; getSessionMetrics = (projectId: string, sessionId: string): Promise => this.get( @@ -269,11 +273,15 @@ export class HttpAPIClient implements ElectronAPI { projectId: string, sessionId: string, subagentId: string, - _options?: { bypassCache?: boolean } - ): Promise => - this.get( - `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}` + options?: { bypassCache?: boolean } + ): Promise => { + const params = new URLSearchParams(); + if (options?.bypassCache) params.set('bypassCache', 'true'); + const qs = params.toString(); + return this.get( + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}${qs ? `?${qs}` : ''}` ); + }; getSessionGroups = (projectId: string, sessionId: string): Promise => this.get( diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 2d935969..b8631861 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -157,8 +157,8 @@ const AUTH_ERROR_PATTERNS = [ // --------------------------------------------------------------------------- /** Convert `#` in plain text to markdown links with task:// protocol. */ -function linkifyTaskIdsInMarkdown(text: string): string { - return text.replace(/#(\d+)/g, '[#$1](task://$1)'); +export function linkifyTaskIdsInMarkdown(text: string): string { + return text.replace(/#(\d+)\b/g, '[#$1](task://$1)'); } /** @@ -166,7 +166,10 @@ function linkifyTaskIdsInMarkdown(text: string): string { * Encodes color in the URL so MarkdownViewer can render colored badges without extra context. * Greedy match: longer names are tried first to avoid partial matches. */ -function linkifyMentionsInMarkdown(text: string, memberColorMap: Map): string { +export function linkifyMentionsInMarkdown( + text: string, + memberColorMap: Map +): string { if (memberColorMap.size === 0) return text; // Sort by name length descending for greedy matching const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length); @@ -182,7 +185,7 @@ function linkifyMentionsInMarkdown(text: string, memberColorMap: Map` in plain text as clickable inline elements with TaskTooltip. */ function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] { - return text.split(/(#\d+)/g).map((part, i) => { + return text.split(/(#\d+\b)/g).map((part, i) => { const match = /^#(\d+)$/.exec(part); if (!match) return {part}; const taskId = match[1]; diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index bc8eeff3..81a9492e 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -352,6 +352,8 @@ export const ActivityTimeline = ({ onVisible={onMessageVisible} zebraShade={zebraShadeSet.has(0)} collapseState={collapseState} + onTaskIdClick={onTaskIdClick} + memberColorMap={colorMap} /> ); })()} @@ -397,6 +399,8 @@ export const ActivityTimeline = ({ onVisible={onMessageVisible} zebraShade={zebraShadeSet.has(realIndex)} collapseState={collapseState} + onTaskIdClick={onTaskIdClick} + memberColorMap={colorMap} /> ); diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index e8c964dd..02fdc485 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -16,6 +16,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; +import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem'; import { isManagedCollapseState } from './collapseState'; import type { ActivityCollapseState } from './collapseState'; @@ -97,6 +98,10 @@ interface LeadThoughtsGroupRowProps { zebraShade?: boolean; /** Explicit collapse state for timeline-controlled collapsed mode. */ collapseState?: ActivityCollapseState; + /** Called when a task ID link (e.g. #10) is clicked in thought text. */ + onTaskIdClick?: (taskId: string) => void; + /** Map of member name → color name for @mention badge rendering. */ + memberColorMap?: Map; } function formatTime(timestamp: string): string { @@ -179,12 +184,16 @@ interface LeadThoughtItemProps { thought: InboxMessage; showDivider: boolean; shouldAnimate: boolean; + onTaskIdClick?: (taskId: string) => void; + memberColorMap?: Map; } const LeadThoughtItem = ({ thought, showDivider, shouldAnimate, + onTaskIdClick, + memberColorMap, }: LeadThoughtItemProps): JSX.Element => { const wrapperRef = useRef(null); const contentRef = useRef(null); @@ -192,6 +201,15 @@ const LeadThoughtItem = ({ const animationFrameRef = useRef(null); const cleanupTimerRef = useRef(null); + const displayContent = useMemo(() => { + let text = thought.text.replace(/\n/g, ' \n'); + text = linkifyTaskIdsInMarkdown(text); + if (memberColorMap && memberColorMap.size > 0) { + text = linkifyMentionsInMarkdown(text, memberColorMap); + } + return text; + }, [thought.text, memberColorMap]); + const clearPendingAnimation = useCallback(() => { if (animationFrameRef.current !== null) { cancelAnimationFrame(animationFrameRef.current); @@ -313,11 +331,25 @@ const LeadThoughtItem = ({ )}

- + { + const link = (e.target as HTMLElement).closest( + 'a[href^="task://"]' + ); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const taskId = link.getAttribute('href')?.replace('task://', ''); + if (taskId) onTaskIdClick(taskId); + } + } + : undefined + } + > + +
{thought.toolSummary && ( @@ -355,6 +387,8 @@ export const LeadThoughtsGroupRow = ({ canBeLive, zebraShade, collapseState, + onTaskIdClick, + memberColorMap, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); @@ -676,6 +710,8 @@ export const LeadThoughtsGroupRow = ({ thought={thought} showDivider={idx > 0} shouldAnimate={isLive && idx === chronologicalThoughts.length - 1} + onTaskIdClick={onTaskIdClick} + memberColorMap={memberColorMap} /> ))}
diff --git a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx index 819ebaf6..345e7a1c 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx @@ -8,12 +8,14 @@ import type { AttachmentPayload } from '@shared/types'; interface AttachmentPreviewItemProps { attachment: AttachmentPayload; onRemove: (id: string) => void; + onPreview?: () => void; disabled?: boolean; } export const AttachmentPreviewItem = ({ attachment, onRemove, + onPreview, disabled, }: AttachmentPreviewItemProps): React.JSX.Element => { const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`; @@ -25,7 +27,7 @@ export const AttachmentPreviewItem = ({
) : null} - +
{attachment.filename} diff --git a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx index 362fe301..42c0043f 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx @@ -1,6 +1,9 @@ +import { useState } from 'react'; + import { AlertCircle, X } from 'lucide-react'; import { AttachmentPreviewItem } from './AttachmentPreviewItem'; +import { ImageLightbox } from './ImageLightbox'; import type { AttachmentPayload } from '@shared/types'; @@ -23,17 +26,25 @@ export const AttachmentPreviewList = ({ disabled, disabledHint, }: AttachmentPreviewListProps): React.JSX.Element | null => { + const [lightboxIndex, setLightboxIndex] = useState(null); + if (attachments.length === 0 && !error) return null; + const lightboxSlides = attachments.map((att) => ({ + src: `data:${att.mimeType};base64,${att.data}`, + alt: att.filename, + })); + return (
{attachments.length > 0 ? (
- {attachments.map((att) => ( + {attachments.map((att, i) => ( setLightboxIndex(i)} disabled={disabled} /> ))} @@ -63,6 +74,14 @@ export const AttachmentPreviewList = ({ ) : null}
) : null} + {lightboxIndex !== null && lightboxSlides[lightboxIndex] ? ( + setLightboxIndex(null)} + slides={lightboxSlides} + index={lightboxIndex} + /> + ) : null}
); }; diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 1415949b..13a9ef00 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { CopyButton } from '@renderer/components/common/CopyButton'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; @@ -54,7 +55,7 @@ interface TaskCommentsSectionProps { /** Convert `#` in plain text to markdown links with task:// protocol. */ function linkifyTaskIdsInMarkdown(text: string): string { - return text.replace(/#(\d+)/g, '[#$1](task://$1)'); + return text.replace(/#(\d+)\b/g, '[#$1](task://$1)'); } /** Convert `@memberName` to markdown links with mention:// protocol for colored badge rendering. */ @@ -190,7 +191,11 @@ export const TaskCommentsSection = ({ } >
- + {comment.type === 'review_approved' ? ( @@ -232,6 +237,9 @@ export const TaskCommentsSection = ({ Reply to comment + + +
{(() => { const reply = parseMessageReply(comment.text); diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index fed6403f..ebe6d36b 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -76,7 +76,6 @@ export const MemberLogsTab = ({ const refreshHideTimeoutRef = useRef | null>(null); const [error, setError] = useState(null); const [expandedId, setExpandedId] = useState(null); - const expandedIdRef = useRef(null); const [detailChunks, setDetailChunks] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [previewChunks, setPreviewChunks] = useState(null); @@ -92,10 +91,6 @@ export const MemberLogsTab = ({ }; }, []); - useEffect(() => { - expandedIdRef.current = expandedId; - }, [expandedId]); - const beginRefreshing = useCallback((): void => { if (refreshCountRef.current === 0) { refreshBeganAtRef.current = Date.now(); @@ -282,16 +277,6 @@ export const MemberLogsTab = ({ setLogs(nextLogs); hasLoadedRef.current = true; } - - // Keep expanded session details in sync with the same refresh - // cadence as the summary (counts/titles) while "Updating..." is shown. - if (!cancelled && didBeginRefreshing) { - try { - await refreshExpandedDetailFromLogs(nextLogs); - } catch { - // Keep last successful detail view; avoid flicker on transient failures. - } - } } catch (e) { if (!cancelled) { setError(e instanceof Error ? e.message : 'Unknown error'); @@ -335,26 +320,6 @@ export const MemberLogsTab = ({ [] ); - const refreshExpandedDetailFromLogs = useCallback( - async (nextLogs: MemberLogSummary[]): Promise => { - const rowId = expandedIdRef.current; - if (!rowId) return; - if (!isMountedRef.current) return; - - const nextExpanded = nextLogs.find((log) => getRowId(log) === rowId); - if (!nextExpanded) return; - - const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; - if (!shouldAutoRefreshSummary && !nextExpanded.isOngoing) return; - - const next = await fetchDetailForLog(nextExpanded, { bypassCache: true }); - if (!isMountedRef.current) return; - // Ensure new reference so memoized transforms update. - setDetailChunks(next ? [...next] : null); - }, - [fetchDetailForLog, getRowId, taskId, taskStatus] - ); - useEffect(() => { if (!shouldShowPreview) { setPreviewChunks(null); @@ -419,10 +384,7 @@ export const MemberLogsTab = ({ useEffect(() => { const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; if (!expandedLogSummary) return; - // When task logs are auto-refreshing, the summary refresh loop also refreshes - // expanded details to keep everything in sync (and avoid duplicate requests). - if (shouldAutoRefreshSummary) return; - if (!expandedLogSummary.isOngoing) return; + if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return; let cancelled = false; @@ -440,6 +402,7 @@ export const MemberLogsTab = ({ } }; + void refreshDetail(); const interval = setInterval(() => void refreshDetail(), 5000); return () => { diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index a624b777..88757314 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -250,7 +250,7 @@ export const MessageComposer = ({ > -
+
{isLeadRecipient ? ( <> +
+ +
- ) : null} + ) : ( + + )} -
+
{!isTeamAlive ? ( Team offline @@ -395,15 +414,6 @@ export const MessageComposer = ({
- - svg { pointer-events: none; + filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.8)); } From 748d6a7b81709cc5767e9ea21ed3c1af16939239 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 7 Mar 2026 01:04:00 +0200 Subject: [PATCH 08/15] feat: update task comment and message length limits to use centralized constant - Replaced hardcoded maximum lengths for comments and messages with a centralized constant `MAX_TEXT_LENGTH` for consistency across components. - Enhanced README to provide a brief overview of the project. - Introduced a new function to build a compact members roster for improved team member display in reminders. - Added support for displaying image attachments from comments in the task detail dialog. --- README.md | 1 + src/main/ipc/teams.ts | 5 +- .../services/team/TeamProvisioningService.ts | 42 +- .../components/chat/markdownComponents.tsx | 28 +- .../components/chat/viewers/FileLink.tsx | 146 +++++++ .../chat/viewers/MarkdownViewer.tsx | 17 +- .../team/dialogs/SendMessageDialog.tsx | 16 +- .../team/dialogs/TaskCommentInput.tsx | 9 +- .../team/dialogs/TaskCommentsSection.tsx | 8 +- .../team/dialogs/TaskDetailDialog.tsx | 153 +++++++- .../team/messages/MessageComposer.tsx | 10 +- src/shared/constants/index.ts | 1 + src/shared/constants/teamLimits.ts | 2 + ...TeamProvisioningServicePostCompact.test.ts | 368 ++++++++++++++++++ test/renderer/components/fileLink.test.ts | 93 +++++ 15 files changed, 837 insertions(+), 62 deletions(-) create mode 100644 src/renderer/components/chat/viewers/FileLink.tsx create mode 100644 src/shared/constants/teamLimits.ts create mode 100644 test/main/services/team/TeamProvisioningServicePostCompact.test.ts create mode 100644 test/renderer/components/fileLink.test.ts diff --git a/README.md b/README.md index e3be5726..a2bf10d5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@


+ ## What is this A new approach to task management with AI agents. diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 434f761b..20919acf 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -60,6 +60,7 @@ import { } from '@preload/constants/ipcChannels'; import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban'; +import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits'; import { createLogger } from '@shared/utils/logger'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; @@ -2031,8 +2032,8 @@ async function handleAddTaskComment( if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; if (typeof text !== 'string' || text.trim().length === 0) return { success: false, error: 'Comment text must be non-empty' }; - if (text.trim().length > 2000) - return { success: false, error: 'Comment exceeds 2000 characters' }; + if (text.trim().length > MAX_TEXT_LENGTH) + return { success: false, error: `Comment exceeds ${MAX_TEXT_LENGTH} characters` }; const rawAttachments = Array.isArray(attachments) ? attachments : []; if (rawAttachments.length > MAX_ATTACHMENTS) { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 02894f3d..8a098bb8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -416,6 +416,16 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string { .join('\n'); } +/** Compact roster: name + role only, no workflow details. Used for post-compact reminders. */ +function buildCompactMembersRoster(members: TeamCreateRequest['members']): string { + return members + .map((member) => { + const rolePart = member.role?.trim() ? ` (${member.role.trim()})` : ''; + return `- ${member.name}${rolePart}`; + }) + .join('\n'); +} + function buildMemberSpawnPrompt( member: TeamCreateRequest['members'][number], displayName: string, @@ -574,8 +584,10 @@ function buildPersistentLeadContext(opts: { leadName: string; isSolo: boolean; members: TeamCreateRequest['members']; + /** When true, emit a compact roster (name + role only, no workflows). Used for post-compact reminders. */ + compact?: boolean; }): string { - const { teamName, leadName, isSolo, members } = opts; + const { teamName, leadName, isSolo, members, compact } = opts; const languageInstruction = getAgentLanguageInstruction(); const agentBlockPolicy = buildAgentBlockUsagePolicy(); const teamCtlOps = buildTeamCtlOpsInstructions(teamName, leadName); @@ -599,7 +611,7 @@ function buildPersistentLeadContext(opts: { `\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.` : ''; - const membersBlock = buildMembersPrompt(members); + const membersBlock = compact ? buildCompactMembersRoster(members) : buildMembersPrompt(members); const membersFooter = membersBlock ? `Members:\n${membersBlock}` : 'Members: (none — solo team lead)'; @@ -3018,11 +3030,7 @@ export class TeamProvisioningService { // (e.g., after session resume when teamContext is lost). We intercept the tool calls // from stdout and persist them to sentMessages.json under the correct team name, // ensuring the UI and notifications show the right team. - if ( - run.provisioningComplete && - !run.silentUserDmForward && - !run.suppressPostCompactReminderOutput - ) { + if (run.provisioningComplete && !run.silentUserDmForward) { this.captureSendMessageToUser(run, content ?? []); } @@ -3161,11 +3169,6 @@ export class TeamProvisioningService { } this.setLeadActivity(run, 'idle'); - - // Deferred post-compact context reinjection: inject durable rules on first idle after compact. - if (run.pendingPostCompactReminder && !run.postCompactReminderInFlight) { - void this.injectPostCompactReminder(run); - } } if (run.leadRelayCapture) { const capture = run.leadRelayCapture; @@ -3178,6 +3181,18 @@ export class TeamProvisioningService { clearTimeout(run.silentUserDmForwardClearHandle); run.silentUserDmForwardClearHandle = null; } + + // Deferred post-compact context reinjection: inject durable rules on first idle after compact. + // Placed AFTER leadRelayCapture/silentUserDmForward cleanup so a previously-deferred + // reminder can proceed now that the blocking conditions are cleared. + if ( + run.provisioningComplete && + run.pendingPostCompactReminder && + !run.postCompactReminderInFlight + ) { + void this.injectPostCompactReminder(run); + } + if (!run.provisioningComplete && !run.cancelRequested) { void this.handleProvisioningTurnComplete(run); } @@ -3328,6 +3343,7 @@ export class TeamProvisioningService { leadName, isSolo, members: run.request.members, + compact: true, }); // Best-effort: fetch fresh task board snapshot. @@ -3364,7 +3380,7 @@ export class TeamProvisioningService { persistentContext, taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '', ``, - `Acknowledge briefly (1 sentence max) and continue with any pending work.`, + `This is a context-only reminder. Do NOT start new work or execute tasks in this turn. Reply with a single word: "OK".`, ] .filter(Boolean) .join('\n'); diff --git a/src/renderer/components/chat/markdownComponents.tsx b/src/renderer/components/chat/markdownComponents.tsx index a78f969f..f093a572 100644 --- a/src/renderer/components/chat/markdownComponents.tsx +++ b/src/renderer/components/chat/markdownComponents.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { PROSE_BODY } from '@renderer/constants/cssVariables'; import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils'; +import { FileLink, isRelativeUrl } from './viewers/FileLink'; import type { Components } from 'react-markdown'; @@ -77,17 +78,22 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo ), // Links — inline element, no hl(); parent block element's hl() descends here - a: ({ href, children }) => ( - - {children} - - ), + a: ({ href, children }) => { + if (href && isRelativeUrl(href)) { + return {children}; + } + return ( + + {children} + + ); + }, // Strong/Bold — inline element, no hl() strong: ({ children }) => ( diff --git a/src/renderer/components/chat/viewers/FileLink.tsx b/src/renderer/components/chat/viewers/FileLink.tsx new file mode 100644 index 00000000..34dd9522 --- /dev/null +++ b/src/renderer/components/chat/viewers/FileLink.tsx @@ -0,0 +1,146 @@ +/** + * FileLink — clickable file path link for markdown content. + * Opens the file in the built-in editor (team context) or copies the absolute path (session context). + * + * Follows the LocalImage pattern (MarkdownViewer.tsx) — a standalone React component + * used inside react-markdown's `a` component factory. + */ + +import React from 'react'; + +import { PROSE_LINK } from '@renderer/constants/cssVariables'; +import { useStore } from '@renderer/store'; +import type { AppState } from '@renderer/store/types'; +import { Check, FileCode } from 'lucide-react'; + +// ============================================================================= +// Exported utilities +// ============================================================================= + +/** Parse "path:line" format (e.g. "src/foo.ts:42") */ +export function parsePathWithLine(href: string): { filePath: string; line: number | null } { + let decoded: string; + try { + decoded = decodeURIComponent(href); + } catch { + decoded = href; + } + const match = decoded.match(/^(.+?):(\d+)$/); + if (match) return { filePath: match[1], line: parseInt(match[2], 10) }; + return { filePath: decoded, line: null }; +} + +/** Check if a URL is relative (not a protocol, not a hash, not data/mailto) */ +export function isRelativeUrl(url: string): boolean { + return ( + !!url && + !url.startsWith('#') && + !url.includes('://') && + !url.startsWith('data:') && + !url.startsWith('mailto:') + ); +} + +// ============================================================================= +// Internal helpers +// ============================================================================= + +function resolveRelativePath(relativeSrc: string, baseDir: string): string { + const parts = `${baseDir}/${relativeSrc}`.split('/'); + const resolved: string[] = []; + for (const part of parts) { + if (part === '.' || part === '') continue; + if (part === '..') { + resolved.pop(); + } else { + resolved.push(part); + } + } + return '/' + resolved.join('/'); +} + +/** Project path based on active tab context (avoids stale cross-tab state) */ +function selectContextProjectPath(s: AppState): string | null { + const activeTab = s.openTabs.find((t) => t.id === s.activeTabId); + if (!activeTab) return null; + + switch (activeTab.type) { + case 'team': + return s.selectedTeamData?.config.projectPath ?? null; + case 'session': + return s.sessionDetail?.session?.projectPath ?? null; + default: + return null; + } +} + +function selectIsTeamTab(s: AppState): boolean { + const activeTab = s.openTabs.find((t) => t.id === s.activeTabId); + return activeTab?.type === 'team'; +} + +// ============================================================================= +// Component +// ============================================================================= + +interface FileLinkProps { + href: string; + children: React.ReactNode; +} + +export const FileLink = React.memo(function FileLink({ + href, + children, +}: FileLinkProps): React.ReactElement { + const projectPath = useStore(selectContextProjectPath); + const isTeamTab = useStore(selectIsTeamTab); + const [copied, setCopied] = React.useState(false); + + if (!projectPath) { + return ( + + {children} + + ); + } + + const { filePath: relativePath, line } = parsePathWithLine(href); + const absolutePath = resolveRelativePath(relativePath, projectPath); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + + if (isTeamTab) { + const { revealFileInEditor, setPendingGoToLine } = useStore.getState(); + if (line !== null) setPendingGoToLine(line); + revealFileInEditor(absolutePath); + } else { + void navigator.clipboard.writeText(absolutePath).then( + () => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, + () => { + // Clipboard API may not be available in all contexts + } + ); + } + }; + + return ( + + + {children} + {copied && } + + ); +}); diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 460640b0..d95a0baf 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -36,6 +36,7 @@ import { type SearchContext, } from '../searchHighlightUtils'; +import { FileLink, isRelativeUrl } from './FileLink'; import { MermaidDiagram } from './MermaidDiagram'; // ============================================================================= @@ -72,18 +73,6 @@ function allowCustomProtocols(url: string): string { return defaultUrlTransform(url); } -/** Check if a URL is relative (not absolute, not data, not mailto, not hash) */ -function isRelativeUrl(url: string): boolean { - return ( - !!url && - !url.startsWith('http://') && - !url.startsWith('https://') && - !url.startsWith('data:') && - !url.startsWith('#') && - !url.startsWith('mailto:') - ); -} - /** Resolve a relative path to an absolute path given a base directory */ function resolveRelativePath(relativeSrc: string, baseDir: string): string { const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc; @@ -255,6 +244,10 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon ); } + // Relative file paths — open in built-in editor or copy path + if (href && isRelativeUrl(href)) { + return {children}; + } return ( 0 && finalText.length > 0 && - finalText.length <= MAX_MESSAGE_LENGTH && - summary.trim().length > 0 && + finalText.length <= MAX_TEXT_LENGTH && !sending && !attachmentsBlocked; @@ -201,10 +200,13 @@ export const SendMessageDialog = ({ const handleSubmit = (): void => { if (!canSend) return; + // TODO: Research whether duplicating message as summary is correct — the team lead + // may only see the Summary field and not the full Message body. Need to verify. + const effectiveSummary = summary.trim() || trimmedText; onSend( member.trim(), finalText, - summary.trim(), + effectiveSummary, attachments.length > 0 ? attachments : undefined ); textDraft.clearDraft(); @@ -400,7 +402,7 @@ export const SendMessageDialog = ({ onModEnter={handleSubmit} minRows={4} maxRows={12} - maxLength={MAX_MESSAGE_LENGTH} + maxLength={MAX_TEXT_LENGTH} disabled={sending} cornerAction={
- -
- - setSummary(e.target.value)} - /> -

- Shown as notification preview. Team lead also sees this for peer messages. -

-
- - - - ); diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 9f38bb8a..3808d4a0 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -59,7 +59,7 @@ export const MemberCard = ({ className="group relative cursor-pointer rounded px-2 py-1.5" style={{ borderLeft: `3px solid ${colors.border}`, - backgroundColor: colors.badge, + background: `linear-gradient(to right, ${colors.badge}, transparent)`, }} title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined} role="button" diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 14d426f0..d3094d96 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -1,4 +1,5 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { useStore } from '@renderer/store'; import { MemberCard } from './MemberCard'; @@ -32,6 +33,8 @@ export const MemberList = ({ onAssignTask, onOpenTask, }: MemberListProps): React.JSX.Element => { + const sidebarCollapsed = useStore((s) => s.sidebarCollapsed); + const gridClass = sidebarCollapsed ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1'; const activeMembers = members .filter((m) => !m.removedAt) .sort((a, b) => { @@ -75,14 +78,16 @@ export const MemberList = ({ }; return ( -
- {activeMembers.map((member) => renderCard(member, false))} +
+
{activeMembers.map((member) => renderCard(member, false))}
{removedMembers.length > 0 && ( <>
Removed ({removedMembers.length})
- {removedMembers.map((member) => renderCard(member, true))} +
+ {removedMembers.map((member) => renderCard(member, true))} +
)}
diff --git a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts index 295e9150..78d66bea 100644 --- a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts +++ b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts @@ -155,7 +155,7 @@ describe('TeamProvisioningService post-compact lifecycle', () => { await svc.cancelProvisioning(runId); }); - it('compact_boundary does NOT set pending when reminder is already in-flight', async () => { + it('compact_boundary re-arms pending when reminder is already in-flight', async () => { const { svc, run, runId } = await setupRunningTeam('compact-test-3'); run.postCompactReminderInFlight = true; @@ -165,8 +165,8 @@ describe('TeamProvisioningService post-compact lifecycle', () => { compact_metadata: { trigger: 'auto' }, }); - // Should NOT be set because in-flight - expect(run.pendingPostCompactReminder).toBe(false); + // Should be re-armed even during in-flight — follow-up reminder after current completes + expect(run.pendingPostCompactReminder).toBe(true); run.postCompactReminderInFlight = false; await svc.cancelProvisioning(runId); @@ -365,4 +365,109 @@ describe('TeamProvisioningService post-compact lifecycle', () => { // Should NOT re-arm pending (strict drop) expect(run.pendingPostCompactReminder).toBe(false); }); + + it('result.error clears pending even when NOT in-flight (no stale pending survives)', async () => { + const { svc, run } = await setupRunningTeam('compact-test-13'); + // pending set but reminder never started (no in-flight) + run.pendingPostCompactReminder = true; + run.postCompactReminderInFlight = false; + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + (svc as any).handleStreamJsonMessage(run, { + type: 'result', + subtype: 'error', + error: 'some error', + }); + + warnSpy.mockRestore(); + + // Pending must be cleared — must not fire on a later unrelated result.success + expect(run.pendingPostCompactReminder).toBe(false); + expect(run.postCompactReminderInFlight).toBe(false); + }); + + it('compact_boundary during in-flight produces follow-up reminder after current completes', async () => { + const { svc, run, runId, writeSpy } = await setupRunningTeam('compact-test-14'); + + // Start first reminder + run.pendingPostCompactReminder = true; + writeSpy.mockClear(); + await (svc as any).injectPostCompactReminder(run); + expect(run.postCompactReminderInFlight).toBe(true); + expect(run.pendingPostCompactReminder).toBe(false); + + // Compact fires while first reminder is in-flight + (svc as any).handleStreamJsonMessage(run, { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'auto' }, + }); + // Re-armed + expect(run.pendingPostCompactReminder).toBe(true); + + // First reminder completes (result.success). + // The success handler clears in-flight, preserves pending, transitions to idle, + // then the injection hook fires immediately because pending=true && !inFlight. + // So after success, a NEW reminder is already in-flight. + writeSpy.mockClear(); + (svc as any).handleStreamJsonMessage(run, { + type: 'result', + subtype: 'success', + result: {}, + }); + + // Allow the void async injection to run + await new Promise((r) => setTimeout(r, 50)); + + // A follow-up reminder was triggered: in-flight again, pending consumed + expect(run.postCompactReminderInFlight).toBe(true); + expect(run.pendingPostCompactReminder).toBe(false); + // Verify a second write happened (the follow-up reminder) + expect(writeSpy).toHaveBeenCalledTimes(1); + + await svc.cancelProvisioning(runId); + }); + + it('reminder reads live config.json members instead of stale launch-time members', async () => { + const { svc, run, runId, writeSpy } = await setupRunningTeam('compact-test-15'); + + // Original launch had only alice + run.request.members = [{ name: 'alice', role: 'developer' }]; + + // Mock configReader.getConfig to return updated team with alice + bob + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + name: 'compact-test-15', + description: 'Test team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', agentType: 'teammate', role: 'developer' }, + { name: 'bob', agentType: 'teammate', role: 'tester' }, + ], + })), + }; + + run.pendingPostCompactReminder = true; + writeSpy.mockClear(); + await (svc as any).injectPostCompactReminder(run); + + const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); + const parsed = JSON.parse(payload) as { + type: string; + message?: { role: string; content: { type: string; text?: string }[] }; + }; + const text = parsed.message?.content?.[0]?.text ?? ''; + + // Should contain bob from live config, not just alice from launch-time + expect(text).toContain('bob'); + expect(text).toContain('alice'); + // Should NOT be in solo mode — check for the actual solo constraint block + expect(text).not.toContain('SOLO MODE: This team CURRENTLY has ZERO teammates'); + // Members section should include both + expect(text).toContain('- alice (developer)'); + expect(text).toContain('- bob (tester)'); + + await svc.cancelProvisioning(runId); + }); }); From 919d40b7bc1bba6a90a9da5f1215732fc67a6cd9 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 7 Mar 2026 12:01:39 +0200 Subject: [PATCH 10/15] chore: remove MCP code (to new build new one from scratch) --- mcp-server/.gitignore | 2 + mcp-server/README.md | 301 ------------------ mcp-server/src/index.ts | 20 -- mcp-server/src/output-parser.ts | 52 --- mcp-server/src/schemas.ts | 115 ------- mcp-server/src/teamctl-runner.ts | 155 --------- mcp-server/src/tools/index.ts | 42 --- mcp-server/src/tools/kanban-move.ts | 45 --- mcp-server/src/tools/kanban-reviewers.ts | 46 --- mcp-server/src/tools/message-send.ts | 39 --- mcp-server/src/tools/review-action.ts | 50 --- mcp-server/src/tools/task-attach.ts | 42 --- mcp-server/src/tools/task-briefing.ts | 32 -- mcp-server/src/tools/task-comment.ts | 34 -- mcp-server/src/tools/task-create.ts | 52 --- mcp-server/src/tools/task-get.ts | 30 -- mcp-server/src/tools/task-link.ts | 51 --- mcp-server/src/tools/task-list.ts | 31 -- mcp-server/src/tools/task-set-owner.ts | 38 --- mcp-server/src/tools/task-set-status.ts | 35 -- mcp-server/test/output-parser.test.ts | 76 ----- mcp-server/test/schemas.test.ts | 220 ------------- mcp-server/test/teamctl-runner.test.ts | 78 ----- mcp-server/test/tools/kanban-move.test.ts | 42 --- .../test/tools/kanban-reviewers.test.ts | 61 ---- mcp-server/test/tools/message-send.test.ts | 48 --- mcp-server/test/tools/register-all.test.ts | 28 -- mcp-server/test/tools/review-action.test.ts | 66 ---- mcp-server/test/tools/task-attach.test.ts | 49 --- mcp-server/test/tools/task-briefing.test.ts | 33 -- mcp-server/test/tools/task-comment.test.ts | 33 -- mcp-server/test/tools/task-create.test.ts | 65 ---- mcp-server/test/tools/task-get.test.ts | 29 -- mcp-server/test/tools/task-link.test.ts | 44 --- mcp-server/test/tools/task-list.test.ts | 29 -- mcp-server/test/tools/task-set-owner.test.ts | 42 --- mcp-server/test/tools/task-set-status.test.ts | 31 -- mcp-server/test/tools/test-helpers.ts | 58 ---- 38 files changed, 2 insertions(+), 2242 deletions(-) create mode 100644 mcp-server/.gitignore delete mode 100644 mcp-server/README.md delete mode 100644 mcp-server/src/index.ts delete mode 100644 mcp-server/src/output-parser.ts delete mode 100644 mcp-server/src/schemas.ts delete mode 100644 mcp-server/src/teamctl-runner.ts delete mode 100644 mcp-server/src/tools/index.ts delete mode 100644 mcp-server/src/tools/kanban-move.ts delete mode 100644 mcp-server/src/tools/kanban-reviewers.ts delete mode 100644 mcp-server/src/tools/message-send.ts delete mode 100644 mcp-server/src/tools/review-action.ts delete mode 100644 mcp-server/src/tools/task-attach.ts delete mode 100644 mcp-server/src/tools/task-briefing.ts delete mode 100644 mcp-server/src/tools/task-comment.ts delete mode 100644 mcp-server/src/tools/task-create.ts delete mode 100644 mcp-server/src/tools/task-get.ts delete mode 100644 mcp-server/src/tools/task-link.ts delete mode 100644 mcp-server/src/tools/task-list.ts delete mode 100644 mcp-server/src/tools/task-set-owner.ts delete mode 100644 mcp-server/src/tools/task-set-status.ts delete mode 100644 mcp-server/test/output-parser.test.ts delete mode 100644 mcp-server/test/schemas.test.ts delete mode 100644 mcp-server/test/teamctl-runner.test.ts delete mode 100644 mcp-server/test/tools/kanban-move.test.ts delete mode 100644 mcp-server/test/tools/kanban-reviewers.test.ts delete mode 100644 mcp-server/test/tools/message-send.test.ts delete mode 100644 mcp-server/test/tools/register-all.test.ts delete mode 100644 mcp-server/test/tools/review-action.test.ts delete mode 100644 mcp-server/test/tools/task-attach.test.ts delete mode 100644 mcp-server/test/tools/task-briefing.test.ts delete mode 100644 mcp-server/test/tools/task-comment.test.ts delete mode 100644 mcp-server/test/tools/task-create.test.ts delete mode 100644 mcp-server/test/tools/task-get.test.ts delete mode 100644 mcp-server/test/tools/task-link.test.ts delete mode 100644 mcp-server/test/tools/task-list.test.ts delete mode 100644 mcp-server/test/tools/task-set-owner.test.ts delete mode 100644 mcp-server/test/tools/task-set-status.test.ts delete mode 100644 mcp-server/test/tools/test-helpers.ts diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 00000000..763301fc --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ \ No newline at end of file diff --git a/mcp-server/README.md b/mcp-server/README.md deleted file mode 100644 index f6b0d7de..00000000 --- a/mcp-server/README.md +++ /dev/null @@ -1,301 +0,0 @@ -# @claude-team/mcp-server - -**Model Context Protocol (MCP) server for managing Claude Agent Teams kanban board and tasks.** - -Exposes 13 tools so AI agents (Claude, Cursor, or any MCP-compatible client) can create tasks, manage the kanban board, conduct code reviews, and send messages — backed by the same `teamctl.js` CLI that powers the Claude Agent Teams UI desktop app. - ---- - -## Table of Contents - -- [Overview](#overview) -- [Relationship to Claude Agent Teams UI](#relationship-to-claude-agent-teams-ui) -- [Quick start](#quick-start) -- [Prerequisites](#prerequisites) -- [Installation](#installation) -- [Configuration](#configuration) -- [Tools Reference](#tools-reference) -- [Data Storage](#data-storage) -- [Development](#development) -- [Testing](#testing) -- [Troubleshooting](#troubleshooting) -- [License](#license) - ---- - -## Overview - -Implements the [Model Context Protocol](https://modelcontextprotocol.io/) over **stdio**. All operations are delegated to `teamctl.js`, which: - -- Stores task data as JSON under `~/.claude/tasks/{teamName}/` -- Stores team config under `~/.claude/teams/{teamName}/` -- Is installed automatically when you run Claude Agent Teams UI at least once - -### Use cases - -| Scenario | Description | -|----------|-------------| -| **Cursor / Claude Desktop** | Add the server to MCP config so AI assistants can create tasks, assign owners, move cards, and send messages without leaving the chat | -| **Automation scripts** | Programmatic interface to the same task board that Claude Code agents use | -| **Multi-agent workflows** | Multiple AI agents sharing one task board, coordinating via comments and messages | - -### Architecture - -``` -┌─────────────────────┐ stdio ┌──────────────────────┐ spawn ┌─────────────────┐ -│ MCP Client │ ◄────────────► │ @claude-team/ │ ◄────────────► │ teamctl.js │ -│ (Cursor, Claude, │ │ mcp-server │ │ ~/.claude/ │ -│ custom scripts) │ │ (FastMCP + 13 tools) │ │ tools/ │ -└─────────────────────┘ └──────────────────────┘ └─────────────────┘ -``` - ---- - -## Relationship to Claude Agent Teams UI - -| Component | Role | -|-----------|------| -| **Claude Agent Teams UI** | Desktop app (Electron). Visualizes sessions, kanban board, code review, team messaging. Installs `teamctl.js` on first run. | -| **teamctl.js** | CLI at `~/.claude/tools/teamctl.js`. Reads/writes task JSON, manages kanban, inboxes, reviews. Used by both the app and agents. | -| **@claude-team/mcp-server** | MCP server wrapping `teamctl.js` so Cursor, Claude Desktop, and other MCP clients can call the same operations as tools. | - -Agents in Claude Code use `teamctl.js` via Bash. Agents in Cursor or Claude Desktop use this MCP server. Both operate on the same data. - ---- - -## Quick start - -1. Run **Claude Agent Teams UI** at least once (installs `teamctl.js`) -2. Build the MCP server: `cd mcp-server && pnpm install && pnpm build` -3. Add to Cursor MCP config (`~/.cursor/mcp.json`): - -```json -{ - "mcpServers": { - "claude-team-tools": { - "command": "node", - "args": ["/absolute/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"] - } - } -} -``` - -4. Restart Cursor. The 13 tools will appear for the AI assistant. - ---- - -## Prerequisites - -- **Node.js 20+** -- **Claude Agent Teams UI** run at least once (installs `teamctl.js` to `~/.claude/tools/teamctl.js`) - -If `teamctl.js` is missing, the server throws at startup: - -``` -teamctl.js not found at ~/.claude/tools/teamctl.js. -Make sure Claude Agent Teams UI has been run at least once, -or set the TEAMCTL_PATH environment variable. -``` - ---- - -## Installation - -### From the monorepo (development) - -```bash -cd mcp-server -pnpm install -pnpm build -``` - -### As a dependency - -```bash -pnpm add @claude-team/mcp-server -# or -npm install @claude-team/mcp-server -``` - ---- - -## Configuration - -### Cursor - -Add to `~/.cursor/mcp.json` or project `.cursor/mcp.json`: - -```json -{ - "mcpServers": { - "claude-team-tools": { - "command": "node", - "args": ["/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"] - } - } -} -``` - -With global install (`pnpm link` or `npm link`): - -```json -{ - "mcpServers": { - "claude-team-tools": { - "command": "team-mcp-server" - } - } -} -``` - -### Claude Desktop - -Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or equivalent: - -```json -{ - "mcpServers": { - "claude-team-tools": { - "command": "node", - "args": ["/absolute/path/to/mcp-server/dist/index.js"] - } - } -} -``` - -### Environment variables - -| Variable | Description | -|----------|-------------| -| `TEAMCTL_PATH` | Path to `teamctl.js` if not at `~/.claude/tools/teamctl.js` | - ---- - -## Tools Reference - -All tools require a `team` parameter (team name, folder under `~/.claude/teams/`). - -### Task CRUD - -| Tool | Description | -|------|-------------| -| `task_create` | Create a task. Optional: `owner`, `description`, `blocked_by`, `related`, `status`, `notify`, `from` | -| `task_get` | Get a task by ID. Returns full JSON (status, owner, comments, dependencies, work intervals, history) | -| `task_list` | List all tasks. Returns JSON array (filter internal tasks client-side if needed) | -| `task_set_status` | Set status: `pending` → `in_progress` → `completed` → `deleted`. Records work intervals | -| `task_set_owner` | Assign or unassign owner. Use `owner="clear"` to unassign. Optional: `notify`, `from` | - -### Task collaboration - -| Tool | Description | -|------|-------------| -| `task_comment` | Add a comment. Sends inbox notification to owner (unless commenter is owner). Optional: `from` | -| `task_link` | Link/unlink dependencies. Types: `blocked-by`, `blocks`, `related`. Bidirectional; circular deps rejected | -| `task_briefing` | Human-readable briefing for a member: their assigned tasks vs full board | -| `task_attach` | Attach a file. Modes: `copy`, `link`. MIME auto-detected (PNG, JPEG, GIF, WebP, PDF, ZIP). Max 20 MB | - -### Kanban board - -| Tool | Description | -|------|-------------| -| `kanban_move` | Move to `review` or `approved`, or `clear` from board. Moving to column sets status to `completed` | -| `kanban_reviewers` | Manage reviewers: `list` (JSON array), `add`, `remove` | - -### Code review - -| Tool | Description | -|------|-------------| -| `review_action` | `approve` — mark approved (optional comment, `notify_owner`). `request-changes` — remove from kanban, reset to `in_progress`, notify owner (comment required) | - -### Messaging - -| Tool | Description | -|------|-------------| -| `message_send` | Send inbox message to a member. Optional: `summary`, `from`. Triggers notifications | - ---- - -## Data Storage - -| Location | Contents | -|----------|----------| -| `~/.claude/tasks/{teamName}/` | Task JSON files (`1.json`, `2.json`, …) | -| `~/.claude/teams/{teamName}/` | Team config, kanban reviewers, inboxes | - -Task IDs are numeric (highwatermark). Team and member names must be safe path segments (no `.`, `..`, `/`, `\`, null bytes). - ---- - -## Development - -### Scripts - -| Command | Description | -|---------|-------------| -| `pnpm build` | Build with tsup → `dist/index.js` | -| `pnpm dev` | Run with tsx (no build) | -| `pnpm test` | Run Vitest tests | -| `pnpm test:watch` | Watch mode | -| `pnpm typecheck` | TypeScript check | - -### Project structure - -``` -mcp-server/ -├── src/ -│ ├── index.ts # FastMCP server, registers all tools -│ ├── teamctl-runner.ts # Spawns teamctl.js subprocess -│ ├── output-parser.ts # Parses JSON / "OK ..." from teamctl stdout -│ ├── schemas.ts # Zod schemas (team, taskId, member, etc.) -│ └── tools/ -│ ├── index.ts # registerAllTools() -│ ├── task-create.ts -│ ├── task-get.ts -│ ├── ... -│ └── message-send.ts -├── test/ -│ ├── tools/ # Per-tool tests -│ ├── teamctl-runner.test.ts -│ ├── output-parser.test.ts -│ └── schemas.test.ts -├── package.json -├── tsup.config.ts -└── vitest.config.ts -``` - -### Adding a new tool - -1. Create `src/tools/your-tool.ts` with `register(server, runner)` -2. Add to `ALL_TOOLS` in `src/tools/index.ts` -3. Add Zod parameters and map to `teamctl` CLI args -4. Use `parseJsonOutput`, `parseOkOutput`, or `parseTextOutput` from `output-parser.ts` -5. Add tests in `test/tools/your-tool.test.ts` - ---- - -## Testing - -Tests use a mock `ITeamctlRunner` (no real `teamctl.js` required): - -```bash -pnpm test -``` - -For integration tests with real `teamctl.js`, use the main app: `test/main/services/team/teamctl.test.ts`. - ---- - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| **teamctl.js not found** | Run Claude Agent Teams UI at least once, or set `TEAMCTL_PATH` | -| **Invalid team/member name** | Names: 1–128 chars, no `.`, `..`, `/`, `\`, null bytes | -| **MCP client not discovering tools** | Check server starts without errors; use absolute path in config; some clients require it | -| **Timeout errors** | Default 10s. Increase via `TeamctlRunnerOptions.timeoutMs` (code change) | - ---- - -## License - -Same as the parent project: [AGPL-3.0](../LICENSE). diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts deleted file mode 100644 index a30a5b06..00000000 --- a/mcp-server/src/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { FastMCP } from 'fastmcp'; -import { TeamctlRunner } from './teamctl-runner.js'; -import { registerAllTools } from './tools/index.js'; - -const server = new FastMCP({ - name: 'claude-team-tools', - version: '1.0.0', - instructions: `MCP server for managing Claude Agent Teams kanban board and tasks. - -Provides 13 tools for task CRUD, kanban board management, code reviews, and team messaging. -All operations are backed by teamctl.js — the battle-tested CLI tool from Claude Agent Teams UI. - -Data is stored as JSON files in ~/.claude/tasks/{teamName}/ and ~/.claude/teams/{teamName}/.`, -}); - -const runner = new TeamctlRunner(); - -registerAllTools(server, runner); - -server.start({ transportType: 'stdio' }); diff --git a/mcp-server/src/output-parser.ts b/mcp-server/src/output-parser.ts deleted file mode 100644 index e2b7c1a5..00000000 --- a/mcp-server/src/output-parser.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Parses teamctl stdout into structured results. - * - * teamctl outputs in three formats: - * 1. JSON — task create/get/list, attach, message send, kanban reviewers list - * 2. "OK ..." text — status changes, comments, links, kanban moves, reviews - * 3. Plain text — task briefing (multi-line human-readable report) - */ - -/** Parse JSON from teamctl stdout (task create/get/list, attach, message send) */ -export function parseJsonOutput(stdout: string): T { - const trimmed = stdout.trim(); - if (!trimmed) { - throw new Error('Empty output from teamctl (expected JSON)'); - } - try { - return JSON.parse(trimmed) as T; - } catch { - throw new Error( - `Failed to parse teamctl JSON output: ${trimmed.slice(0, 200)}`, - ); - } -} - -/** Parse "OK ..." acknowledgment lines from teamctl */ -export function parseOkOutput(stdout: string): string { - const trimmed = stdout.trim(); - if (trimmed.startsWith('OK ')) { - return trimmed.slice(3); // Strip "OK " prefix, keep the rest - } - // Some commands output just "OK\n" - if (trimmed === 'OK') { - return 'OK'; - } - // Return as-is if format is unexpected — don't throw - return trimmed; -} - -/** Return plain text as-is (briefing, help output) */ -export function parseTextOutput(stdout: string): string { - return stdout.trim(); -} - -/** - * Format teamctl stderr into a user-friendly error message. - * teamctl writes errors to stderr via `die(message)` and exits with code 1. - */ -export function formatError(stderr: string, stdout: string): string { - const msg = stderr.trim() || stdout.trim(); - if (!msg) return 'Unknown teamctl error'; - return msg; -} diff --git a/mcp-server/src/schemas.ts b/mcp-server/src/schemas.ts deleted file mode 100644 index cc808e0b..00000000 --- a/mcp-server/src/schemas.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { z } from 'zod'; -import { sep, isAbsolute } from 'node:path'; - -// --------------------------------------------------------------------------- -// Identifiers -// --------------------------------------------------------------------------- - -/** - * Matches teamctl's `isSafePathSegment()`: - * rejects empty, '.', '..', and strings containing '/', '\\', '\0', or '..' - */ -const safePathSegment = (label: string) => - z - .string() - .min(1) - .max(128) - .refine( - (v) => - v.trim().length > 0 && - v !== '.' && - v !== '..' && - !v.includes('/') && - !v.includes('\\') && - !v.includes('\0') && - !v.includes('..'), - { message: `Invalid ${label}: must be a safe path segment` }, - ); - -/** Team name — folder inside `~/.claude/teams/` */ -export const teamNameSchema = safePathSegment('team name').describe( - 'Team name (folder in ~/.claude/teams/)', -); - -/** Numeric task ID produced by teamctl's highwatermark counter */ -export const taskIdSchema = z - .string() - .regex(/^\d{1,10}$/, 'Task ID must be a positive integer (e.g. "1", "42")') - .describe('Numeric task ID'); - -/** Team member name — folder inside inboxes, safe path segment */ -export const memberNameSchema = safePathSegment('member name').describe( - 'Team member name', -); - -// --------------------------------------------------------------------------- -// Enums — match teamctl's normalizeStatus / normalizeColumn / etc. -// --------------------------------------------------------------------------- - -export const taskStatusSchema = z.enum([ - 'pending', - 'in_progress', - 'completed', - 'deleted', -]); - -export const kanbanColumnSchema = z - .enum(['review', 'approved']) - .describe('Kanban column to move task to'); - -export const clarificationSchema = z - .enum(['lead', 'user', 'clear']) - .describe('Who needs to clarify: lead, user, or clear the flag'); - -export const linkTypeSchema = z - .enum(['blocked-by', 'blocks', 'related']) - .describe('Relationship type between tasks'); - -export const linkOperationSchema = z.enum(['link', 'unlink']); - -export const reviewDecisionSchema = z.enum(['approve', 'request-changes']); - -export const reviewerOperationSchema = z.enum(['list', 'add', 'remove']); - -// --------------------------------------------------------------------------- -// Composite schemas -// --------------------------------------------------------------------------- - -/** Comma-separated task IDs sent as a single CLI argument */ -export const taskIdsArraySchema = z - .array(taskIdSchema) - .describe('Array of task IDs (e.g. ["1", "3"])'); - -// --------------------------------------------------------------------------- -// File / attachment schemas — defence-in-depth for CLI arguments -// --------------------------------------------------------------------------- - -/** Absolute file path without traversal sequences */ -export const filePathSchema = z - .string() - .min(1) - .refine((p) => isAbsolute(p), { message: 'Path must be absolute' }) - .refine((p) => !p.split(sep).includes('..'), { - message: 'Path must not contain traversal sequences (..)', - }) - .refine((p) => !p.includes('\0'), { - message: 'Path must not contain null bytes', - }); - -/** Safe filename — no path separators, no null bytes, reasonable length */ -export const safeFilenameSchema = z - .string() - .min(1) - .max(255) - .refine( - (f) => !f.includes('/') && !f.includes('\\') && !f.includes('\0'), - { message: 'Filename must not contain path separators or null bytes' }, - ); - -/** MIME type — standard type/subtype format */ -export const mimeTypeSchema = z - .string() - .regex( - /^[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*$/, - 'Invalid MIME type format (expected type/subtype)', - ); diff --git a/mcp-server/src/teamctl-runner.ts b/mcp-server/src/teamctl-runner.ts deleted file mode 100644 index d9892106..00000000 --- a/mcp-server/src/teamctl-runner.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface TeamctlResult { - stdout: string; - stderr: string; - exitCode: number; -} - -export interface ITeamctlRunner { - execute(args: string[]): Promise; -} - -export interface TeamctlRunnerOptions { - /** Explicit path to teamctl.js. Falls back to TEAMCTL_PATH env, then default. */ - teamctlPath?: string; - /** Max concurrent subprocess calls (default: 5) */ - maxConcurrent?: number; - /** Subprocess timeout in ms (default: 10 000) */ - timeoutMs?: number; -} - -// --------------------------------------------------------------------------- -// Semaphore — limits concurrent subprocess spawns -// --------------------------------------------------------------------------- - -class Semaphore { - private current = 0; - private queue: Array<() => void> = []; - - constructor(private readonly max: number) {} - - async acquire(): Promise { - if (this.current < this.max) { - this.current++; - return; - } - return new Promise((resolve) => { - this.queue.push(() => { - this.current++; - resolve(); - }); - }); - } - - release(): void { - this.current--; - const next = this.queue.shift(); - if (next) next(); - } -} - -// --------------------------------------------------------------------------- -// TeamctlRunner -// --------------------------------------------------------------------------- - -export class TeamctlRunner implements ITeamctlRunner { - readonly teamctlPath: string; - private readonly timeoutMs: number; - private readonly semaphore: Semaphore; - - constructor(options?: TeamctlRunnerOptions) { - this.teamctlPath = resolveTeamctlPath(options?.teamctlPath); - this.timeoutMs = options?.timeoutMs ?? 10_000; - this.semaphore = new Semaphore(options?.maxConcurrent ?? 5); - - // Fail fast if teamctl.js doesn't exist - if (!existsSync(this.teamctlPath)) { - throw new Error( - `teamctl.js not found at ${this.teamctlPath}. ` + - 'Make sure Claude Agent Teams UI has been run at least once, ' + - 'or set the TEAMCTL_PATH environment variable.', - ); - } - } - - async execute(args: string[]): Promise { - await this.semaphore.acquire(); - try { - return await this.spawn(args); - } finally { - this.semaphore.release(); - } - } - - private spawn(args: string[]): Promise { - return new Promise((resolve, reject) => { - const child = spawn('node', [this.teamctlPath, ...args], { - stdio: ['ignore', 'pipe', 'pipe'], - timeout: this.timeoutMs, - env: { ...process.env }, - }); - - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - - child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); - child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); - - let settled = false; - - child.on('error', (err) => { - if (!settled) { - settled = true; - reject(new Error(`Failed to spawn teamctl: ${err.message}`)); - } - }); - - child.on('close', (code, signal) => { - if (settled) return; - settled = true; - - const stdout = Buffer.concat(stdoutChunks).toString('utf-8'); - const stderr = Buffer.concat(stderrChunks).toString('utf-8'); - - if (signal === 'SIGTERM') { - const partial = stdout.slice(0, 500) || stderr.slice(0, 500); - reject( - new Error( - `teamctl timed out after ${this.timeoutMs}ms` + - (partial ? `. Partial output: ${partial}` : ''), - ), - ); - return; - } - - resolve({ - stdout, - stderr, - exitCode: code ?? 1, - }); - }); - }); - } -} - -// --------------------------------------------------------------------------- -// Path resolution -// --------------------------------------------------------------------------- - -function resolveTeamctlPath(explicit?: string): string { - if (explicit) return explicit; - - const fromEnv = process.env['TEAMCTL_PATH']; - if (fromEnv) return fromEnv; - - // Default: ~/.claude/tools/teamctl.js - return join(homedir(), '.claude', 'tools', 'teamctl.js'); -} diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts deleted file mode 100644 index 6a4da6f9..00000000 --- a/mcp-server/src/tools/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; - -import { register as taskCreate } from './task-create.js'; -import { register as taskSetStatus } from './task-set-status.js'; -import { register as taskSetOwner } from './task-set-owner.js'; -import { register as taskGet } from './task-get.js'; -import { register as taskList } from './task-list.js'; -import { register as taskComment } from './task-comment.js'; -import { register as taskLink } from './task-link.js'; -import { register as taskBriefing } from './task-briefing.js'; -import { register as taskAttach } from './task-attach.js'; -import { register as kanbanMove } from './kanban-move.js'; -import { register as kanbanReviewers } from './kanban-reviewers.js'; -import { register as reviewAction } from './review-action.js'; -import { register as messageSend } from './message-send.js'; - -const ALL_TOOLS = [ - taskCreate, - taskSetStatus, - taskSetOwner, - taskGet, - taskList, - taskComment, - taskLink, - taskBriefing, - taskAttach, - kanbanMove, - kanbanReviewers, - reviewAction, - messageSend, -] as const; - -/** - * Register all 13 MCP tools with the server. - * Each tool wraps a teamctl CLI command via the runner. - */ -export function registerAllTools(server: FastMCP, runner: ITeamctlRunner): void { - for (const register of ALL_TOOLS) { - register(server, runner); - } -} diff --git a/mcp-server/src/tools/kanban-move.ts b/mcp-server/src/tools/kanban-move.ts deleted file mode 100644 index 565a836f..00000000 --- a/mcp-server/src/tools/kanban-move.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseOkOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema, kanbanColumnSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'kanban_move', - description: `Move a task to a kanban column or clear it from the kanban board. - -Columns: "review" (awaiting code review) or "approved" (review passed). -Use operation "clear" to remove a task from the kanban board (returns to status-based display). -Moving to a kanban column also sets the task status to "completed".`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - operation: z.enum(['set-column', 'clear']).describe('"set-column" to move, "clear" to remove from kanban'), - column: kanbanColumnSchema.optional().describe('Target column (required for set-column)'), - }), - execute: async (args) => { - let cliArgs: string[]; - - if (args.operation === 'set-column') { - if (!args.column) { - throw new UserError('column is required when operation is "set-column"'); - } - cliArgs = ['--team', args.team, 'kanban', 'set-column', args.task_id, args.column]; - } else { - cliArgs = ['--team', args.team, 'kanban', 'clear', args.task_id]; - } - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to update kanban: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/kanban-reviewers.ts b/mcp-server/src/tools/kanban-reviewers.ts deleted file mode 100644 index 03569247..00000000 --- a/mcp-server/src/tools/kanban-reviewers.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseJsonOutput, parseOkOutput } from '../output-parser.js'; -import { teamNameSchema, memberNameSchema, reviewerOperationSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'kanban_reviewers', - description: `Manage the kanban board's reviewer list. - -Operations: -- "list": returns JSON array of reviewer names -- "add": adds a reviewer (name required) -- "remove": removes a reviewer (name required)`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - operation: reviewerOperationSchema.describe('"list", "add", or "remove"'), - name: memberNameSchema.optional().describe('Reviewer name (required for add/remove)'), - }), - execute: async (args) => { - if (args.operation !== 'list' && !args.name) { - throw new UserError(`name is required for "${args.operation}" operation`); - } - - const cliArgs = ['--team', args.team, 'kanban', 'reviewers', args.operation]; - if (args.name) cliArgs.push(args.name); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to manage reviewers: ${result.stderr.trim() || result.stdout.trim()}`); - } - - // "list" returns JSON array, "add"/"remove" return "OK ..." text - if (args.operation === 'list') { - return parseJsonOutput(result.stdout); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/message-send.ts b/mcp-server/src/tools/message-send.ts deleted file mode 100644 index 0693bd33..00000000 --- a/mcp-server/src/tools/message-send.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseJsonOutput } from '../output-parser.js'; -import { teamNameSchema, memberNameSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'message_send', - description: `Send an inbox message to a team member. Returns delivery confirmation JSON. - -Messages appear in the member's inbox and can trigger notifications. -The "source" field is automatically stripped for security — external callers cannot impersonate system notifications.`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - to: memberNameSchema.describe('Recipient member name'), - text: z.string().min(1).max(10000).describe('Message text'), - summary: z.string().max(200).optional().describe('Short summary for notification preview'), - from: memberNameSchema.optional().describe('Sender name'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'message', 'send', '--to', args.to, '--text', args.text]; - - if (args.summary) cliArgs.push('--summary', args.summary); - if (args.from) cliArgs.push('--from', args.from); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to send message: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseJsonOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/review-action.ts b/mcp-server/src/tools/review-action.ts deleted file mode 100644 index 64bad45d..00000000 --- a/mcp-server/src/tools/review-action.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseOkOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema, memberNameSchema, reviewDecisionSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'review_action', - description: `Approve a task or request changes. - -- "approve": marks the task as approved in kanban, optionally posts a review comment -- "request-changes": removes from kanban, resets status to "in_progress", notifies the task owner with the change request comment`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - decision: reviewDecisionSchema.describe('"approve" or "request-changes"'), - comment: z.string().max(5000).optional().describe('Review comment (required for request-changes)'), - from: memberNameSchema.optional().describe('Reviewer name'), - notify_owner: z.boolean().optional().describe('Notify the task owner (for approve)'), - }), - execute: async (args) => { - if (args.decision === 'request-changes' && !args.comment) { - throw new UserError('comment is required when requesting changes'); - } - - const cliArgs = ['--team', args.team, 'review', args.decision, args.task_id]; - - // approve uses --note for optional comment; request-changes uses --comment - if (args.decision === 'request-changes' && args.comment) { - cliArgs.push('--comment', args.comment); - } else if (args.decision === 'approve' && args.comment) { - cliArgs.push('--note', args.comment); - } - if (args.from) cliArgs.push('--from', args.from); - if (args.notify_owner) cliArgs.push('--notify-owner'); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to ${args.decision}: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-attach.ts b/mcp-server/src/tools/task-attach.ts deleted file mode 100644 index a0e9830d..00000000 --- a/mcp-server/src/tools/task-attach.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseJsonOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema, memberNameSchema, filePathSchema, safeFilenameSchema, mimeTypeSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_attach', - description: `Attach a file to a task. Returns attachment metadata JSON. - -Supports copy (default) or hardlink mode. MIME type is auto-detected from file content (PNG, JPEG, GIF, WebP, PDF, ZIP) with fallback to application/octet-stream. Max file size: 20 MB.`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - file: filePathSchema.describe('Absolute path to the file to attach'), - filename: safeFilenameSchema.optional().describe('Override stored filename'), - mime_type: mimeTypeSchema.optional().describe('Override MIME type (auto-detected by default)'), - mode: z.enum(['copy', 'link']).optional().describe('Storage mode: copy (default) or hardlink'), - from: memberNameSchema.optional().describe('Uploader name'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'attach', args.task_id, '--file', args.file]; - - if (args.filename) cliArgs.push('--filename', args.filename); - if (args.mime_type) cliArgs.push('--mime-type', args.mime_type); - if (args.mode) cliArgs.push('--mode', args.mode); - if (args.from) cliArgs.push('--from', args.from); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to attach file: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseJsonOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-briefing.ts b/mcp-server/src/tools/task-briefing.ts deleted file mode 100644 index 9273a6d7..00000000 --- a/mcp-server/src/tools/task-briefing.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseTextOutput } from '../output-parser.js'; -import { teamNameSchema, memberNameSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_briefing', - description: `Generate a text briefing for a team member showing their assigned tasks vs the team board. - -Returns a human-readable multi-line report. Automatically filters out internal bookkeeping tasks.`, - annotations: { - readOnlyHint: true, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - member: memberNameSchema.describe('Member name to generate briefing for'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'briefing', '--for', args.member]; - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to get briefing: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseTextOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-comment.ts b/mcp-server/src/tools/task-comment.ts deleted file mode 100644 index 951ef0a9..00000000 --- a/mcp-server/src/tools/task-comment.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseOkOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_comment', - description: `Add a comment to a task. Sends an inbox notification to the task owner (unless the commenter is the owner).`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - text: z.string().min(1).max(10000).describe('Comment text'), - from: memberNameSchema.optional().describe('Author name (skips self-notification)'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'comment', args.task_id, '--text', args.text]; - - if (args.from) cliArgs.push('--from', args.from); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to add comment: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-create.ts b/mcp-server/src/tools/task-create.ts deleted file mode 100644 index 5c08e4e7..00000000 --- a/mcp-server/src/tools/task-create.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseJsonOutput } from '../output-parser.js'; -import { teamNameSchema, memberNameSchema, taskIdsArraySchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_create', - description: `Create a new task in a team's task board. Returns the created task JSON. - -Behavior: -- If owner is set and no blockers → status defaults to "in_progress" -- If blocked_by specified → status defaults to "pending" (even with owner) -- If notify is true, sends an inbox notification to the assigned owner`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - subject: z.string().min(1).max(500).describe('Task title'), - description: z.string().max(5000).optional().describe('Detailed task description'), - owner: memberNameSchema.optional().describe('Assign to a team member'), - blocked_by: taskIdsArraySchema.optional().describe('Task IDs that block this task'), - related: taskIdsArraySchema.optional().describe('Related (non-blocking) task IDs'), - status: z.enum(['pending', 'in_progress']).optional().describe('Initial status override'), - active_form: z.string().max(200).optional().describe('Active form hint for CLI display'), - notify: z.boolean().optional().describe('Send inbox notification to owner'), - from: memberNameSchema.optional().describe('Author name for notifications'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'create', '--subject', args.subject]; - - if (args.description) cliArgs.push('--description', args.description); - if (args.owner) cliArgs.push('--owner', args.owner); - if (args.blocked_by?.length) cliArgs.push('--blocked-by', args.blocked_by.join(',')); - if (args.related?.length) cliArgs.push('--related', args.related.join(',')); - if (args.status) cliArgs.push('--status', args.status); - if (args.active_form) cliArgs.push('--activeForm', args.active_form); - if (args.notify) cliArgs.push('--notify'); - if (args.from) cliArgs.push('--from', args.from); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to create task: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseJsonOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-get.ts b/mcp-server/src/tools/task-get.ts deleted file mode 100644 index cf56ec6d..00000000 --- a/mcp-server/src/tools/task-get.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseJsonOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_get', - description: `Get a single task by its ID. Returns the full task JSON including status, owner, comments, dependencies, work intervals, and status history.`, - annotations: { - readOnlyHint: true, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'get', args.task_id]; - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to get task: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseJsonOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-link.ts b/mcp-server/src/tools/task-link.ts deleted file mode 100644 index 0d43021c..00000000 --- a/mcp-server/src/tools/task-link.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseOkOutput } from '../output-parser.js'; -import { - teamNameSchema, - taskIdSchema, - linkTypeSchema, - linkOperationSchema, -} from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_link', - description: `Link or unlink task dependencies. - -Relationship types: -- "blocked-by": this task is blocked by the target -- "blocks": this task blocks the target -- "related": non-blocking relationship - -Links are bidirectional: linking A blocked-by B also sets B blocks A. -Circular dependencies are detected and rejected.`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - operation: linkOperationSchema.describe('"link" to add, "unlink" to remove'), - relationship: linkTypeSchema, - target_id: taskIdSchema.describe('The other task ID to link/unlink'), - }), - execute: async (args) => { - const cliArgs = [ - '--team', args.team, - 'task', args.operation, - args.task_id, - `--${args.relationship}`, args.target_id, - ]; - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to ${args.operation} tasks: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-list.ts b/mcp-server/src/tools/task-list.ts deleted file mode 100644 index 37c05bfd..00000000 --- a/mcp-server/src/tools/task-list.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseJsonOutput } from '../output-parser.js'; -import { teamNameSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_list', - description: `List all tasks for a team. Returns a JSON array of task objects. - -Note: includes internal bookkeeping tasks (metadata._internal). Filter client-side if needed.`, - annotations: { - readOnlyHint: true, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'list']; - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to list tasks: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseJsonOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-set-owner.ts b/mcp-server/src/tools/task-set-owner.ts deleted file mode 100644 index 4cfd04bf..00000000 --- a/mcp-server/src/tools/task-set-owner.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseOkOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_set_owner', - description: `Assign a task to a team member or clear the assignment. - -Pass owner="clear" to unassign. Optionally sends an inbox notification to the new owner.`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - owner: z.union([memberNameSchema, z.literal('clear')]).describe('Member name to assign, or "clear" to unassign'), - notify: z.boolean().optional().describe('Send inbox notification to new owner'), - from: memberNameSchema.optional().describe('Author name for notification'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'set-owner', args.task_id, args.owner]; - - if (args.notify) cliArgs.push('--notify'); - if (args.from) cliArgs.push('--from', args.from); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to set owner: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-set-status.ts b/mcp-server/src/tools/task-set-status.ts deleted file mode 100644 index a5b96294..00000000 --- a/mcp-server/src/tools/task-set-status.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseOkOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema, taskStatusSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_set_status', - description: `Change the status of a task. - -Valid transitions: pending → in_progress → completed → deleted (and back). -Shortcuts: status "in_progress" is equivalent to "task start", "completed" to "task complete". -Records status history and tracks work intervals (time spent in_progress).`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - status: taskStatusSchema.describe('New status for the task'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'set-status', args.task_id, args.status]; - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to set status: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/test/output-parser.test.ts b/mcp-server/test/output-parser.test.ts deleted file mode 100644 index 52f8179f..00000000 --- a/mcp-server/test/output-parser.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - parseJsonOutput, - parseOkOutput, - parseTextOutput, - formatError, -} from '../src/output-parser.js'; - -describe('parseJsonOutput', () => { - it('parses valid JSON object', () => { - const input = '{"id":"42","subject":"Fix bug"}\n'; - expect(parseJsonOutput(input)).toEqual({ id: '42', subject: 'Fix bug' }); - }); - - it('parses valid JSON array', () => { - const input = '[{"id":"1"},{"id":"2"}]\n'; - expect(parseJsonOutput(input)).toEqual([{ id: '1' }, { id: '2' }]); - }); - - it('trims whitespace', () => { - const input = ' \n {"ok":true} \n '; - expect(parseJsonOutput(input)).toEqual({ ok: true }); - }); - - it('throws on empty output', () => { - expect(() => parseJsonOutput('')).toThrow('Empty output'); - expect(() => parseJsonOutput(' \n ')).toThrow('Empty output'); - }); - - it('throws on invalid JSON', () => { - expect(() => parseJsonOutput('not json')).toThrow('Failed to parse'); - }); -}); - -describe('parseOkOutput', () => { - it('strips "OK " prefix', () => { - expect(parseOkOutput('OK task #1 status=completed\n')).toBe('task #1 status=completed'); - }); - - it('handles bare "OK"', () => { - expect(parseOkOutput('OK\n')).toBe('OK'); - }); - - it('returns as-is for unexpected format', () => { - expect(parseOkOutput('Something else')).toBe('Something else'); - }); - - it('trims whitespace', () => { - expect(parseOkOutput(' OK kanban #1 cleared \n')).toBe('kanban #1 cleared'); - }); -}); - -describe('parseTextOutput', () => { - it('trims and returns text', () => { - const briefing = '=== Task Briefing for alice ===\nTask #1: Fix bug\n'; - expect(parseTextOutput(briefing)).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug'); - }); - - it('handles empty string', () => { - expect(parseTextOutput('')).toBe(''); - }); -}); - -describe('formatError', () => { - it('uses stderr when available', () => { - expect(formatError('Task not found: #42\n', '')).toBe('Task not found: #42'); - }); - - it('falls back to stdout', () => { - expect(formatError('', 'Unexpected error\n')).toBe('Unexpected error'); - }); - - it('returns default for empty', () => { - expect(formatError('', '')).toBe('Unknown teamctl error'); - }); -}); diff --git a/mcp-server/test/schemas.test.ts b/mcp-server/test/schemas.test.ts deleted file mode 100644 index cfd8b5f6..00000000 --- a/mcp-server/test/schemas.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - teamNameSchema, - taskIdSchema, - memberNameSchema, - taskStatusSchema, - kanbanColumnSchema, - clarificationSchema, - linkTypeSchema, - linkOperationSchema, - reviewDecisionSchema, - reviewerOperationSchema, - taskIdsArraySchema, - filePathSchema, - safeFilenameSchema, - mimeTypeSchema, -} from '../src/schemas.js'; - -describe('teamNameSchema', () => { - it('accepts valid team names', () => { - expect(teamNameSchema.parse('acme')).toBe('acme'); - expect(teamNameSchema.parse('my-team')).toBe('my-team'); - expect(teamNameSchema.parse('My_Team')).toBe('My_Team'); - expect(teamNameSchema.parse('team123')).toBe('team123'); - }); - - it('rejects empty', () => { - expect(() => teamNameSchema.parse('')).toThrow(); - }); - - it('rejects path traversal', () => { - expect(() => teamNameSchema.parse('..')).toThrow(); - expect(() => teamNameSchema.parse('.')).toThrow(); - expect(() => teamNameSchema.parse('a/b')).toThrow(); - expect(() => teamNameSchema.parse('a\\b')).toThrow(); - expect(() => teamNameSchema.parse('a..b')).toThrow(); - }); - - it('rejects null bytes', () => { - expect(() => teamNameSchema.parse('a\0b')).toThrow(); - }); - - it('rejects too long', () => { - expect(() => teamNameSchema.parse('a'.repeat(129))).toThrow(); - }); -}); - -describe('taskIdSchema', () => { - it('accepts numeric IDs', () => { - expect(taskIdSchema.parse('1')).toBe('1'); - expect(taskIdSchema.parse('42')).toBe('42'); - expect(taskIdSchema.parse('1234567890')).toBe('1234567890'); - }); - - it('accepts zero', () => { - expect(taskIdSchema.parse('0')).toBe('0'); - }); - - it('accepts leading zeros', () => { - expect(taskIdSchema.parse('007')).toBe('007'); - }); - - it('rejects non-numeric', () => { - expect(() => taskIdSchema.parse('abc')).toThrow(); - expect(() => taskIdSchema.parse('')).toThrow(); - expect(() => taskIdSchema.parse('1.5')).toThrow(); - expect(() => taskIdSchema.parse('-1')).toThrow(); - }); - - it('rejects too long', () => { - expect(() => taskIdSchema.parse('12345678901')).toThrow(); - }); -}); - -describe('memberNameSchema', () => { - it('accepts valid member names', () => { - expect(memberNameSchema.parse('alice')).toBe('alice'); - expect(memberNameSchema.parse('bob-smith')).toBe('bob-smith'); - expect(memberNameSchema.parse('user_1')).toBe('user_1'); - }); - - it('rejects path traversal', () => { - expect(() => memberNameSchema.parse('..')).toThrow(); - expect(() => memberNameSchema.parse('a/b')).toThrow(); - expect(() => memberNameSchema.parse('a\\b')).toThrow(); - expect(() => memberNameSchema.parse('a..b')).toThrow(); - }); - - it('rejects null bytes', () => { - expect(() => memberNameSchema.parse('a\0b')).toThrow(); - }); - - it('rejects too long', () => { - expect(() => memberNameSchema.parse('a'.repeat(129))).toThrow(); - }); - - it('rejects empty', () => { - expect(() => memberNameSchema.parse('')).toThrow(); - }); -}); - -describe('enum schemas', () => { - it('taskStatusSchema accepts valid values', () => { - expect(taskStatusSchema.parse('pending')).toBe('pending'); - expect(taskStatusSchema.parse('in_progress')).toBe('in_progress'); - expect(taskStatusSchema.parse('completed')).toBe('completed'); - expect(taskStatusSchema.parse('deleted')).toBe('deleted'); - expect(() => taskStatusSchema.parse('invalid')).toThrow(); - }); - - it('kanbanColumnSchema accepts valid values', () => { - expect(kanbanColumnSchema.parse('review')).toBe('review'); - expect(kanbanColumnSchema.parse('approved')).toBe('approved'); - expect(() => kanbanColumnSchema.parse('todo')).toThrow(); - expect(() => kanbanColumnSchema.parse('')).toThrow(); - }); - - it('clarificationSchema accepts valid values', () => { - expect(clarificationSchema.parse('lead')).toBe('lead'); - expect(clarificationSchema.parse('user')).toBe('user'); - expect(clarificationSchema.parse('clear')).toBe('clear'); - expect(() => clarificationSchema.parse('nobody')).toThrow(); - }); - - it('linkTypeSchema accepts valid values', () => { - expect(linkTypeSchema.parse('blocked-by')).toBe('blocked-by'); - expect(linkTypeSchema.parse('blocks')).toBe('blocks'); - expect(linkTypeSchema.parse('related')).toBe('related'); - }); - - it('linkOperationSchema accepts valid values', () => { - expect(linkOperationSchema.parse('link')).toBe('link'); - expect(linkOperationSchema.parse('unlink')).toBe('unlink'); - }); - - it('reviewDecisionSchema accepts valid values', () => { - expect(reviewDecisionSchema.parse('approve')).toBe('approve'); - expect(reviewDecisionSchema.parse('request-changes')).toBe('request-changes'); - }); - - it('reviewerOperationSchema accepts valid values', () => { - expect(reviewerOperationSchema.parse('list')).toBe('list'); - expect(reviewerOperationSchema.parse('add')).toBe('add'); - expect(reviewerOperationSchema.parse('remove')).toBe('remove'); - }); -}); - -describe('taskIdsArraySchema', () => { - it('accepts valid arrays', () => { - expect(taskIdsArraySchema.parse(['1', '2', '3'])).toEqual(['1', '2', '3']); - expect(taskIdsArraySchema.parse([])).toEqual([]); - }); - - it('rejects arrays with invalid IDs', () => { - expect(() => taskIdsArraySchema.parse(['abc'])).toThrow(); - expect(() => taskIdsArraySchema.parse(['1', 'bad'])).toThrow(); - }); -}); - -describe('filePathSchema', () => { - it('accepts absolute paths', () => { - expect(filePathSchema.parse('/home/user/file.txt')).toBe('/home/user/file.txt'); - expect(filePathSchema.parse('/tmp/attachment.pdf')).toBe('/tmp/attachment.pdf'); - }); - - it('rejects relative paths', () => { - expect(() => filePathSchema.parse('relative/path.txt')).toThrow(); - expect(() => filePathSchema.parse('./file.txt')).toThrow(); - }); - - it('rejects path traversal', () => { - expect(() => filePathSchema.parse('/home/user/../etc/passwd')).toThrow(); - expect(() => filePathSchema.parse('/home/../../../etc/shadow')).toThrow(); - }); - - it('rejects null bytes', () => { - expect(() => filePathSchema.parse('/home/user/\0evil')).toThrow(); - }); - - it('rejects empty', () => { - expect(() => filePathSchema.parse('')).toThrow(); - }); -}); - -describe('safeFilenameSchema', () => { - it('accepts valid filenames', () => { - expect(safeFilenameSchema.parse('report.pdf')).toBe('report.pdf'); - expect(safeFilenameSchema.parse('my-file_v2.tar.gz')).toBe('my-file_v2.tar.gz'); - }); - - it('rejects path separators', () => { - expect(() => safeFilenameSchema.parse('../../evil')).toThrow(); - expect(() => safeFilenameSchema.parse('dir/file')).toThrow(); - expect(() => safeFilenameSchema.parse('dir\\file')).toThrow(); - }); - - it('rejects null bytes', () => { - expect(() => safeFilenameSchema.parse('file\0name')).toThrow(); - }); - - it('rejects too long', () => { - expect(() => safeFilenameSchema.parse('a'.repeat(256))).toThrow(); - }); -}); - -describe('mimeTypeSchema', () => { - it('accepts valid MIME types', () => { - expect(mimeTypeSchema.parse('application/pdf')).toBe('application/pdf'); - expect(mimeTypeSchema.parse('image/png')).toBe('image/png'); - expect(mimeTypeSchema.parse('text/plain')).toBe('text/plain'); - expect(mimeTypeSchema.parse('application/octet-stream')).toBe('application/octet-stream'); - }); - - it('rejects invalid formats', () => { - expect(() => mimeTypeSchema.parse('invalid')).toThrow(); - expect(() => mimeTypeSchema.parse('/pdf')).toThrow(); - expect(() => mimeTypeSchema.parse('application/')).toThrow(); - expect(() => mimeTypeSchema.parse('')).toThrow(); - }); -}); diff --git a/mcp-server/test/teamctl-runner.test.ts b/mcp-server/test/teamctl-runner.test.ts deleted file mode 100644 index 54b703f7..00000000 --- a/mcp-server/test/teamctl-runner.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { TeamctlRunner } from '../src/teamctl-runner.js'; -import type { ITeamctlRunner, TeamctlResult } from '../src/teamctl-runner.js'; - -// We can't easily test the real subprocess without teamctl.js installed, -// so we test the interface contract and error handling. - -describe('TeamctlRunner', () => { - it('throws if teamctl.js does not exist', () => { - expect( - () => new TeamctlRunner({ teamctlPath: '/nonexistent/teamctl.js' }), - ).toThrow('teamctl.js not found'); - }); - - it('resolves path from TEAMCTL_PATH env', () => { - const original = process.env['TEAMCTL_PATH']; - try { - process.env['TEAMCTL_PATH'] = '/tmp/test-teamctl.js'; - // Will throw because file doesn't exist, but we can check the error message - expect( - () => new TeamctlRunner(), - ).toThrow('/tmp/test-teamctl.js'); - } finally { - if (original !== undefined) { - process.env['TEAMCTL_PATH'] = original; - } else { - delete process.env['TEAMCTL_PATH']; - } - } - }); -}); - -// Mock runner for tool tests -export function createMockRunner( - responses: Map | TeamctlResult, -): ITeamctlRunner { - return { - execute: vi.fn(async (args: string[]): Promise => { - if (responses instanceof Map) { - const key = args.join(' '); - const result = responses.get(key); - if (result) return result; - // Fallback: check if any key is a prefix - for (const [k, v] of responses) { - if (key.startsWith(k)) return v; - } - return { stdout: '', stderr: 'No mock for: ' + key, exitCode: 1 }; - } - return responses; - }), - }; -} - -describe('ITeamctlRunner interface', () => { - it('mock runner returns success', async () => { - const runner = createMockRunner({ - stdout: '{"id":"1","subject":"Test"}\n', - stderr: '', - exitCode: 0, - }); - - const result = await runner.execute(['--team', 'test', 'task', 'create']); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toHaveProperty('id', '1'); - }); - - it('mock runner returns error', async () => { - const runner = createMockRunner({ - stdout: '', - stderr: 'Task not found: #99\n', - exitCode: 1, - }); - - const result = await runner.execute(['--team', 'test', 'task', 'get', '99']); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('Task not found'); - }); -}); diff --git a/mcp-server/test/tools/kanban-move.test.ts b/mcp-server/test/tools/kanban-move.test.ts deleted file mode 100644 index e0468131..00000000 --- a/mcp-server/test/tools/kanban-move.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/kanban-move.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('kanban_move', () => { - function setup(response = ok('OK kanban #1 column=review\n')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('kanban_move')! }; - } - - it('builds set-column CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', operation: 'set-column', column: 'review' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'kanban', 'set-column', '1', 'review', - ]); - }); - - it('builds clear CLI args', async () => { - const { runner, tool } = setup(ok('OK kanban #1 cleared\n')); - await tool.execute({ team: 'acme', task_id: '1', operation: 'clear' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'kanban', 'clear', '1', - ]); - }); - - it('throws UserError when set-column called without column', async () => { - const { tool } = setup(); - await expect( - tool.execute({ team: 'acme', task_id: '1', operation: 'set-column' }), - ).rejects.toThrow('column is required'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('task not found')); - await expect( - tool.execute({ team: 'acme', task_id: '99', operation: 'clear' }), - ).rejects.toThrow('Failed to update kanban'); - }); -}); diff --git a/mcp-server/test/tools/kanban-reviewers.test.ts b/mcp-server/test/tools/kanban-reviewers.test.ts deleted file mode 100644 index ee854535..00000000 --- a/mcp-server/test/tools/kanban-reviewers.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/kanban-reviewers.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('kanban_reviewers', () => { - function setup(response = ok('["alice","bob"]')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('kanban_reviewers')! }; - } - - it('builds list CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', operation: 'list' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'kanban', 'reviewers', 'list', - ]); - }); - - it('returns JSON array for list', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', operation: 'list' }); - expect(result).toEqual(['alice', 'bob']); - }); - - it('builds add CLI args with name', async () => { - const { runner, tool } = setup(ok('OK reviewer added\n')); - await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'kanban', 'reviewers', 'add', 'charlie', - ]); - }); - - it('returns OK text for add/remove', async () => { - const { tool } = setup(ok('OK reviewer added\n')); - const result = await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' }); - expect(result).toBe('reviewer added'); - }); - - it('throws UserError when add called without name', async () => { - const { tool } = setup(); - await expect( - tool.execute({ team: 'acme', operation: 'add' }), - ).rejects.toThrow('name is required'); - }); - - it('throws UserError when remove called without name', async () => { - const { tool } = setup(); - await expect( - tool.execute({ team: 'acme', operation: 'remove' }), - ).rejects.toThrow('name is required'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('reviewer not found')); - await expect( - tool.execute({ team: 'acme', operation: 'remove', name: 'nobody' }), - ).rejects.toThrow('Failed to manage reviewers'); - }); -}); diff --git a/mcp-server/test/tools/message-send.test.ts b/mcp-server/test/tools/message-send.test.ts deleted file mode 100644 index 8bb206e8..00000000 --- a/mcp-server/test/tools/message-send.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/message-send.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('message_send', () => { - const deliveryJson = '{"deliveredToInbox":true,"messageId":"msg_abc"}'; - - function setup(response = ok(deliveryJson)) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('message_send')! }; - } - - it('builds minimal CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'message', 'send', '--to', 'alice', '--text', 'Hello!', - ]); - }); - - it('includes summary and from flags', async () => { - const { runner, tool } = setup(); - await tool.execute({ - team: 'acme', to: 'alice', text: 'Task done', - summary: 'Completed task #1', from: 'bob', - }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--summary'); - expect(args[args.indexOf('--summary') + 1]).toBe('Completed task #1'); - expect(args).toContain('--from'); - expect(args[args.indexOf('--from') + 1]).toBe('bob'); - }); - - it('returns parsed JSON', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' }); - expect(result).toEqual({ deliveredToInbox: true, messageId: 'msg_abc' }); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('recipient inbox not found')); - await expect( - tool.execute({ team: 'acme', to: 'nobody', text: 'Hi' }), - ).rejects.toThrow('Failed to send message'); - }); -}); diff --git a/mcp-server/test/tools/register-all.test.ts b/mcp-server/test/tools/register-all.test.ts deleted file mode 100644 index d562525e..00000000 --- a/mcp-server/test/tools/register-all.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { registerAllTools } from '../../src/tools/index.js'; -import { createMockRunner, createMockServer } from './test-helpers.js'; - -describe('registerAllTools', () => { - it('registers exactly 13 tools', () => { - const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 }); - const { server, tools } = createMockServer(); - registerAllTools(server, runner); - expect(tools.size).toBe(13); - }); - - it('registers all expected tool names', () => { - const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 }); - const { server, tools } = createMockServer(); - registerAllTools(server, runner); - - const expected = [ - 'task_create', 'task_set_status', 'task_set_owner', - 'task_get', 'task_list', 'task_comment', 'task_link', - 'task_briefing', 'task_attach', 'kanban_move', - 'kanban_reviewers', 'review_action', 'message_send', - ]; - for (const name of expected) { - expect(tools.has(name), `missing tool: ${name}`).toBe(true); - } - }); -}); diff --git a/mcp-server/test/tools/review-action.test.ts b/mcp-server/test/tools/review-action.test.ts deleted file mode 100644 index 952f1ae9..00000000 --- a/mcp-server/test/tools/review-action.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/review-action.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('review_action', () => { - function setup(response = ok('OK review #1 approved\n')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('review_action')! }; - } - - it('builds approve CLI args (no comment)', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', decision: 'approve' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'review', 'approve', '1', - ]); - }); - - it('builds approve CLI args with --note (not --comment)', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', decision: 'approve', comment: 'LGTM' }); - const args = runner.execute.mock.calls[0]![0] as string[]; - // approve uses --note, NOT --comment - expect(args).toContain('--note'); - expect(args[args.indexOf('--note') + 1]).toBe('LGTM'); - expect(args).not.toContain('--comment'); - }); - - it('builds request-changes CLI args with --comment (not --note)', async () => { - const { runner, tool } = setup(ok('OK review #1 requested changes\n')); - await tool.execute({ - team: 'acme', task_id: '1', decision: 'request-changes', comment: 'Fix tests', - }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--comment'); - expect(args[args.indexOf('--comment') + 1]).toBe('Fix tests'); - expect(args).not.toContain('--note'); - }); - - it('throws when request-changes has no comment', async () => { - const { tool } = setup(); - await expect( - tool.execute({ team: 'acme', task_id: '1', decision: 'request-changes' }), - ).rejects.toThrow('comment is required when requesting changes'); - }); - - it('includes from and notify_owner flags', async () => { - const { runner, tool } = setup(); - await tool.execute({ - team: 'acme', task_id: '1', decision: 'approve', - from: 'alice', notify_owner: true, - }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--from'); - expect(args).toContain('--notify-owner'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('task not in review')); - await expect( - tool.execute({ team: 'acme', task_id: '1', decision: 'approve' }), - ).rejects.toThrow('Failed to approve'); - }); -}); diff --git a/mcp-server/test/tools/task-attach.test.ts b/mcp-server/test/tools/task-attach.test.ts deleted file mode 100644 index e8b63fe5..00000000 --- a/mcp-server/test/tools/task-attach.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-attach.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_attach', () => { - const attachJson = '{"id":"att_123","filename":"report.pdf","mimeType":"application/pdf"}'; - - function setup(response = ok(attachJson)) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_attach')! }; - } - - it('builds minimal CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'attach', '1', '--file', '/tmp/report.pdf', - ]); - }); - - it('includes all optional flags', async () => { - const { runner, tool } = setup(); - await tool.execute({ - team: 'acme', task_id: '1', file: '/tmp/file.pdf', - filename: 'renamed.pdf', mime_type: 'application/pdf', - mode: 'link', from: 'alice', - }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--filename'); - expect(args).toContain('--mime-type'); - expect(args).toContain('--mode'); - expect(args).toContain('--from'); - }); - - it('returns parsed JSON', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' }); - expect(result).toEqual({ id: 'att_123', filename: 'report.pdf', mimeType: 'application/pdf' }); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('file too large')); - await expect( - tool.execute({ team: 'acme', task_id: '1', file: '/tmp/huge.bin' }), - ).rejects.toThrow('Failed to attach file'); - }); -}); diff --git a/mcp-server/test/tools/task-briefing.test.ts b/mcp-server/test/tools/task-briefing.test.ts deleted file mode 100644 index b915e994..00000000 --- a/mcp-server/test/tools/task-briefing.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-briefing.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_briefing', () => { - const briefingText = '=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]\n'; - - function setup(response = ok(briefingText)) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_briefing')! }; - } - - it('builds correct CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', member: 'alice' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'briefing', '--for', 'alice', - ]); - }); - - it('returns trimmed plain text', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', member: 'alice' }); - expect(result).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('member not found')); - await expect(tool.execute({ team: 'acme', member: 'nobody' })).rejects.toThrow('Failed to get briefing'); - }); -}); diff --git a/mcp-server/test/tools/task-comment.test.ts b/mcp-server/test/tools/task-comment.test.ts deleted file mode 100644 index 2c340e48..00000000 --- a/mcp-server/test/tools/task-comment.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-comment.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_comment', () => { - function setup(response = ok('OK comment added to task #1\n')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_comment')! }; - } - - it('builds correct CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', text: 'Looking good!' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'comment', '1', '--text', 'Looking good!', - ]); - }); - - it('includes from flag', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', text: 'Done', from: 'alice' }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--from'); - expect(args[args.indexOf('--from') + 1]).toBe('alice'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('task not found')); - await expect(tool.execute({ team: 'acme', task_id: '99', text: 'Hi' })).rejects.toThrow('Failed to add comment'); - }); -}); diff --git a/mcp-server/test/tools/task-create.test.ts b/mcp-server/test/tools/task-create.test.ts deleted file mode 100644 index 5866e9f9..00000000 --- a/mcp-server/test/tools/task-create.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-create.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_create', () => { - function setup(response = ok('{"id":"1","subject":"Fix bug","status":"in_progress"}')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_create')! }; - } - - it('builds minimal CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', subject: 'Fix bug' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'create', '--subject', 'Fix bug', - ]); - }); - - it('includes all optional flags', async () => { - const { runner, tool } = setup(); - await tool.execute({ - team: 'acme', - subject: 'Big task', - description: 'Details here', - owner: 'alice', - blocked_by: ['1', '2'], - related: ['3'], - status: 'pending', - active_form: 'Fixing bug', - notify: true, - from: 'bob', - }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--description'); - expect(args).toContain('--owner'); - expect(args).toContain('--blocked-by'); - expect(args[args.indexOf('--blocked-by') + 1]).toBe('1,2'); - expect(args).toContain('--related'); - expect(args[args.indexOf('--related') + 1]).toBe('3'); - expect(args).toContain('--status'); - expect(args).toContain('--activeForm'); - expect(args).toContain('--notify'); - expect(args).toContain('--from'); - }); - - it('skips empty blocked_by array', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', subject: 'Task', blocked_by: [] }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).not.toContain('--blocked-by'); - }); - - it('returns parsed JSON', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', subject: 'Fix bug' }); - expect(result).toEqual({ id: '1', subject: 'Fix bug', status: 'in_progress' }); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('team not found')); - await expect(tool.execute({ team: 'bad', subject: 'X' })).rejects.toThrow('Failed to create task'); - }); -}); diff --git a/mcp-server/test/tools/task-get.test.ts b/mcp-server/test/tools/task-get.test.ts deleted file mode 100644 index 79a3d97f..00000000 --- a/mcp-server/test/tools/task-get.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-get.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_get', () => { - function setup(response = ok('{"id":"42","subject":"Test","status":"pending"}')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_get')! }; - } - - it('builds correct CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '42' }); - expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'get', '42']); - }); - - it('returns parsed JSON', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', task_id: '42' }); - expect(result).toEqual({ id: '42', subject: 'Test', status: 'pending' }); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('Task not found: #99')); - await expect(tool.execute({ team: 'acme', task_id: '99' })).rejects.toThrow('Failed to get task'); - }); -}); diff --git a/mcp-server/test/tools/task-link.test.ts b/mcp-server/test/tools/task-link.test.ts deleted file mode 100644 index 7e16b802..00000000 --- a/mcp-server/test/tools/task-link.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-link.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_link', () => { - function setup(response = ok('OK task #1 blocked-by #2\n')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_link')! }; - } - - it('builds link CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ - team: 'acme', task_id: '1', operation: 'link', - relationship: 'blocked-by', target_id: '2', - }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'link', '1', '--blocked-by', '2', - ]); - }); - - it('builds unlink CLI args', async () => { - const { runner, tool } = setup(ok('OK task #1 unlinked from #2\n')); - await tool.execute({ - team: 'acme', task_id: '1', operation: 'unlink', - relationship: 'related', target_id: '3', - }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'unlink', '1', '--related', '3', - ]); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('circular dependency')); - await expect( - tool.execute({ - team: 'acme', task_id: '1', operation: 'link', - relationship: 'blocked-by', target_id: '1', - }), - ).rejects.toThrow('Failed to link tasks'); - }); -}); diff --git a/mcp-server/test/tools/task-list.test.ts b/mcp-server/test/tools/task-list.test.ts deleted file mode 100644 index be1a25af..00000000 --- a/mcp-server/test/tools/task-list.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-list.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_list', () => { - function setup(response = ok('[{"id":"1"},{"id":"2"}]')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_list')! }; - } - - it('builds correct CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme' }); - expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'list']); - }); - - it('returns parsed JSON array', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme' }); - expect(result).toEqual([{ id: '1' }, { id: '2' }]); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('team dir not found')); - await expect(tool.execute({ team: 'bad' })).rejects.toThrow('Failed to list tasks'); - }); -}); diff --git a/mcp-server/test/tools/task-set-owner.test.ts b/mcp-server/test/tools/task-set-owner.test.ts deleted file mode 100644 index d9859066..00000000 --- a/mcp-server/test/tools/task-set-owner.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-set-owner.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_set_owner', () => { - function setup(response = ok('OK task #1 owner=alice\n')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_set_owner')! }; - } - - it('builds args for assignment', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', owner: 'alice' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'set-owner', '1', 'alice', - ]); - }); - - it('builds args for clear', async () => { - const { runner, tool } = setup(ok('OK task #1 owner=cleared\n')); - await tool.execute({ team: 'acme', task_id: '1', owner: 'clear' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'set-owner', '1', 'clear', - ]); - }); - - it('includes notify and from flags', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', owner: 'alice', notify: true, from: 'bob' }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--notify'); - expect(args).toContain('--from'); - expect(args[args.indexOf('--from') + 1]).toBe('bob'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('member not found')); - await expect(tool.execute({ team: 'acme', task_id: '1', owner: 'nobody' })).rejects.toThrow('Failed to set owner'); - }); -}); diff --git a/mcp-server/test/tools/task-set-status.test.ts b/mcp-server/test/tools/task-set-status.test.ts deleted file mode 100644 index 0ca68d0d..00000000 --- a/mcp-server/test/tools/task-set-status.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-set-status.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_set_status', () => { - function setup(response = ok('OK task #1 status=completed\n')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_set_status')! }; - } - - it('builds correct CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', status: 'completed' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'set-status', '1', 'completed', - ]); - }); - - it('returns parsed OK text', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', task_id: '1', status: 'completed' }); - expect(result).toBe('task #1 status=completed'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('invalid transition')); - await expect(tool.execute({ team: 'acme', task_id: '1', status: 'deleted' })).rejects.toThrow('Failed to set status'); - }); -}); diff --git a/mcp-server/test/tools/test-helpers.ts b/mcp-server/test/tools/test-helpers.ts deleted file mode 100644 index fb0a8e01..00000000 --- a/mcp-server/test/tools/test-helpers.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { vi } from 'vitest'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner, TeamctlResult } from '../../src/teamctl-runner.js'; - -/** - * Creates a mock ITeamctlRunner that records calls and returns predetermined results. - */ -export function createMockRunner( - response: TeamctlResult | ((args: string[]) => TeamctlResult), -): ITeamctlRunner & { execute: ReturnType } { - return { - execute: vi.fn(async (args: string[]): Promise => { - return typeof response === 'function' ? response(args) : response; - }), - }; -} - -/** Success response helpers */ -export const ok = (stdout: string): TeamctlResult => ({ - stdout, - stderr: '', - exitCode: 0, -}); - -export const fail = (stderr: string): TeamctlResult => ({ - stdout: '', - stderr, - exitCode: 1, -}); - -/** - * Captures registered tools via a mock FastMCP-like server. - * Returns a map of tool name → { execute function, parameters schema }. - */ -export interface CapturedTool { - name: string; - execute: (args: Record) => Promise; - parameters: unknown; -} - -export function createMockServer(): { - server: FastMCP; - tools: Map; -} { - const tools = new Map(); - - const server = { - addTool: (def: { name: string; execute: CapturedTool['execute']; parameters: unknown }) => { - tools.set(def.name, { - name: def.name, - execute: def.execute, - parameters: def.parameters, - }); - }, - } as unknown as FastMCP; - - return { server, tools }; -} From e9b369e667e5d518cd4b01c59cb7815cf156f846 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 7 Mar 2026 12:02:12 +0200 Subject: [PATCH 11/15] feat: enhance theme support and UI consistency across components - Added theme-aware accent and info colors in tailwind configuration for improved visual consistency. - Updated CSS variables for accent and info styles to support light and dark themes. - Refactored various components to utilize the new themed badge logic, ensuring consistent styling based on the current theme. - Improved accessibility and visual feedback in components like SidebarTaskItem, TeamListView, and ActivityItem by adjusting color schemes and hover states. - Enhanced the CreateTeamDialog and other team-related components with updated styling for better user experience. --- .../components/chat/items/LinkedToolItem.tsx | 6 +- .../components/chat/items/SubagentItem.tsx | 14 ++- .../chat/items/TeammateMessageItem.tsx | 6 +- .../chat/viewers/MarkdownViewer.tsx | 17 +++- .../components/common/OngoingIndicator.tsx | 13 +-- .../components/common/UpdateBanner.tsx | 4 +- .../components/dashboard/CliStatusBanner.tsx | 4 +- .../components/dashboard/DashboardView.tsx | 2 +- .../components/layout/SortableTab.tsx | 10 +- .../settings/sections/GeneralSection.tsx | 27 ++++-- .../components/sidebar/GlobalTaskList.tsx | 4 +- .../components/sidebar/SidebarTaskItem.tsx | 33 ++++--- .../team/CollapsibleTeamSection.tsx | 2 +- src/renderer/components/team/MemberBadge.tsx | 6 +- .../components/team/TeamDetailView.tsx | 6 +- src/renderer/components/team/TeamListView.tsx | 14 +-- .../components/team/TeamSessionsSection.tsx | 4 +- .../components/team/ToolApprovalSheet.tsx | 6 +- .../components/team/UnreadCommentsBadge.tsx | 2 +- .../team/activity/ActiveTasksBlock.tsx | 8 +- .../components/team/activity/ActivityItem.tsx | 34 ++++++- .../team/activity/ActivityTimeline.tsx | 12 ++- .../team/activity/LeadThoughtsGroup.tsx | 6 +- .../team/activity/PendingRepliesBlock.tsx | 8 +- .../team/activity/ReplyQuoteBlock.tsx | 8 +- .../team/dialogs/CreateTeamDialog.tsx | 6 +- .../team/dialogs/EditTeamDialog.tsx | 6 +- .../team/dialogs/TaskCommentsSection.tsx | 2 +- .../team/dialogs/TaskDetailDialog.tsx | 29 +++--- .../team/editor/ProjectEditorOverlay.tsx | 2 +- .../components/team/kanban/KanbanTaskCard.tsx | 8 +- .../components/team/members/MemberCard.tsx | 6 +- .../components/team/tasks/TaskRow.tsx | 8 +- src/renderer/components/ui/MemberSelect.tsx | 6 +- .../components/ui/MentionableTextarea.tsx | 8 +- src/renderer/constants/teamColors.ts | 94 ++++++++++++++++--- src/renderer/index.css | 18 +++- src/renderer/index.html | 5 +- src/renderer/utils/memberHelpers.ts | 4 +- src/renderer/utils/projectColor.ts | 19 +++- tailwind.config.js | 8 ++ 41 files changed, 342 insertions(+), 143 deletions(-) diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index 033d4c7f..2b7c4a51 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -9,7 +9,8 @@ import React, { useRef } from 'react'; import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; import { getToolContextTokens, getToolStatus, @@ -70,6 +71,7 @@ export const LinkedToolItem: React.FC = ({ registerRef, }) => { const status = getToolStatus(linkedTool); + const { isLight } = useTheme(); const summary = getToolSummary(linkedTool.name, linkedTool.input); const summaryNode = searchQueryOverride && searchQueryOverride.trim().length > 0 @@ -104,7 +106,7 @@ export const LinkedToolItem: React.FC = ({ {name} diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx index 7f653642..39b7482c 100644 --- a/src/renderer/components/chat/items/SubagentItem.tsx +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -12,8 +12,13 @@ import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY, } from '@renderer/constants/cssVariables'; -import { getSubagentTypeColorSet, getTeamColorSet } from '@renderer/constants/teamColors'; +import { + getSubagentTypeColorSet, + getTeamColorSet, + getThemedBadge, +} from '@renderer/constants/teamColors'; import { useTabUI } from '@renderer/hooks/useTabUI'; +import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer'; import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers'; @@ -80,6 +85,7 @@ export const SubagentItem: React.FC = ({ // Team member colors (when this subagent is a team member) const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null; + const { isLight } = useTheme(); // Type-based colors for non-team subagents (from agent config or deterministic hash) const typeColors = !teamColors ? getSubagentTypeColorSet(subagentType, agentConfigs) : null; @@ -233,7 +239,7 @@ export const SubagentItem: React.FC = ({ = ({ = ({ = ({ highlightStyle, }) => { const colors = getTeamColorSet(teammateMessage.color); + const { isLight } = useTheme(); // Detect operational noise const noiseLabel = useMemo( @@ -162,7 +164,7 @@ export const TeammateMessageItem: React.FC = ({ searchCtx ? highlightSearchInChildren(children, searchCtx) : children; @@ -214,7 +218,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon // malformed percent-encoding — use empty color } const colorSet = getTeamColorSet(color); - const bg = colorSet.badge; + const bg = getThemedBadge(colorSet, isLight); return ( = ({ }) => { const [showRaw, setShowRaw] = React.useState(false); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); + const { isLight } = useTheme(); const isTooLarge = content.length > MAX_MARKDOWN_CHARS; const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; @@ -601,7 +606,11 @@ export const MarkdownViewer: React.FC = ({ // Create markdown components with optional search highlighting // When search is active, create fresh each render (match counter is stateful and must start at 0) // useMemo would cache stale closures when parent re-renders without search deps changing - const baseComponents = searchCtx ? createViewerMarkdownComponents(searchCtx) : defaultComponents; + const baseComponents = searchCtx + ? createViewerMarkdownComponents(searchCtx, isLight) + : isLight + ? createViewerMarkdownComponents(null, true) + : defaultComponents; // When baseDir is set (editor preview), override img to load local files via IPC const components = baseDir diff --git a/src/renderer/components/common/OngoingIndicator.tsx b/src/renderer/components/common/OngoingIndicator.tsx index 81245738..8a0473ab 100644 --- a/src/renderer/components/common/OngoingIndicator.tsx +++ b/src/renderer/components/common/OngoingIndicator.tsx @@ -34,7 +34,7 @@ export const OngoingIndicator = ({ {showLabel && ( - + {label} )} @@ -51,15 +51,12 @@ export const OngoingBanner = (): React.JSX.Element => {
- - + + Session is in progress...
diff --git a/src/renderer/components/common/UpdateBanner.tsx b/src/renderer/components/common/UpdateBanner.tsx index c377aae1..3a0c0270 100644 --- a/src/renderer/components/common/UpdateBanner.tsx +++ b/src/renderer/components/common/UpdateBanner.tsx @@ -37,7 +37,7 @@ export const UpdateBanner = (): React.JSX.Element | null => { className="mb-1.5 flex items-center gap-2 text-xs" style={{ color: 'var(--color-text-secondary)' }} > - + Updating app {clampedPercent}% @@ -48,7 +48,7 @@ export const UpdateBanner = (): React.JSX.Element | null => { style={{ backgroundColor: 'var(--color-border)' }} >
diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 98d6052f..ecdd4e9d 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -34,7 +34,7 @@ const VARIANT_STYLES: Record = { loading: { border: 'var(--color-border)', bg: 'transparent' }, error: { border: '#ef4444', bg: 'rgba(239, 68, 68, 0.06)' }, success: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.04)' }, - info: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.04)' }, + info: { border: 'var(--info-border)', bg: 'var(--info-bg)' }, warning: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.06)' }, }; @@ -255,7 +255,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { >
- + Downloading Claude CLI... diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index e02380cb..afcc0b00 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -287,7 +287,7 @@ const RepositoryCard = ({ <> · {taskCounts.inProgress > 0 && ( - + {taskCounts.inProgress} active )} diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 499849f4..2be1cc5d 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -7,7 +7,8 @@ import { useCallback, useState } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; import { nameColorSet } from '@renderer/utils/projectColor'; import { @@ -61,6 +62,7 @@ export const SortableTab = ({ setRef, }: SortableTabProps): React.JSX.Element => { const [isHovered, setIsHovered] = useState(false); + const { isLight } = useTheme(); const isPinned = useStore( useShallow((s) => @@ -96,14 +98,14 @@ export const SortableTab = ({ opacity: isDragging ? 0.3 : 1, backgroundColor: isActive ? teamColorSet - ? teamColorSet.badge + ? getThemedBadge(teamColorSet, isLight) : 'var(--color-surface-raised)' : isHovered ? teamColorSet - ? teamColorSet.badge + ? getThemedBadge(teamColorSet, isLight) : 'var(--color-surface-overlay)' : teamColorSet - ? teamColorSet.badge + ? getThemedBadge(teamColorSet, isLight) : 'transparent', color: isActive || isHovered diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index c78078fa..7f2fdbbf 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -7,12 +7,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api, isElectronMode } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { Combobox } from '@renderer/components/ui/combobox'; +import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { getFullResetState } from '@renderer/store/utils/stateResetHelpers'; import { AGENT_LANGUAGE_OPTIONS, resolveLanguageName } from '@shared/utils/agentLanguage'; import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react'; -import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components'; +import { SettingRow, SettingsSectionHeader, SettingsToggle } from '../components'; import type { SafeConfig } from '../hooks/useSettingsConfig'; import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types'; @@ -335,12 +336,24 @@ export const GeneralSection = ({ - +
+ {THEME_OPTIONS.map((opt) => ( + + ))} +
Group by:
@@ -508,7 +508,7 @@ export const GlobalTaskList = ({ className={cn( 'rounded px-2 py-0.5 transition-colors', groupingMode === mode - ? 'bg-surface-raised text-text shadow-sm' + ? 'ring-border-emphasis/60 bg-surface-raised text-text shadow-sm ring-1' : 'text-text-muted hover:text-text-secondary' )} > diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 2fc40d9b..e1aad9c8 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useStore } from '@renderer/store'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -78,6 +79,7 @@ export const SidebarTaskItem = ({ const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); const teamMembers = useStore((s) => s.teamByName[task.teamName]?.members); const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); + const { isLight } = useTheme(); const isRenaming = renamingKey === `${task.teamName}:${task.id}`; const displaySubject = getDisplaySubject?.(task) ?? task.subject; @@ -118,19 +120,24 @@ export const SidebarTaskItem = ({ return colorName ? getTeamColorSet(colorName) : null; }, [teamMembers, task.owner]); + const ownerTextColor = useMemo(() => { + if (!ownerColorSet) return undefined; + return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; + }, [ownerColorSet, isLight]); + const projectLabel = useMemo(() => { if (!task.projectPath?.trim()) return null; return projectLabelFromPath(task.projectPath); }, [task.projectPath]); const projectColorSet = useMemo( - () => (projectLabel ? projectColor(projectLabel) : null), - [projectLabel] + () => (projectLabel ? projectColor(projectLabel, isLight) : null), + [projectLabel, isLight] ); const teamColor = useMemo( - () => (showTeamName ? nameColorSet(task.teamDisplayName) : null), - [showTeamName, task.teamDisplayName] + () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), + [showTeamName, task.teamDisplayName, isLight] ); const showTeamRow = showTeamName && !hideTeamName; @@ -220,17 +227,19 @@ export const SidebarTaskItem = ({ )} {!showTeamRow && ( <> - {projectLabel && ·} + {projectLabel && ·} {task.owner ?? 'unassigned'} )} {dateLabel && ( - + {dateLabel} )} @@ -242,14 +251,14 @@ export const SidebarTaskItem = ({ className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight" style={{ color: 'var(--color-text-muted)' }} > - Team: + Team: {task.teamDisplayName} - · + · {task.owner ?? 'unassigned'} diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 5b513f4d..fb78dd64 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -106,7 +106,7 @@ export const CollapsibleTeamSection = ({ {secondaryBadge != null && secondaryBadge > 0 && ( {secondaryBadge} new diff --git a/src/renderer/components/team/MemberBadge.tsx b/src/renderer/components/team/MemberBadge.tsx index bca37d17..a6857a06 100644 --- a/src/renderer/components/team/MemberBadge.tsx +++ b/src/renderer/components/team/MemberBadge.tsx @@ -1,4 +1,5 @@ -import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; interface MemberBadgeProps { @@ -24,12 +25,13 @@ export const MemberBadge = ({ onClick, }: MemberBadgeProps): React.JSX.Element => { const colors = getTeamColorSet(color ?? ''); + const { isLight } = useTheme(); const avatarSize = size === 'md' ? 32 : 24; const avatarClass = size === 'md' ? 'size-6' : 'size-5'; const textClass = size === 'md' ? 'text-xs' : 'text-[10px]'; const badgeStyle = { - backgroundColor: colors.badge, + backgroundColor: getThemedBadge(colors, isLight), color: colors.text, border: `1px solid ${colors.border}40`, }; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 307f9181..44600ce8 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -13,11 +13,12 @@ import { DialogTitle, } from '@renderer/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; +import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; @@ -125,6 +126,7 @@ function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTask } export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => { + const { isLight } = useTheme(); const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); const [selectedTask, setSelectedTask] = useState(null); const [selectedMember, setSelectedMember] = useState(null); @@ -961,7 +963,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele {headerColorSet ? (
) : null}
@@ -84,7 +85,7 @@ function renderMemberChips(members: TeamSummaryMember[]): React.JSX.Element { style={ memberColor ? { - backgroundColor: memberColor.badge, + backgroundColor: getThemedBadge(memberColor, isLight), color: memberColor.text, border: `1px solid ${memberColor.border}40`, } @@ -177,6 +178,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => { }; export const TeamListView = (): React.JSX.Element => { + const { isLight } = useTheme(); const electronMode = isElectronMode(); const [showCreateDialog, setShowCreateDialog] = useState(false); const [copyData, setCopyData] = useState(null); @@ -679,7 +681,7 @@ export const TeamListView = (): React.JSX.Element => { {teamColorSet ? (
) : null}
{
{team.members && team.members.length > 0 ? ( - renderMemberChips(team.members) + renderMemberChips(team.members, isLight) ) : team.memberCount === 0 ? ( Solo @@ -895,7 +897,7 @@ export const TeamListView = (): React.JSX.Element => {

{team.members && team.members.length > 0 && (
- {renderMemberChips(team.members)} + {renderMemberChips(team.members, isLight)}
)}
diff --git a/src/renderer/components/team/TeamSessionsSection.tsx b/src/renderer/components/team/TeamSessionsSection.tsx index 0a500389..13929447 100644 --- a/src/renderer/components/team/TeamSessionsSection.tsx +++ b/src/renderer/components/team/TeamSessionsSection.tsx @@ -130,7 +130,7 @@ export const TeamSessionsSection = ({ {selectedSessionId !== null && (
diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 4894939a..2268b088 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; import { FileText, Search, Terminal } from 'lucide-react'; @@ -112,6 +113,7 @@ export const ToolApprovalSheet: React.FC = () => { const teamSummary = teams.find((t) => t.teamName === current.teamName); const teamColor = teamSummary?.color ? getTeamColorSet(teamSummary.color) : null; + const { isLight } = useTheme(); return (
{ 0 - ? 'bg-blue-500/20 text-blue-400' + ? 'bg-blue-500/20 text-blue-600 dark:text-blue-400' : 'bg-[var(--color-surface-raised)] text-[var(--color-text-muted)]' }`} title={unreadCount > 0 ? `${unreadCount} unread` : 'All read'} diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx index a93993ad..76fd2f7d 100644 --- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx +++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx @@ -1,5 +1,6 @@ import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { Loader2 } from 'lucide-react'; @@ -19,6 +20,7 @@ export const ActiveTasksBlock = ({ onMemberClick, onTaskClick, }: ActiveTasksBlockProps): React.JSX.Element | null => { + const { isLight } = useTheme(); const colorMap = buildMemberColorMap(members); const taskMap = new Map(tasks.map((t) => [t.id, t])); const working = members.filter((m) => m.currentTaskId != null); @@ -61,7 +63,7 @@ export const ActiveTasksBlock = ({ type="button" className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]" style={{ - backgroundColor: colors.badge, + backgroundColor: getThemedBadge(colors, isLight), color: colors.text, border: `1px solid ${colors.border}40`, }} @@ -73,7 +75,7 @@ export const ActiveTasksBlock = ({ void): React.
)} -
+
+
+ +
{thought.toolSummary && ( diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx index 6a535c42..04dea364 100644 --- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx +++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx @@ -1,5 +1,6 @@ import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { formatDistanceToNowStrict } from 'date-fns'; @@ -18,6 +19,7 @@ export const PendingRepliesBlock = ({ pendingRepliesByMember, onMemberClick, }: PendingRepliesBlockProps): React.JSX.Element | null => { + const { isLight } = useTheme(); const colorMap = buildMemberColorMap(members); const pending = Object.entries(pendingRepliesByMember) .map(([name, sentAtMs]) => ({ @@ -62,7 +64,7 @@ export const PendingRepliesBlock = ({ type="button" className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]" style={{ - backgroundColor: colors.badge, + backgroundColor: getThemedBadge(colors, isLight), color: colors.text, border: `1px solid ${colors.border}40`, }} @@ -75,7 +77,7 @@ export const PendingRepliesBlock = ({ {/* Quote block — styled like SendMessageDialog */} -
+
{/* Decorative quotation mark */} - + {/* "Replying to" + MemberBadge */}
- Replying to + Replying to
@@ -50,7 +50,7 @@ export const ReplyQuoteBlock = ({ {isLong ? (
) : currentTask.description ? ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - startEditDescription(); - } - }} - > +
- + + + + + Edit description +
) : (