From 42171e239d23cf51cfb3d2159aac7b6542903ea3 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 22 Feb 2026 22:15:48 +0200 Subject: [PATCH] feat: enhance task management and UI with new filters and line counting - Implemented line counting for NotebookEdit and Bash commands in MemberStatsComputer to improve task tracking accuracy. - Updated TeamDataService to include kanban column information for tasks, enhancing task visibility. - Refactored Sidebar and GlobalTaskList components to support task filtering by status and team, improving user interaction. - Introduced TaskFiltersPopover for better task filtering options, allowing users to filter tasks by status and unread comments. - Enhanced UI components for better responsiveness and user experience in task management. These changes aim to improve task management efficiency and enhance collaboration within teams. --- src/main/services/team/MemberStatsComputer.ts | 125 ++++++++ .../services/team/TeamAgentToolsInstaller.ts | 1 - src/main/services/team/TeamDataService.ts | 25 +- src/main/services/team/TeamKanbanManager.ts | 5 - .../services/team/TeamProvisioningService.ts | 61 +++- src/main/services/team/TeamTaskReader.ts | 1 + .../components/dashboard/DashboardView.tsx | 7 +- src/renderer/components/layout/Sidebar.tsx | 119 ++++++-- .../components/layout/SidebarHeader.tsx | 282 ++++++------------ .../components/sidebar/GlobalTaskList.tsx | 110 ++++--- .../components/sidebar/SessionItem.tsx | 2 +- .../components/sidebar/SidebarTaskItem.tsx | 15 +- .../components/sidebar/TaskFiltersPopover.tsx | 194 ++++++++++++ .../components/team/TeamDetailView.tsx | 17 +- src/renderer/components/team/TeamListView.tsx | 5 +- .../components/team/activity/ActivityItem.tsx | 31 +- .../team/activity/ActivityTimeline.tsx | 3 + .../team/activity/ReplyQuoteBlock.tsx | 22 ++ .../team/dialogs/SendMessageDialog.tsx | 33 +- .../team/dialogs/TaskCommentsSection.tsx | 106 +++++-- .../team/dialogs/TaskDetailDialog.tsx | 23 +- .../components/team/kanban/KanbanTaskCard.tsx | 3 - .../components/team/kanban/ReviewBadge.tsx | 17 -- .../team/members/MemberStatsTab.tsx | 28 +- .../components/ui/MentionableTextarea.tsx | 19 +- src/renderer/components/ui/combobox.tsx | 4 +- src/renderer/hooks/useMentionDetection.ts | 4 +- src/renderer/utils/agentMessageFormatting.ts | 48 +++ src/shared/constants/agentBlocks.ts | 20 ++ src/shared/types/team.ts | 4 +- .../services/team/MemberStatsComputer.test.ts | 74 +++++ .../services/team/TeamKanbanManager.test.ts | 3 +- 32 files changed, 1057 insertions(+), 354 deletions(-) create mode 100644 src/renderer/components/sidebar/TaskFiltersPopover.tsx create mode 100644 src/renderer/components/team/activity/ReplyQuoteBlock.tsx delete mode 100644 src/renderer/components/team/kanban/ReviewBadge.tsx create mode 100644 test/main/services/team/MemberStatsComputer.test.ts diff --git a/src/main/services/team/MemberStatsComputer.ts b/src/main/services/team/MemberStatsComputer.ts index d6514977..152697ac 100644 --- a/src/main/services/team/MemberStatsComputer.ts +++ b/src/main/services/team/MemberStatsComputer.ts @@ -167,6 +167,28 @@ export class MemberStatsComputer { linesAdded += writeContent.split('\n').length; } } + + // Count lines for NotebookEdit + if (toolName === 'NotebookEdit') { + const src = typeof input.new_source === 'string' ? input.new_source : ''; + if (src) { + linesAdded += src.split('\n').length; + } + if (typeof input.notebook_path === 'string') { + filesTouchedSet.add(input.notebook_path); + } + } + + // Count lines for Bash commands that write to files + if (toolName === 'Bash') { + const cmd = typeof input.command === 'string' ? input.command : ''; + if (cmd) { + const bashLines = estimateBashLinesChanged(cmd); + linesAdded += bashLines.added; + linesRemoved += bashLines.removed; + for (const f of bashLines.files) filesTouchedSet.add(f); + } + } } } } @@ -234,3 +256,106 @@ export class MemberStatsComputer { }; } } + +// --------------------------------------------------------------------------- +// Bash line-change heuristics +// --------------------------------------------------------------------------- + +interface BashLinesResult { + added: number; + removed: number; + files: string[]; +} + +/** + * Best-effort estimation of lines changed by a Bash command. + * Handles common patterns: heredoc writes, echo/printf redirects, + * sed in-place edits, and tee writes. + * + * TODO: Improve Bash line counting accuracy: + * - Currently only covers ~30-40% of real Bash file-write patterns. + * - Misses: variable expansions (`echo "$var" > file`), piped output + * (`grep ... | sort > file`), `python -c`, `git apply`, `patch`, + * `mv`/`cp`, complex heredocs with `<<-` (tab-stripped). + * - The fundamental limitation is that Bash command output is not stored + * in the JSONL tool_use input โ€” only the command string is available. + * The actual content written to files lives inside the shell runtime + * and is not captured. + * - Potential improvements: parse tool_result blocks for git diff --stat + * patterns (requires two-pass parser), or run a post-hoc `git log --stat` + * against the project repo filtered by session timestamps. + */ +export function estimateBashLinesChanged(command: string): BashLinesResult { + let added = 0; + let removed = 0; + const files: string[] = []; + + // 1. Heredoc: cat <<'EOF' > file OR cat < file + // Count lines between delimiter markers. + const heredocPattern = /<<-?\s*'?(\w+)'?/g; + let heredocMatch: RegExpExecArray | null; + while ((heredocMatch = heredocPattern.exec(command)) !== null) { + const delimiter = heredocMatch[1]; + const afterHeredoc = command.slice(heredocMatch.index + heredocMatch[0].length); + const endIdx = afterHeredoc.indexOf(`\n${delimiter}`); + if (endIdx > 0) { + const startIdx = afterHeredoc.indexOf('\n'); + if (startIdx >= 0 && startIdx < endIdx) { + const content = afterHeredoc.slice(startIdx + 1, endIdx); + added += content.split('\n').length; + } + } + } + + // 2. Echo / printf with redirect: echo "..." > /path OR printf "..." > /path + const echoPattern = + /(?:echo|printf)\s+(?:-[a-zA-Z]+\s+)?(?:"([^"]*)"|'([^']*)')\s*>{1,2}\s*(\S+)/g; + let echoMatch: RegExpExecArray | null; + while ((echoMatch = echoPattern.exec(command)) !== null) { + const content = echoMatch[1] ?? echoMatch[2] ?? ''; + if (content) { + added += content.split('\\n').length; + } + const filePath = echoMatch[3]; + if (filePath && filePath.startsWith('/')) { + files.push(filePath); + } + } + + // 3. sed -i: each invocation ~ 1 line changed + const sedPattern = /sed\s+(?:-[a-zA-Z]*i[a-zA-Z]*|-i)\s/g; + let sedMatch: RegExpExecArray | null; + while ((sedMatch = sedPattern.exec(command)) !== null) { + added += 1; + removed += 1; + const afterSed = command.slice(sedMatch.index); + const sedFileMatch = /\s(\/\S+)\s*(?:[;&|]|$)/.exec(afterSed); + if (sedFileMatch) { + files.push(sedFileMatch[1]); + } + } + + // 4. Redirect to file (catch-all for remaining redirects not caught above) + if (added === 0 && removed === 0) { + const redirectPattern = />{1,2}\s*(\/\S+)/g; + let redirectMatch: RegExpExecArray | null; + while ((redirectMatch = redirectPattern.exec(command)) !== null) { + const filePath = redirectMatch[1]; + if (filePath) { + files.push(filePath); + } + } + } + + // 5. tee: ... | tee /path/to/file + const teePattern = /\btee\s+(?:-a\s+)?(\/\S+)/g; + let teeMatch: RegExpExecArray | null; + while ((teeMatch = teePattern.exec(command)) !== null) { + const filePath = teeMatch[1]; + if (filePath) { + files.push(filePath); + } + } + + return { added, removed, files }; +} diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index f7fecbb5..b85f6ba9 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -312,7 +312,6 @@ function setKanbanColumn(paths, teamName, taskId, column) { if (normalized === 'review') { state.tasks[String(taskId)] = { column: 'review', - reviewStatus: 'pending', reviewer: null, movedAt: nowIso(), }; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 48537e72..3006d6c3 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -77,17 +77,36 @@ export class TeamDataService { }); } - // Only include tasks that belong to a known team. - // ~/.claude/tasks/ may also contain solo session task dirs (UUID-named) - // which have no corresponding team in ~/.claude/teams/. + const teamNames = [ + ...new Set(rawTasks.map((t) => t.teamName).filter((n) => teamInfoMap.has(n))), + ]; + const kanbanByTeam = new Map(); + await Promise.all( + teamNames.map(async (teamName) => { + try { + const state = await this.kanbanManager.getState(teamName); + kanbanByTeam.set(teamName, state); + } catch { + // ignore + } + }) + ); + return rawTasks .filter((task) => teamInfoMap.has(task.teamName)) .map((task) => { const info = teamInfoMap.get(task.teamName)!; + const kanban = kanbanByTeam.get(task.teamName); + const kanbanEntry = kanban?.tasks[task.id]; + const kanbanColumn = + kanbanEntry?.column === 'review' || kanbanEntry?.column === 'approved' + ? kanbanEntry.column + : undefined; return { ...task, teamDisplayName: info.displayName, projectPath: task.projectPath ?? info.projectPath, + kanbanColumn, }; }); } diff --git a/src/main/services/team/TeamKanbanManager.ts b/src/main/services/team/TeamKanbanManager.ts index 39b1816b..89c8e794 100644 --- a/src/main/services/team/TeamKanbanManager.ts +++ b/src/main/services/team/TeamKanbanManager.ts @@ -56,10 +56,6 @@ export class TeamKanbanManager { sanitizedTasks[taskId] = { column: candidate.column, movedAt: candidate.movedAt, - reviewStatus: - candidate.reviewStatus === 'pending' || candidate.reviewStatus === 'error' - ? candidate.reviewStatus - : undefined, reviewer: typeof candidate.reviewer === 'string' || candidate.reviewer === null ? candidate.reviewer @@ -87,7 +83,6 @@ export class TeamKanbanManager { } else if (patch.column === 'review') { state.tasks[taskId] = { column: 'review', - reviewStatus: 'pending', reviewer: null, movedAt: new Date().toISOString(), }; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index fde140a8..278133bc 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -442,6 +442,12 @@ export class TeamProvisioningService { } async prepareForProvisioning(cwd?: string): Promise { + // Always validate cwd even when cache is available + const targetCwdForValidation = cwd?.trim() || process.cwd(); + if (targetCwdForValidation && path.isAbsolute(targetCwdForValidation)) { + await ensureCwdExists(targetCwdForValidation); + } + if (cachedProbeResult) { const { warning, authSource } = cachedProbeResult; const warnings: string[] = []; @@ -796,11 +802,18 @@ export class TeamProvisioningService { // Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names. await this.normalizeTeamConfigForLaunch(request.teamName, configRaw); - await ensureCwdExists(request.cwd); + let claudePath: string | null; + try { + await ensureCwdExists(request.cwd); - const claudePath = await ClaudeBinaryResolver.resolve(); - if (!claudePath) { - throw new Error('Claude CLI not found; install it or provide a valid path'); + claudePath = await ClaudeBinaryResolver.resolve(); + if (!claudePath) { + throw new Error('Claude CLI not found; install it or provide a valid path'); + } + } catch (error) { + // Restore pre-launch backup so config.json is not left in normalized (lead-only) state + await this.restorePrelaunchConfig(request.teamName); + throw error; } const teamsBasePathsToProbe = getTeamsBasePathsToProbe(); @@ -894,6 +907,7 @@ export class TeamProvisioningService { } catch (error) { this.runs.delete(runId); this.activeByTeam.delete(request.teamName); + await this.restorePrelaunchConfig(request.teamName); throw error; } @@ -1138,6 +1152,7 @@ export class TeamProvisioningService { if (run.isLaunch) { await this.updateConfigPostLaunch(run.teamName, run.request.cwd); + await this.cleanupPrelaunchBackup(run.teamName); const readyMessage = 'Team launched โ€” process alive and ready'; const progress = updateProgress(run, 'ready', readyMessage, { cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), @@ -1850,6 +1865,34 @@ export class TeamProvisioningService { await this.mergeAndRemoveDuplicateInboxes(teamName, baseNames); } + /** + * Restore config.json from prelaunch backup if launch fails after normalization. + */ + private async restorePrelaunchConfig(teamName: string): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + const backupPath = `${configPath}.prelaunch.bak`; + try { + const backupRaw = await fs.promises.readFile(backupPath, 'utf8'); + await atomicWriteAsync(configPath, backupRaw); + logger.info(`[${teamName}] Restored config.json from prelaunch backup after launch failure`); + } catch { + logger.debug(`[${teamName}] No prelaunch backup to restore (or read failed)`); + } + } + + /** + * Remove the prelaunch backup file after a successful launch. + */ + async cleanupPrelaunchBackup(teamName: string): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + const backupPath = `${configPath}.prelaunch.bak`; + try { + await fs.promises.unlink(backupPath); + } catch { + // Backup may not exist โ€” that's fine + } + } + private async mergeAndRemoveDuplicateInboxes( teamName: string, baseNames: Set @@ -1938,12 +1981,16 @@ export class TeamProvisioningService { const at = a && typeof a === 'object' ? Date.parse((a as { timestamp?: string }).timestamp ?? '') - : 0; + : NaN; const bt = b && typeof b === 'object' ? Date.parse((b as { timestamp?: string }).timestamp ?? '') - : 0; - if (Number.isNaN(at) || Number.isNaN(bt)) return 0; + : NaN; + const atNaN = Number.isNaN(at); + const btNaN = Number.isNaN(bt); + if (atNaN && btNaN) return 0; + if (atNaN) return 1; + if (btNaN) return -1; return bt - at; }); diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index b13c6887..44761ca8 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -113,6 +113,7 @@ export class TeamTaskReader { c && typeof c === 'object' && typeof c.id === 'string' && + typeof c.author === 'string' && typeof c.text === 'string' && typeof c.createdAt === 'string' ) diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index ae68092a..3189abdb 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -308,11 +308,16 @@ const ProjectsGrid = ({ })) ); + const hasFetchedTasksRef = React.useRef(false); + useEffect(() => { if (repositoryGroups.length === 0) { void fetchRepositoryGroups(); } - void fetchAllTasks(); + if (!hasFetchedTasksRef.current) { + hasFetchedTasksRef.current = true; + void fetchAllTasks(); + } }, [repositoryGroups.length, fetchRepositoryGroups, fetchAllTasks]); const taskCountsMap = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]); diff --git a/src/renderer/components/layout/Sidebar.tsx b/src/renderer/components/layout/Sidebar.tsx index bd1d6a0c..557f3ae7 100644 --- a/src/renderer/components/layout/Sidebar.tsx +++ b/src/renderer/components/layout/Sidebar.tsx @@ -3,11 +3,10 @@ * * Structure: * - Fixed Header: Project selector (Row 1) + Worktree selector (Row 2, conditional) - * - Scrollable Body: Date-grouped session list + * - Tab bar: Tasks | Sessions + * - Scrollable Body: Task list or date-grouped session list * - Resizable: Drag right edge to resize * - Collapsible: Cmd+B to toggle (Notion-style) - * - * Provides clear hierarchy visibility: Project -> Worktree -> Session */ import { useCallback, useEffect, useRef, useState } from 'react'; @@ -15,25 +14,35 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; import { useShallow } from 'zustand/react/shallow'; +import { DateGroupedSessions } from '../sidebar/DateGroupedSessions'; import { GlobalTaskList } from '../sidebar/GlobalTaskList'; +import { defaultTaskFiltersState, TaskFiltersPopover } from '../sidebar/TaskFiltersPopover'; import { SidebarHeader } from './SidebarHeader'; +import type { TaskFiltersState } from '../sidebar/TaskFiltersPopover'; + +type SidebarTab = 'tasks' | 'sessions'; + const MIN_WIDTH = 200; const MAX_WIDTH = 500; const DEFAULT_WIDTH = 280; -export const Sidebar = (): React.JSX.Element | null => { - const { projects, projectsLoading, fetchProjects, sidebarCollapsed } = useStore( +export const Sidebar = (): React.JSX.Element => { + const { projects, projectsLoading, fetchProjects, sidebarCollapsed, teams } = useStore( useShallow((s) => ({ projects: s.projects, projectsLoading: s.projectsLoading, fetchProjects: s.fetchProjects, sidebarCollapsed: s.sidebarCollapsed, + teams: s.teams, })) ); const [width, setWidth] = useState(DEFAULT_WIDTH); const [isResizing, setIsResizing] = useState(false); + const [sidebarTab, setSidebarTab] = useState('tasks'); + const [taskFilters, setTaskFilters] = useState(defaultTaskFiltersState); + const [taskFiltersPopoverOpen, setTaskFiltersPopoverOpen] = useState(false); const sidebarRef = useRef(null); // Fetch projects on mount if not loaded @@ -83,38 +92,96 @@ export const Sidebar = (): React.JSX.Element | null => { setIsResizing(true); }; - // Collapsed state - sidebar is completely hidden (expand button is in TabBar) - if (sidebarCollapsed) { - return null; - } - return (
- {/* Sidebar header with project dropdown */} - +
+ - {/* Global task list */} -
- + {/* Tab bar: Tasks | Sessions */} +
+
+ + +
+ {sidebarTab === 'tasks' && ( + ({ teamName: t.teamName, displayName: t.displayName }))} + filters={taskFilters} + onFiltersChange={setTaskFilters} + onApply={() => {}} + /> + )} +
+ + {/* Content: Tasks list or Sessions list */} +
+ {sidebarTab === 'tasks' ? ( + + ) : ( + + )} +
- {/* Resize handle */} -
); }; diff --git a/src/renderer/components/layout/SidebarHeader.tsx b/src/renderer/components/layout/SidebarHeader.tsx index 1accc1f6..f320bb1f 100644 --- a/src/renderer/components/layout/SidebarHeader.tsx +++ b/src/renderer/components/layout/SidebarHeader.tsx @@ -11,10 +11,11 @@ * - Row 2 is a full-width button with no side margins */ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { isElectronMode } from '@renderer/api'; import { HEADER_ROW1_HEIGHT, HEADER_ROW2_HEIGHT } from '@renderer/constants/layout'; +import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { truncateMiddle } from '@renderer/utils/stringUtils'; import { Check, ChevronDown, GitBranch, PanelLeft } from 'lucide-react'; @@ -22,6 +23,7 @@ import { useShallow } from 'zustand/react/shallow'; import { AppLogo } from '../common/AppLogo'; import { WorktreeBadge } from '../common/WorktreeBadge'; +import { Combobox, type ComboboxOption } from '../ui/combobox'; import type { Worktree, WorktreeSource } from '@renderer/types/data'; @@ -139,62 +141,6 @@ const WorktreeItem = ({ ); }; -/** - * Individual project/repository item in the dropdown. - */ -interface ProjectDropdownItemProps { - name: string; - path?: string; - sessionCount: number; - isSelected: boolean; - onSelect: () => void; -} - -const ProjectDropdownItem = ({ - name, - path, - sessionCount, - isSelected, - onSelect, -}: Readonly): React.JSX.Element => { - const [isHovered, setIsHovered] = useState(false); - - const buttonStyle: React.CSSProperties = isSelected - ? { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' } - : { - backgroundColor: isHovered ? 'var(--color-surface-raised)' : 'transparent', - opacity: isHovered ? 0.5 : 1, - }; - - return ( - - ); -}; - export const SidebarHeader = (): React.JSX.Element => { const isMacElectron = isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac'); @@ -239,9 +185,7 @@ export const SidebarHeader = (): React.JSX.Element => { }, [viewMode, repositoryGroups.length, projects.length, fetchRepositoryGroups, fetchProjects]); const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false); - const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false); const worktreeDropdownRef = useRef(null); - const projectDropdownRef = useRef(null); // Find the active repository and worktree const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId); @@ -255,71 +199,49 @@ export const SidebarHeader = (): React.JSX.Element => { const mainWorktree = worktreeGroupingResult.mainWorktree; const worktreeGroups = worktreeGroupingResult.groups; - // For flat mode - const activeProject = projects.find((p) => p.id === activeProjectId); - - // Get display name - const projectName = - viewMode === 'grouped' - ? (activeRepo?.name ?? 'Select Project') - : (activeProject?.name ?? 'Select Project'); - const worktreeName = activeWorktree?.name ?? 'main'; - const hasSelection = viewMode === 'grouped' ? !!activeRepo : !!activeProject; - - // Close dropdowns on outside click - useEffect(() => { - function handleClickOutside(event: MouseEvent): void { - if ( - worktreeDropdownRef.current && - !worktreeDropdownRef.current.contains(event.target as Node) - ) { - setIsWorktreeDropdownOpen(false); - } - if ( - projectDropdownRef.current && - !projectDropdownRef.current.contains(event.target as Node) - ) { - setIsProjectDropdownOpen(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - // Close on escape - useEffect(() => { - function handleEscape(event: KeyboardEvent): void { - if (event.key === 'Escape') { - setIsWorktreeDropdownOpen(false); - setIsProjectDropdownOpen(false); - } - } - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, []); const handleSelectWorktree = (worktree: Worktree): void => { selectWorktree(worktree.id); setIsWorktreeDropdownOpen(false); }; - const handleSelectRepo = (repoId: string): void => { - selectRepository(repoId); - setIsProjectDropdownOpen(false); + const handleProjectValueChange = (id: string): void => { + if (viewMode === 'grouped') selectRepository(id); + else setActiveProject(id); }; - const handleSelectProject = (projectId: string): void => { - setActiveProject(projectId); - setIsProjectDropdownOpen(false); - }; - - // Items for project dropdown - filter out repositories/projects with 0 sessions + // Items for project combobox - filter out repositories/projects with 0 sessions const projectItems = viewMode === 'grouped' ? repositoryGroups.filter((r) => r.totalSessions > 0) : projects.filter((p) => p.sessions.length > 0); + const projectComboboxOptions = useMemo((): ComboboxOption[] => { + const items = + viewMode === 'grouped' + ? repositoryGroups.filter((r) => r.totalSessions > 0) + : projects.filter((p) => p.sessions.length > 0); + return items.map((item) => { + const sessionCount = + viewMode === 'grouped' + ? (item as (typeof repositoryGroups)[0]).totalSessions + : (item as (typeof projects)[0]).sessions.length; + const path = + viewMode === 'grouped' + ? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path + : (item as (typeof projects)[0]).path; + return { + value: item.id, + label: item.name, + description: path, + meta: { sessionCount, path }, + }; + }); + }, [viewMode, repositoryGroups, projects]); + + const activeProjectValue = viewMode === 'grouped' ? selectedRepositoryId : activeProjectId; + const [isCollapseHovered, setIsCollapseHovered] = useState(false); return ( @@ -327,43 +249,75 @@ export const SidebarHeader = (): React.JSX.Element => { className="flex w-full flex-col" style={{ backgroundColor: 'var(--color-surface-sidebar)' }} > - {/* ROW 1: Project Identity (Title Bar / Drag Region) */} + {/* ROW 1: Logo in corner, project selector fills width, collapse button */}
- {/* App logo + Project name dropdown button */} - - - - {/* Collapse sidebar button */} +
- - {/* Project Dropdown */} - {isProjectDropdownOpen && ( - <> -
setIsProjectDropdownOpen(false)} - /> -
-
- Switch {viewMode === 'grouped' ? 'Repository' : 'Project'} -
- - {projectItems.length === 0 ? ( -
- No {viewMode === 'grouped' ? 'repositories' : 'projects'} found -
- ) : ( - projectItems.map((item) => { - const isSelected = - viewMode === 'grouped' - ? item.id === selectedRepositoryId - : item.id === activeProjectId; - const itemSessions = - viewMode === 'grouped' - ? (item as (typeof repositoryGroups)[0]).totalSessions - : (item as (typeof projects)[0]).sessions.length; - // Get path for display - const itemPath = - viewMode === 'grouped' - ? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path - : (item as (typeof projects)[0]).path; - - return ( - - viewMode === 'grouped' - ? handleSelectRepo(item.id) - : handleSelectProject(item.id) - } - /> - ); - }) - )} -
- - )}
{/* ROW 2: Worktree Selector (Full Width) */} diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 3238101c..20ffea51 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -7,29 +7,32 @@ import { ListTodo, Search, X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { SidebarTaskItem } from './SidebarTaskItem'; +import { + defaultTaskFiltersState, + getTaskUnreadCount, + TaskFiltersPopover, + taskMatchesStatus, + useReadStateSnapshot, +} from './TaskFiltersPopover'; +import type { TaskFiltersState } from './TaskFiltersPopover'; import type { GlobalTask } from '@shared/types'; -type StatusFilter = 'all' | 'active' | 'done'; - -const filterButtons: { value: StatusFilter; label: string }[] = [ - { value: 'all', label: 'All' }, - { value: 'active', label: 'Active' }, - { value: 'done', label: 'Done' }, -]; +export interface GlobalTaskListProps { + /** When true, do not render the header row (Tasks + Filters); parent renders tabs and filters. */ + hideHeader?: boolean; + /** External filters state when used with sidebar tabs. */ + filters?: TaskFiltersState; + onFiltersChange?: (f: TaskFiltersState) => void; + filtersPopoverOpen?: boolean; + onFiltersPopoverOpenChange?: (open: boolean) => void; +} const dateCategoryLabels: Record = { 'Previous 7 Days': 'Last 7 Days', Older: 'Earlier', }; -function applyFilter(tasks: GlobalTask[], filter: StatusFilter): GlobalTask[] { - if (filter === 'all') return tasks; - if (filter === 'active') - return tasks.filter((t) => t.status === 'pending' || t.status === 'in_progress'); - return tasks.filter((t) => t.status === 'completed'); -} - function applySearch(tasks: GlobalTask[], query: string): GlobalTask[] { if (!query.trim()) return tasks; const q = query.toLowerCase(); @@ -47,7 +50,13 @@ function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): Gl return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized); } -export const GlobalTaskList = (): React.JSX.Element => { +export const GlobalTaskList = ({ + hideHeader = false, + filters: externalFilters, + onFiltersChange: externalOnFiltersChange, + filtersPopoverOpen: externalFiltersPopoverOpen, + onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange, +}: GlobalTaskListProps = {}): React.JSX.Element => { const { globalTasks, globalTasksLoading, @@ -58,6 +67,7 @@ export const GlobalTaskList = (): React.JSX.Element => { repositoryGroups, selectedRepositoryId, selectedWorktreeId, + teams, } = useStore( useShallow((s) => ({ globalTasks: s.globalTasks, @@ -69,13 +79,20 @@ export const GlobalTaskList = (): React.JSX.Element => { repositoryGroups: s.repositoryGroups, selectedRepositoryId: s.selectedRepositoryId, selectedWorktreeId: s.selectedWorktreeId, + teams: s.teams, })) ); - const [filter, setFilter] = useState('all'); + const [internalFilters, setInternalFilters] = useState(defaultTaskFiltersState); + const [internalFiltersPopoverOpen, setInternalFiltersPopoverOpen] = useState(false); + const filters = externalFilters ?? internalFilters; + const setFilters = externalOnFiltersChange ?? setInternalFilters; + const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen; + const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen; const [searchQuery, setSearchQuery] = useState(''); const searchInputRef = useRef(null); const hasFetchedRef = useRef(false); + const readState = useReadStateSnapshot(); useEffect(() => { if (!hasFetchedRef.current) { @@ -104,43 +121,52 @@ export const GlobalTaskList = (): React.JSX.Element => { const filtered = useMemo(() => { let result = globalTasks; result = applyProjectFilter(result, selectedProjectPath); - result = applyFilter(result, filter); + result = result.filter((t) => taskMatchesStatus(t, filters.statusIds)); + if (filters.teamName) { + result = result.filter((t) => t.teamName === filters.teamName); + } + if (filters.unreadOnly) { + result = result.filter( + (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) > 0 + ); + } result = applySearch(result, searchQuery); return result; - }, [globalTasks, selectedProjectPath, filter, searchQuery]); + }, [ + globalTasks, + selectedProjectPath, + filters.statusIds, + filters.teamName, + filters.unreadOnly, + searchQuery, + readState, + ]); const grouped = useMemo(() => groupTasksByDate(filtered), [filtered]); const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]); return ( -
- {/* Header + Filter bar */} -
- Tasks -
- {filterButtons.map((btn) => ( - - ))} +
+ {!hideHeader && ( +
+ Tasks + ({ teamName: t.teamName, displayName: t.displayName }))} + filters={filters} + onFiltersChange={setFilters} + onApply={() => {}} + />
-
+ )} {/* Search bar */}
diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 8d756d86..e54479f3 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -246,7 +246,7 @@ export const SessionItem = ({ + + +
+
+
+ Status + +
+
+ {STATUS_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+ Team + ({ value: t.teamName, label: t.displayName })), + ]} + value={filters.teamName ?? '__all__'} + onValueChange={(v) => + onFiltersChange({ + ...filters, + teamName: v === '__all__' ? null : v, + }) + } + placeholder="All teams" + searchPlaceholder="Search teams..." + emptyMessage="No teams found" + className="text-[12px]" + /> +
+ + + + +
+
+ + ); +}; + +export const defaultTaskFiltersState = (): TaskFiltersState => ({ + statusIds: new Set(STATUS_OPTIONS.map((o) => o.id)), + teamName: null, + unreadOnly: false, +}); + +export function taskMatchesStatus( + task: { status: string; kanbanColumn?: 'review' | 'approved' }, + statusIds: Set +): boolean { + if (statusIds.size === 0) return false; + if (statusIds.size === STATUS_OPTIONS.length) return true; + + const inTodo = task.status === 'pending' && !task.kanbanColumn; + const inProgress = task.status === 'in_progress'; + const inDone = task.status === 'completed' && !task.kanbanColumn; + const inReview = task.kanbanColumn === 'review'; + const inApproved = task.kanbanColumn === 'approved'; + + return ( + (statusIds.has('todo') && inTodo) || + (statusIds.has('in_progress') && inProgress) || + (statusIds.has('done') && inDone) || + (statusIds.has('review') && inReview) || + (statusIds.has('approved') && inApproved) + ); +} + +export function useReadStateSnapshot(): ReturnType { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export function getTaskUnreadCount( + readState: ReturnType, + teamName: string, + taskId: string, + comments: { createdAt: string }[] | undefined +): number { + return getUnreadCount(readState, teamName, taskId, comments ?? []); +} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 623fd180..337eecbb 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -72,6 +72,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [launchDialogOpen, setLaunchDialogOpen] = useState(false); const [sendDialogOpen, setSendDialogOpen] = useState(false); const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); + const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( + undefined + ); // Session loading and filtering state const [sessions, setSessions] = useState([]); @@ -460,6 +463,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onMemberClick={setSelectedMember} onSendMessage={(member) => { setSendDialogRecipient(member.name); + setReplyQuote(undefined); setSendDialogOpen(true); }} onAssignTask={(member) => { @@ -595,6 +599,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onClick={(e) => { e.stopPropagation(); setSendDialogRecipient(undefined); + setReplyQuote(undefined); setSendDialogOpen(true); }} > @@ -609,6 +614,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onCreateTaskFromMessage={(subject, description) => { openCreateTaskDialog(subject, description); }} + onReplyToMessage={(message) => { + setSendDialogRecipient(message.from); + setReplyQuote({ from: message.from, text: message.text }); + setSendDialogOpen(true); + }} /> @@ -645,6 +655,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const name = selectedMember?.name ?? ''; setSelectedMember(null); setSendDialogRecipient(name || undefined); + setReplyQuote(undefined); setSendDialogOpen(true); }} onAssignTask={() => { @@ -697,13 +708,17 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele open={sendDialogOpen} members={data.members} defaultRecipient={sendDialogRecipient} + quotedMessage={replyQuote} sending={sendingMessage} sendError={sendMessageError} lastResult={lastSendMessageResult} onSend={(member, text, summary) => { void sendTeamMessage(teamName, { member, text, summary }); }} - onClose={() => setSendDialogOpen(false)} + onClose={() => { + setSendDialogOpen(false); + setReplyQuote(undefined); + }} /> void; + onReply?: (message: InboxMessage) => void; } function getStringField(obj: StructuredMessage, key: string): string | null { @@ -121,6 +125,7 @@ export const ActivityItem = ({ memberRole, memberColor, onCreateTask, + onReply, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const formattedRole = formatAgentRole(memberRole); @@ -142,6 +147,12 @@ export const ActivityItem = ({ [structured, message.text] ); + // Check if this is a reply message + const parsedReply = useMemo( + () => (displayText ? parseMessageReply(displayText) : null), + [displayText] + ); + // Noise messages: minimal inline row if (noiseLabel) { return ; @@ -255,8 +266,22 @@ export const ActivityItem = ({ {summaryText} - {/* Timestamp + create task */} + {/* Timestamp + reply + create task */}
+ {onReply && ( + + )} {onCreateTask && (
+ ) : parsedReply ? ( + ) : ( )} diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 9dadec9b..50cf6857 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -6,12 +6,14 @@ interface ActivityTimelineProps { messages: InboxMessage[]; members?: ResolvedTeamMember[]; onCreateTaskFromMessage?: (subject: string, description: string) => void; + onReplyToMessage?: (message: InboxMessage) => void; } export const ActivityTimeline = ({ messages, members, onCreateTaskFromMessage, + onReplyToMessage, }: ActivityTimelineProps): React.JSX.Element => { const memberInfo = new Map(); if (members) { @@ -43,6 +45,7 @@ export const ActivityTimeline = ({ memberRole={info?.role} memberColor={info?.color} onCreateTask={onCreateTaskFromMessage} + onReply={onReplyToMessage} /> ); })} diff --git a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx new file mode 100644 index 00000000..46b450f2 --- /dev/null +++ b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx @@ -0,0 +1,22 @@ +import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; + +import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting'; + +interface ReplyQuoteBlockProps { + reply: ParsedMessageReply; +} + +export const ReplyQuoteBlock = ({ reply }: ReplyQuoteBlockProps): React.JSX.Element => ( +
+
+ + @{reply.agentName} + +

{reply.originalText}

+
+ +
+); diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 439ebbb2..cd827969 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -21,15 +21,23 @@ import { } from '@renderer/components/ui/select'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; +import { X } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { ResolvedTeamMember, SendMessageResult } from '@shared/types'; +interface QuotedMessage { + from: string; + text: string; +} + interface SendMessageDialogProps { open: boolean; members: ResolvedTeamMember[]; defaultRecipient?: string; + quotedMessage?: QuotedMessage; sending: boolean; sendError: string | null; lastResult: SendMessageResult | null; @@ -43,12 +51,14 @@ export const SendMessageDialog = ({ open, members, defaultRecipient, + quotedMessage, sending, sendError, lastResult, onSend, onClose, }: SendMessageDialogProps): React.JSX.Element => { + const [quote, setQuote] = useState(undefined); const [member, setMember] = useState(''); const textDraft = useDraftPersistence({ key: 'sendMessage:text' }); const [summary, setSummary] = useState(''); @@ -59,6 +69,7 @@ export const SendMessageDialog = ({ if (open && !prevOpen) { setMember(defaultRecipient ?? ''); setSummary(''); + setQuote(quotedMessage); setPrevResult(lastResult); } if (open !== prevOpen) { @@ -99,7 +110,9 @@ export const SendMessageDialog = ({ const handleSubmit = (): void => { if (!canSend) return; - onSend(member.trim(), textDraft.value.trim(), summary.trim() || undefined); + const rawText = textDraft.value.trim(); + const finalText = quote ? buildReplyBlock(quote.from, quote.text, rawText) : rawText; + onSend(member.trim(), finalText, summary.trim() || undefined); textDraft.clearDraft(); }; @@ -165,6 +178,24 @@ export const SendMessageDialog = ({ />
+ {quote ? ( +
+ + + Replying to @{quote.from} + +

+ {quote.text} +

+
+ ) : null} +
s.addingComment); const commentsRef = useMarkCommentsRead(teamName, taskId, comments); + const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null); + const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` }); const mentionSuggestions = useMemo( @@ -51,12 +56,14 @@ export const TaskCommentsSection = ({ const handleSubmit = useCallback(async () => { if (!canSubmit) return; try { - await addTaskComment(teamName, taskId, trimmed); + const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, trimmed) : trimmed; + await addTaskComment(teamName, taskId, text); draft.clearDraft(); + setReplyTo(null); } catch { // Error is stored in addCommentError via store } - }, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft]); + }, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, replyTo]); return (
@@ -75,7 +82,7 @@ export const TaskCommentsSection = ({ {comments.map((comment) => (
{comment.author} - {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })} + + {(() => { + const date = new Date(comment.createdAt); + return isNaN(date.getTime()) + ? 'unknown time' + : formatDistanceToNow(date, { addSuffix: true }); + })()} + +
- + {(() => { + const reply = parseMessageReply(comment.text); + return reply ? ( + + ) : ( + + ); + })()}
))}
) : null} -
+ {replyTo ? ( +
+
+
+ Replying to{' '} + m.name === replyTo.author)?.color ?? + 'var(--color-text-secondary)'), + }} + > + @{replyTo.author} + +
+
+ {replyTo.text} +
+
+ +
+ ) : null} + +
void handleSubmit()} + > + + Comment + + } footerRight={
{remaining < 200 ? ( @@ -126,17 +203,6 @@ export const TaskCommentsSection = ({
} /> -
- -
); diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 2b4aa2a7..07c893ea 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -1,5 +1,4 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; -import { ReviewBadge } from '@renderer/components/team/kanban/ReviewBadge'; import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; @@ -93,14 +92,19 @@ export const TaskDetailDialog = ({ {currentTask.owner ?? '\u2014'}
- {currentTask.createdAt ? ( -
- - - {formatDistanceToNow(new Date(currentTask.createdAt), { addSuffix: true })} - -
- ) : null} + {currentTask.createdAt + ? (() => { + const date = new Date(currentTask.createdAt); + return isNaN(date.getTime()) ? null : ( +
+ + + {formatDistanceToNow(date, { addSuffix: true })} + +
+ ); + })() + : null}
{/* Description */} @@ -178,7 +182,6 @@ export const TaskDetailDialog = ({ {/* Review info */} {kanbanTaskState ? (
- {kanbanTaskState.reviewer ? ( Reviewer: {kanbanTaskState.reviewer} diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 69e9ce30..cca9704a 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -4,8 +4,6 @@ import { Button } from '@renderer/components/ui/button'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play } from 'lucide-react'; -import { ReviewBadge } from './ReviewBadge'; - import type { KanbanColumnId, KanbanTaskState, TeamTask } from '@shared/types'; interface KanbanTaskCardProps { @@ -111,7 +109,6 @@ export const KanbanTaskCard = ({
{task.subject}
- {columnId === 'review' ? : null}

Owner: {task.owner ?? '\u2014'}

diff --git a/src/renderer/components/team/kanban/ReviewBadge.tsx b/src/renderer/components/team/kanban/ReviewBadge.tsx deleted file mode 100644 index eff8b189..00000000 --- a/src/renderer/components/team/kanban/ReviewBadge.tsx +++ /dev/null @@ -1,17 +0,0 @@ -interface ReviewBadgeProps { - status?: 'pending' | 'error'; -} - -export const ReviewBadge = ({ status = 'pending' }: ReviewBadgeProps): React.JSX.Element => { - const isError = status === 'error'; - - return ( - - {isError ? 'Review: Error' : 'Review: Pending'} - - ); -}; diff --git a/src/renderer/components/team/members/MemberStatsTab.tsx b/src/renderer/components/team/members/MemberStatsTab.tsx index 4970844a..ff9023dc 100644 --- a/src/renderer/components/team/members/MemberStatsTab.tsx +++ b/src/renderer/components/team/members/MemberStatsTab.tsx @@ -2,7 +2,15 @@ import { useEffect, useState } from 'react'; import { api } from '@renderer/api'; import { formatTokensCompact } from '@shared/utils/tokenFormatting'; -import { AlertCircle, BarChart3, ChevronDown, ChevronRight, FileCode, Loader2 } from 'lucide-react'; +import { + AlertCircle, + BarChart3, + ChevronDown, + ChevronRight, + FileCode, + Info, + Loader2, +} from 'lucide-react'; import type { MemberFullStats } from '@shared/types'; @@ -90,14 +98,29 @@ const StatCard = ({ label, value, sub, + info, }: { label: string; value: string | number; sub?: string; + info?: string; }): React.JSX.Element => (

{value}

-

{label}

+
+

{label}

+ {info && ( + + + + {info} + + + )} +
{sub &&

{sub}

}
); @@ -116,6 +139,7 @@ const SummaryCards = ({ label="Lines" value={`+${stats.linesAdded}`} sub={stats.linesRemoved > 0 ? `-${stats.linesRemoved}` : undefined} + info="Approximate. Accurate for Edit and Write tools. Bash file writes are estimated from command patterns (heredoc, echo, sed) and may be underreported." /> diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index 00b55d74..8f4a5e9c 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useMentionDetection } from '@renderer/hooks/useMentionDetection'; +import { cn } from '@renderer/lib/utils'; import { AutoResizeTextarea } from './auto-resize-textarea'; import { MentionSuggestionList } from './MentionSuggestionList'; @@ -114,6 +115,8 @@ interface MentionableTextareaProps extends Omit< showHint?: boolean; /** Content rendered at the right side of the footer row (e.g. "Draft saved") */ footerRight?: React.ReactNode; + /** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */ + cornerAction?: React.ReactNode; } export const MentionableTextarea = React.forwardRef( @@ -125,7 +128,9 @@ export const MentionableTextarea = React.forwardRef ); - })} - {/* Trailing space ensures trailing newlines render correctly */}{' '} + })}{' '}
) : null} @@ -243,9 +250,15 @@ export const MentionableTextarea = React.forwardRef + {cornerAction ? ( +
+
{cornerAction}
+
+ ) : null} {showFooter ? ( diff --git a/src/renderer/components/ui/combobox.tsx b/src/renderer/components/ui/combobox.tsx index f05cfef2..ceef2029 100644 --- a/src/renderer/components/ui/combobox.tsx +++ b/src/renderer/components/ui/combobox.tsx @@ -6,10 +6,12 @@ import { Check, ChevronsUpDown } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from './popover'; -interface ComboboxOption { +export interface ComboboxOption { value: string; label: string; description?: string; + /** Extra data for renderOption (e.g. sessionCount, path). */ + meta?: Record; } interface ComboboxProps { diff --git a/src/renderer/hooks/useMentionDetection.ts b/src/renderer/hooks/useMentionDetection.ts index c0ddd3be..51bf0fd0 100644 --- a/src/renderer/hooks/useMentionDetection.ts +++ b/src/renderer/hooks/useMentionDetection.ts @@ -127,8 +127,8 @@ export function findMentionTrigger(text: string, cursorPos: number): MentionTrig for (let i = beforeCursor.length - 1; i >= 0; i--) { const char = beforeCursor[i]; - // If we hit a space before finding @, no valid trigger - if (char === ' ' || char === '\t') return null; + // If we hit whitespace or newline before finding @, no valid trigger + if (char === ' ' || char === '\t' || char === '\n' || char === '\r') return null; if (char === '@') { // @ must be at start or after whitespace/newline diff --git a/src/renderer/utils/agentMessageFormatting.ts b/src/renderer/utils/agentMessageFormatting.ts index b3e903ef..ab41fb17 100644 --- a/src/renderer/utils/agentMessageFormatting.ts +++ b/src/renderer/utils/agentMessageFormatting.ts @@ -1,5 +1,53 @@ +import { MESSAGE_REPLY_TAG } from '@shared/constants/agentBlocks'; + type StructuredAgentMessage = Record; +// --------------------------------------------------------------------------- +// Reply block parsing +// --------------------------------------------------------------------------- + +export interface ParsedMessageReply { + agentName: string; + originalText: string; + replyText: string; +} + +const REPLY_BLOCK_RE = new RegExp( + '```' + + MESSAGE_REPLY_TAG + + '\\nReply on @([\\w-]+) original message with text "([\\s\\S]*?)", here is answer: "([\\s\\S]*?)"\\n```' +); + +/** + * Parses a message_reply_for_agent block from content. + * Returns null if no reply block is found. + */ +export function parseMessageReply(content: string): ParsedMessageReply | null { + const match = REPLY_BLOCK_RE.exec(content); + if (!match) return null; + return { + agentName: match[1], + originalText: match[2], + replyText: match[3], + }; +} + +/** + * Builds a reply block string for sending. + */ +export function buildReplyBlock( + agentName: string, + originalText: string, + replyText: string +): string { + const tag = MESSAGE_REPLY_TAG; + return [ + '```' + tag, + `Reply on @${agentName} original message with text "${originalText}", here is answer: "${replyText}"`, + '```', + ].join('\n'); +} + const NOISE_TYPES = new Set([ 'idle_notification', 'shutdown_approved', diff --git a/src/shared/constants/agentBlocks.ts b/src/shared/constants/agentBlocks.ts index 30aa2800..26beb1d2 100644 --- a/src/shared/constants/agentBlocks.ts +++ b/src/shared/constants/agentBlocks.ts @@ -31,3 +31,23 @@ export function createAgentBlockRegex(): RegExp { * Kept for backward compatibility with .replace() calls. */ export const AGENT_BLOCK_REGEX = new RegExp(AGENT_BLOCK_PATTERN, 'g'); + +/** + * Fenced code block marker for reply messages between agents. + * + * Format: + * ```message_reply_for_agent + * Reply on @agent-name original message with text "", here is answer: "" + * ``` + */ +export const MESSAGE_REPLY_TAG = 'message_reply_for_agent'; +export const MESSAGE_REPLY_OPEN = '```' + MESSAGE_REPLY_TAG; +export const MESSAGE_REPLY_CLOSE = '```'; + +/** + * Creates a new RegExp for matching message reply blocks. + * Returns a fresh instance each time to avoid stateful 'g' flag issues with .test(). + */ +export function createMessageReplyBlockRegex(): RegExp { + return new RegExp('```message_reply_for_agent\\n[\\s\\S]*?\\n```', 'g'); +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index f1395544..93a13db8 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -95,11 +95,9 @@ export interface SendMessageResult { export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown'; export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved'; -export type KanbanReviewStatus = 'pending' | 'error'; export interface KanbanTaskState { column: Extract; - reviewStatus?: KanbanReviewStatus; reviewer?: string | null; errorDescription?: string; movedAt: string; @@ -225,6 +223,8 @@ export interface GlobalTask extends TeamTask { teamName: string; teamDisplayName: string; projectPath?: string; + /** Set when task is in team kanban (review or approved column). */ + kanbanColumn?: 'review' | 'approved'; } export interface MemberSubagentSummary { diff --git a/test/main/services/team/MemberStatsComputer.test.ts b/test/main/services/team/MemberStatsComputer.test.ts new file mode 100644 index 00000000..5c62acf7 --- /dev/null +++ b/test/main/services/team/MemberStatsComputer.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { estimateBashLinesChanged } from '../../../../src/main/services/team/MemberStatsComputer'; + +describe('estimateBashLinesChanged', () => { + it('returns zero for simple non-writing commands', () => { + expect(estimateBashLinesChanged('ls -la')).toEqual({ added: 0, removed: 0, files: [] }); + expect(estimateBashLinesChanged('cd /tmp')).toEqual({ added: 0, removed: 0, files: [] }); + expect(estimateBashLinesChanged('git status')).toEqual({ added: 0, removed: 0, files: [] }); + }); + + it('counts lines in heredoc', () => { + const cmd = `cat <<'EOF' > /tmp/test.txt\nline1\nline2\nline3\nEOF`; + const result = estimateBashLinesChanged(cmd); + expect(result.added).toBe(3); + }); + + it('counts lines in heredoc without quotes', () => { + const cmd = `cat < /tmp/test.txt\nfirst\nsecond\nEOF`; + const result = estimateBashLinesChanged(cmd); + expect(result.added).toBe(2); + }); + + it('counts echo redirect with newlines', () => { + const cmd = 'echo "line1\\nline2\\nline3" > /tmp/out.txt'; + const result = estimateBashLinesChanged(cmd); + expect(result.added).toBe(3); + expect(result.files).toContain('/tmp/out.txt'); + }); + + it('counts printf redirect', () => { + const cmd = "printf 'hello\\nworld' > /tmp/out.txt"; + const result = estimateBashLinesChanged(cmd); + expect(result.added).toBe(2); + expect(result.files).toContain('/tmp/out.txt'); + }); + + it('counts sed -i as 1 line changed', () => { + const cmd = "sed -i 's/old/new/g' /tmp/file.txt"; + const result = estimateBashLinesChanged(cmd); + expect(result.added).toBe(1); + expect(result.removed).toBe(1); + expect(result.files).toContain('/tmp/file.txt'); + }); + + it('counts sed with combined flags', () => { + const cmd = "sed -Ei 's/pattern/replacement/' /tmp/file.txt"; + const result = estimateBashLinesChanged(cmd); + expect(result.added).toBe(1); + expect(result.removed).toBe(1); + }); + + it('extracts file from redirect (catch-all)', () => { + const cmd = 'some_command > /tmp/output.log'; + const result = estimateBashLinesChanged(cmd); + expect(result.files).toContain('/tmp/output.log'); + }); + + it('extracts file from tee', () => { + const cmd = 'echo test | tee /tmp/output.txt'; + const result = estimateBashLinesChanged(cmd); + expect(result.files).toContain('/tmp/output.txt'); + }); + + it('extracts file from tee -a (append)', () => { + const cmd = 'echo test | tee -a /tmp/output.txt'; + const result = estimateBashLinesChanged(cmd); + expect(result.files).toContain('/tmp/output.txt'); + }); + + it('handles empty command', () => { + expect(estimateBashLinesChanged('')).toEqual({ added: 0, removed: 0, files: [] }); + }); +}); diff --git a/test/main/services/team/TeamKanbanManager.test.ts b/test/main/services/team/TeamKanbanManager.test.ts index 14f3bd68..ec3a8389 100644 --- a/test/main/services/team/TeamKanbanManager.test.ts +++ b/test/main/services/team/TeamKanbanManager.test.ts @@ -69,11 +69,10 @@ describe('TeamKanbanManager', () => { it('writes review state with movedAt on set_column', async () => { await manager.updateTask('my-team', '12', { op: 'set_column', column: 'review' }); const persisted = JSON.parse(hoisted.files.get(statePath) ?? '{}') as { - tasks?: Record; + tasks?: Record; }; expect(persisted.tasks?.['12']?.column).toBe('review'); - expect(persisted.tasks?.['12']?.reviewStatus).toBe('pending'); expect(typeof persisted.tasks?.['12']?.movedAt).toBe('string'); expect(hoisted.atomicWrite).toHaveBeenCalledTimes(1); });