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.
This commit is contained in:
iliya 2026-02-22 22:15:48 +02:00 committed by Илия
parent da25703935
commit 42171e239d
32 changed files with 1057 additions and 354 deletions

View file

@ -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 <<EOF > 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 };
}

View file

@ -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(),
};

View file

@ -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<string, KanbanState>();
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,
};
});
}

View file

@ -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(),
};

View file

@ -442,6 +442,12 @@ export class TeamProvisioningService {
}
async prepareForProvisioning(cwd?: string): Promise<TeamProvisioningPrepareResult> {
// 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<void> {
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<void> {
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<string>
@ -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;
});

View file

@ -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'
)

View file

@ -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]);

View file

@ -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<SidebarTab>('tasks');
const [taskFilters, setTaskFilters] = useState<TaskFiltersState>(defaultTaskFiltersState);
const [taskFiltersPopoverOpen, setTaskFiltersPopoverOpen] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(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 (
<div
ref={sidebarRef}
className="relative flex shrink-0 flex-col border-r"
className="relative flex shrink-0 flex-col overflow-hidden border-r"
style={{
backgroundColor: 'var(--color-surface-sidebar)',
borderColor: 'var(--color-border)',
width: `${width}px`,
width: sidebarCollapsed ? 0 : width,
minWidth: sidebarCollapsed ? 0 : undefined,
borderRightWidth: sidebarCollapsed ? 0 : undefined,
transition: 'width 0.22s ease-out, border-width 0.22s ease-out',
}}
>
{/* Sidebar header with project dropdown */}
<SidebarHeader />
<div
className="flex min-w-0 flex-1 flex-col overflow-hidden"
style={{
width: '100%',
minWidth: sidebarCollapsed ? 0 : width,
}}
>
<SidebarHeader />
{/* Global task list */}
<div className="flex-1 overflow-hidden">
<GlobalTaskList />
{/* Tab bar: Tasks | Sessions */}
<div
className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5"
style={{ borderColor: 'var(--color-border)' }}
>
<div className="flex gap-0.5">
<button
type="button"
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
sidebarTab === 'tasks'
? 'bg-surface-raised text-text'
: 'text-text-muted hover:text-text-secondary'
}`}
onClick={() => setSidebarTab('tasks')}
>
Tasks
</button>
<button
type="button"
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
sidebarTab === 'sessions'
? 'bg-surface-raised text-text'
: 'text-text-muted hover:text-text-secondary'
}`}
onClick={() => setSidebarTab('sessions')}
>
Sessions
</button>
</div>
{sidebarTab === 'tasks' && (
<TaskFiltersPopover
open={taskFiltersPopoverOpen}
onOpenChange={setTaskFiltersPopoverOpen}
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
filters={taskFilters}
onFiltersChange={setTaskFilters}
onApply={() => {}}
/>
)}
</div>
{/* Content: Tasks list or Sessions list */}
<div className="min-w-0 flex-1 overflow-hidden">
{sidebarTab === 'tasks' ? (
<GlobalTaskList
hideHeader
filters={taskFilters}
onFiltersChange={setTaskFilters}
filtersPopoverOpen={taskFiltersPopoverOpen}
onFiltersPopoverOpenChange={setTaskFiltersPopoverOpen}
/>
) : (
<DateGroupedSessions />
)}
</div>
</div>
{/* Resize handle */}
<button
type="button"
aria-label="Resize sidebar"
className={`absolute right-0 top-0 h-full w-1 cursor-col-resize border-0 bg-transparent p-0 transition-colors hover:bg-blue-500/50 ${
isResizing ? 'bg-blue-500/50' : ''
}`}
onMouseDown={handleResizeStart}
/>
{/* Resize handle - only interactive when expanded */}
{!sidebarCollapsed && (
<button
type="button"
aria-label="Resize sidebar"
className={`absolute right-0 top-0 h-full w-1 cursor-col-resize border-0 bg-transparent p-0 transition-colors hover:bg-blue-500/50 ${
isResizing ? 'bg-blue-500/50' : ''
}`}
onMouseDown={handleResizeStart}
/>
)}
</div>
);
};

View file

@ -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<ProjectDropdownItemProps>): 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 (
<button
onClick={onSelect}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors"
style={buttonStyle}
>
<div className="min-w-0 flex-1">
<span
className={`block truncate text-sm ${isSelected ? 'font-medium' : ''}`}
style={{ color: isSelected ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{name}
</span>
{path && (
<span className="block truncate text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
{path}
</span>
)}
</div>
<span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
{sessionCount}
</span>
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
</button>
);
};
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<HTMLDivElement>(null);
const projectDropdownRef = useRef<HTMLDivElement>(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 */}
<div
ref={projectDropdownRef}
className="relative flex select-none items-center gap-2 pr-2"
className="flex select-none items-center gap-2 pr-2"
style={
{
height: `${HEADER_ROW1_HEIGHT}px`,
paddingLeft: isMacElectron ? 'var(--macos-traffic-light-padding-left, 72px)' : '16px',
paddingLeft: isMacElectron ? 'var(--macos-traffic-light-padding-left, 72px)' : 0,
WebkitAppRegion: isMacElectron ? 'drag' : undefined,
} as React.CSSProperties
}
>
{/* App logo + Project name dropdown button */}
<AppLogo size={22} className="shrink-0" />
<button
onClick={() => setIsProjectDropdownOpen(!isProjectDropdownOpen)}
className="flex min-w-0 items-center gap-2 transition-opacity hover:opacity-80"
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<AppLogo size={22} className="shrink-0" />
</div>
<div
className="min-w-0 flex-1"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<span
className="min-w-0 truncate text-sm font-bold tracking-tight"
style={{ color: hasSelection ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{projectName}
</span>
<ChevronDown
className={`size-3.5 shrink-0 transition-transform ${isProjectDropdownOpen ? 'rotate-180' : ''}`}
style={{ color: 'var(--color-text-muted)' }}
<Combobox
options={projectComboboxOptions}
value={activeProjectValue ?? ''}
onValueChange={handleProjectValueChange}
placeholder="Select Project"
searchPlaceholder="Search..."
emptyMessage={
projectItems.length === 0
? `No ${viewMode === 'grouped' ? 'repositories' : 'projects'} found`
: 'Nothing found'
}
className="text-sm font-medium"
renderOption={(option, isSelected) => {
const sessionCount = (option.meta?.sessionCount as number) ?? 0;
const path = option.meta?.path as string | undefined;
return (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'text-indigo-400 opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p
className={cn(
'truncate',
isSelected
? 'font-medium text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
>
{option.label}
</p>
{path ? (
<p className="truncate text-[10px] text-[var(--color-text-muted)]">{path}</p>
) : null}
</div>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{sessionCount}
</span>
</>
);
}}
/>
</button>
{/* Collapse sidebar button */}
</div>
<button
onClick={toggleSidebar}
onMouseEnter={() => setIsCollapseHovered(true)}
onMouseLeave={() => setIsCollapseHovered(false)}
className="ml-auto shrink-0 rounded-md p-1.5 transition-colors"
className="shrink-0 rounded-md p-1.5 transition-colors"
style={
{
WebkitAppRegion: 'no-drag',
@ -375,70 +329,6 @@ export const SidebarHeader = (): React.JSX.Element => {
>
<PanelLeft className="size-4" />
</button>
{/* Project Dropdown */}
{isProjectDropdownOpen && (
<>
<div
role="presentation"
className="fixed inset-0 z-10"
onClick={() => setIsProjectDropdownOpen(false)}
/>
<div
className="absolute inset-x-4 top-full z-20 mt-1 max-h-[350px] overflow-y-auto rounded-lg py-1 shadow-xl"
style={{
backgroundColor: 'var(--color-surface-sidebar)',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'var(--color-border)',
}}
>
<div
className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
Switch {viewMode === 'grouped' ? 'Repository' : 'Project'}
</div>
{projectItems.length === 0 ? (
<div className="p-3 text-sm" style={{ color: 'var(--color-text-muted)' }}>
No {viewMode === 'grouped' ? 'repositories' : 'projects'} found
</div>
) : (
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 (
<ProjectDropdownItem
key={item.id}
name={item.name}
path={itemPath}
sessionCount={itemSessions}
isSelected={isSelected}
onSelect={() =>
viewMode === 'grouped'
? handleSelectRepo(item.id)
: handleSelectProject(item.id)
}
/>
);
})
)}
</div>
</>
)}
</div>
{/* ROW 2: Worktree Selector (Full Width) */}

View file

@ -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<string, string> = {
'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<StatusFilter>('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<HTMLInputElement>(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 (
<div className="flex h-full flex-col">
{/* Header + Filter bar */}
<div
className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
style={{ borderColor: 'var(--color-border)' }}
>
<span className="text-[12px] font-semibold text-text-secondary">Tasks</span>
<div className="flex gap-1">
{filterButtons.map((btn) => (
<button
key={btn.value}
type="button"
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
filter === btn.value
? 'bg-surface-raised text-text'
: 'text-text-muted hover:text-text-secondary'
}`}
onClick={() => setFilter(btn.value)}
>
{btn.label}
</button>
))}
<div className="flex size-full min-w-0 flex-col">
{!hideHeader && (
<div
className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5"
style={{ borderColor: 'var(--color-border)' }}
>
<span className="text-[12px] font-semibold text-text-secondary">Tasks</span>
<TaskFiltersPopover
open={filtersPopoverOpen}
onOpenChange={setFiltersPopoverOpen}
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
filters={filters}
onFiltersChange={setFilters}
onApply={() => {}}
/>
</div>
</div>
)}
{/* Search bar */}
<div
className="flex shrink-0 items-center gap-1.5 border-b px-3 py-1"
className="flex shrink-0 items-center gap-1.5 border-b px-2 py-1"
style={{ borderColor: 'var(--color-border)' }}
>
<Search className="size-3 shrink-0 text-text-muted" />

View file

@ -246,7 +246,7 @@ export const SessionItem = ({
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
className={`h-[48px] w-full overflow-hidden border-b px-3 py-2 text-left transition-all duration-150 ${isActive ? '' : 'bg-transparent hover:opacity-80'} `}
className={`h-[48px] w-full overflow-hidden border-b px-3 py-2 text-left transition-colors ${isActive ? '' : 'bg-transparent hover:bg-surface-raised'}`}
style={{
borderColor: 'var(--color-border)',
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),

View file

@ -37,11 +37,17 @@ export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Eleme
return (
<button
type="button"
className="flex h-[48px] w-full cursor-pointer flex-col justify-center px-3 text-left transition-colors hover:bg-surface-raised"
className="flex h-[48px] w-full cursor-pointer flex-col justify-center border-b px-3 py-2 text-left transition-colors hover:bg-surface-raised"
style={{ borderColor: 'var(--color-border)' }}
onClick={() => openTeamTab(task.teamName, undefined, task.id)}
>
<div className="flex w-full items-center gap-1.5 overflow-hidden">
<span className="truncate text-[13px] leading-tight text-text">{task.subject}</span>
<span
className="truncate text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
{task.subject}
</span>
{unreadCount > 0 && (
<span
className="size-1.5 shrink-0 rounded-full bg-blue-400"
@ -50,7 +56,10 @@ export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Eleme
)}
<StatusIcon className={`size-3 shrink-0 ${cfg.color}`} />
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] leading-tight text-text-muted">
<div
className="mt-0.5 flex items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
<span>{task.owner ?? 'unassigned'}</span>
<span className="opacity-40">·</span>
<span className="truncate">{task.teamDisplayName}</span>

View file

@ -0,0 +1,194 @@
import { useSyncExternalStore } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Combobox } from '@renderer/components/ui/combobox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage';
import { Filter } from 'lucide-react';
export type TaskStatusFilterId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
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 interface TaskFiltersState {
statusIds: Set<TaskStatusFilterId>;
teamName: string | null;
unreadOnly: boolean;
}
interface TaskFiltersPopoverProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teams: { teamName: string; displayName: string }[];
filters: TaskFiltersState;
onFiltersChange: (f: TaskFiltersState) => void;
onApply: () => void;
}
export const TaskFiltersPopover = ({
open,
onOpenChange,
teams,
filters,
onFiltersChange,
onApply,
}: TaskFiltersPopoverProps): React.JSX.Element => {
const allSelected =
STATUS_OPTIONS.length > 0 && STATUS_OPTIONS.every((opt) => filters.statusIds.has(opt.id));
const handleSelectAll = (): void => {
if (allSelected) {
onFiltersChange({ ...filters, statusIds: new Set() });
} else {
onFiltersChange({
...filters,
statusIds: new Set(STATUS_OPTIONS.map((o) => o.id)),
});
}
};
const toggleStatus = (id: TaskStatusFilterId): void => {
const next = new Set(filters.statusIds);
if (next.has(id)) next.delete(id);
else next.add(id);
onFiltersChange({ ...filters, statusIds: next });
};
const handleApply = (): void => {
onApply();
onOpenChange(false);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-1 rounded px-2 py-0.5 text-[11px] font-medium text-text-muted transition-colors hover:text-text-secondary data-[state=open]:bg-surface-raised data-[state=open]:text-text"
>
<Filter className="size-3" />
Filters
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="end" sideOffset={6}>
<div className="space-y-3">
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-secondary">Status</span>
<button
type="button"
className="text-[10px] text-text-muted hover:text-text-secondary"
onClick={handleSelectAll}
>
{allSelected ? 'Clear all' : 'Select all'}
</button>
</div>
<div className="flex flex-col gap-1.5">
{STATUS_OPTIONS.map((opt) => (
<label
key={opt.id}
className="flex cursor-pointer items-center gap-2 text-[12px] text-text"
>
<Checkbox
checked={filters.statusIds.has(opt.id)}
onCheckedChange={() => toggleStatus(opt.id)}
/>
{opt.label}
</label>
))}
</div>
</div>
<div>
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">Team</span>
<Combobox
options={[
{ value: '__all__', label: 'All teams' },
...teams.map((t) => ({ 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]"
/>
</div>
<label className="flex cursor-pointer items-center gap-2 text-[12px] text-text">
<Checkbox
checked={filters.unreadOnly}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, unreadOnly: checked === true })
}
/>
Tasks with unread comments
</label>
<Button
type="button"
variant="default"
size="sm"
className="w-full"
onClick={handleApply}
>
Apply
</Button>
</div>
</PopoverContent>
</Popover>
);
};
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<TaskStatusFilterId>
): 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<typeof getSnapshot> {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export function getTaskUnreadCount(
readState: ReturnType<typeof getSnapshot>,
teamName: string,
taskId: string,
comments: { createdAt: string }[] | undefined
): number {
return getUnreadCount(readState, teamName, taskId, comments ?? []);
}

View file

@ -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<string | undefined>(undefined);
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
undefined
);
// Session loading and filtering state
const [sessions, setSessions] = useState<Session[]>([]);
@ -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);
}}
/>
</CollapsibleTeamSection>
@ -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);
}}
/>
<TaskDetailDialog

View file

@ -7,6 +7,7 @@ import { Input } from '@renderer/components/ui/input';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils';
import { Copy, FolderOpen, Search, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -38,9 +39,7 @@ function getRecentProjects(team: TeamSummary): string[] {
}
function folderName(fullPath: string): string {
// eslint-disable-next-line sonarjs/slow-regex -- Anchored regex on short path strings, no backtracking risk
const parts = fullPath.replace(/\/+$/, '').split('/');
return parts[parts.length - 1] || fullPath;
return getBaseName(fullPath) || fullPath;
}
function resolveTeamStatus(

View file

@ -11,11 +11,14 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import {
getMessageTypeLabel,
getStructuredMessageSummary,
parseMessageReply,
parseStructuredAgentMessage,
} from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
import { Bot, ChevronRight, ListPlus, MessageSquare } from 'lucide-react';
import { Bot, ChevronRight, ListPlus, MessageSquare, Reply } from 'lucide-react';
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
import type { TeamColorSet } from '@renderer/constants/teamColors';
import type { InboxMessage } from '@shared/types';
@ -27,6 +30,7 @@ interface ActivityItemProps {
memberRole?: string;
memberColor?: string;
onCreateTask?: (subject: string, description: string) => 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 <NoiseRow name={message.from} label={noiseLabel} colors={colors} />;
@ -255,8 +266,22 @@ export const ActivityItem = ({
{summaryText}
</span>
{/* Timestamp + create task */}
{/* Timestamp + reply + create task */}
<div className="flex shrink-0 items-center gap-1.5">
{onReply && (
<button
type="button"
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
title="Reply to message"
onClick={(e) => {
e.stopPropagation();
onReply(message);
}}
>
<Reply size={14} />
</button>
)}
{onCreateTask && (
<button
type="button"
@ -294,6 +319,8 @@ export const ActivityItem = ({
</pre>
</details>
</div>
) : parsedReply ? (
<ReplyQuoteBlock reply={parsedReply} />
) : (
<MarkdownViewer content={displayText ?? message.text} maxHeight="max-h-56" copyable />
)}

View file

@ -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<string, { role?: string; color?: string }>();
if (members) {
@ -43,6 +45,7 @@ export const ActivityTimeline = ({
memberRole={info?.role}
memberColor={info?.color}
onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage}
/>
);
})}

View file

@ -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 => (
<div className="space-y-2">
<div
className="rounded-md border-l-2 border-[var(--color-border-emphasis)] bg-[var(--color-surface)] px-3 py-2"
style={{ opacity: 0.7 }}
>
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]">
@{reply.agentName}
</span>
<p className="line-clamp-3 text-xs text-[var(--color-text-muted)]">{reply.originalText}</p>
</div>
<MarkdownViewer content={reply.replyText} maxHeight="max-h-56" copyable />
</div>
);

View file

@ -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<QuotedMessage | undefined>(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 = ({
/>
</div>
{quote ? (
<div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
<button
type="button"
className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => setQuote(undefined)}
>
<X size={12} />
</button>
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to @{quote.from}
</span>
<p className="line-clamp-3 pr-5 text-xs text-[var(--color-text-muted)]">
{quote.text}
</p>
</div>
) : null}
<div className="grid gap-2">
<Label htmlFor="smd-message">Message</Label>
<MentionableTextarea

View file

@ -1,13 +1,16 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
import { useStore } from '@renderer/store';
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { formatDistanceToNow } from 'date-fns';
import { MessageSquare, Send } from 'lucide-react';
import { MessageSquare, Reply, Send, X } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
@ -31,6 +34,8 @@ export const TaskCommentsSection = ({
const addingComment = useStore((s) => 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<MentionSuggestion[]>(
@ -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 (
<div ref={commentsRef}>
@ -75,7 +82,7 @@ export const TaskCommentsSection = ({
{comments.map((comment) => (
<div
key={comment.id}
className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5"
className="group rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5"
>
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<span
@ -90,20 +97,79 @@ export const TaskCommentsSection = ({
>
{comment.author}
</span>
<span>{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}</span>
<span>
{(() => {
const date = new Date(comment.createdAt);
return isNaN(date.getTime())
? 'unknown time'
: formatDistanceToNow(date, { addSuffix: true });
})()}
</span>
<button
type="button"
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
onClick={() =>
setReplyTo({
author: comment.author,
text: parseMessageReply(comment.text)?.replyText ?? comment.text,
})
}
>
<Reply size={11} />
Reply
</button>
</div>
<div className="text-xs">
<MarkdownViewer content={comment.text} maxHeight="max-h-[120px]" />
{(() => {
const reply = parseMessageReply(comment.text);
return reply ? (
<ReplyQuoteBlock reply={reply} />
) : (
<MarkdownViewer content={comment.text} maxHeight="max-h-[120px]" />
);
})()}
</div>
</div>
))}
</div>
) : null}
<div className="space-y-1.5">
{replyTo ? (
<div className="mb-2 flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2">
<div className="min-w-0 flex-1">
<div className="mb-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to{' '}
<span
className="font-semibold"
style={{
color:
replyTo.author === 'user'
? 'var(--color-text-secondary)'
: (members.find((m) => m.name === replyTo.author)?.color ??
'var(--color-text-secondary)'),
}}
>
@{replyTo.author}
</span>
</div>
<div className="line-clamp-3 text-[11px] text-[var(--color-text-muted)]">
{replyTo.text}
</div>
</div>
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => setReplyTo(null)}
>
<X size={12} />
</button>
</div>
) : null}
<div className="relative">
<MentionableTextarea
id={`task-comment-${taskId}`}
placeholder="Add a comment... (Cmd+Enter to send)"
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
value={draft.value}
onValueChange={draft.setValue}
suggestions={mentionSuggestions}
@ -111,6 +177,17 @@ export const TaskCommentsSection = ({
maxRows={8}
maxLength={MAX_COMMENT_LENGTH}
disabled={addingComment}
cornerAction={
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canSubmit}
onClick={() => void handleSubmit()}
>
<Send size={12} />
Comment
</button>
}
footerRight={
<div className="flex items-center gap-2">
{remaining < 200 ? (
@ -126,17 +203,6 @@ export const TaskCommentsSection = ({
</div>
}
/>
<div className="flex justify-end">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md bg-blue-600 px-2.5 py-1 text-[11px] font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canSubmit}
onClick={() => void handleSubmit()}
>
<Send size={12} />
Comment
</button>
</div>
</div>
</div>
);

View file

@ -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'}
</span>
</div>
{currentTask.createdAt ? (
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
<Clock size={12} />
<span className="text-[var(--color-text-secondary)]">
{formatDistanceToNow(new Date(currentTask.createdAt), { addSuffix: true })}
</span>
</div>
) : null}
{currentTask.createdAt
? (() => {
const date = new Date(currentTask.createdAt);
return isNaN(date.getTime()) ? null : (
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
<Clock size={12} />
<span className="text-[var(--color-text-secondary)]">
{formatDistanceToNow(date, { addSuffix: true })}
</span>
</div>
);
})()
: null}
</div>
{/* Description */}
@ -178,7 +182,6 @@ export const TaskDetailDialog = ({
{/* Review info */}
{kanbanTaskState ? (
<div className="flex items-center gap-2">
<ReviewBadge status={kanbanTaskState.reviewStatus} />
{kanbanTaskState.reviewer ? (
<span className="text-xs text-[var(--color-text-secondary)]">
Reviewer: {kanbanTaskState.reviewer}

View file

@ -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 = ({
</div>
<h5 className="text-sm font-medium text-[var(--color-text)]">{task.subject}</h5>
</div>
{columnId === 'review' ? <ReviewBadge status={kanbanTaskState?.reviewStatus} /> : null}
</div>
<p className="mb-2 text-xs text-[var(--color-text-muted)]">Owner: {task.owner ?? '\u2014'}</p>

View file

@ -1,17 +0,0 @@
interface ReviewBadgeProps {
status?: 'pending' | 'error';
}
export const ReviewBadge = ({ status = 'pending' }: ReviewBadgeProps): React.JSX.Element => {
const isError = status === 'error';
return (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${
isError ? 'bg-red-500/15 text-red-300' : 'bg-yellow-500/15 text-yellow-300'
}`}
>
{isError ? 'Review: Error' : 'Review: Pending'}
</span>
);
};

View file

@ -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 => (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2">
<p className="text-lg font-semibold text-[var(--color-text)]">{value}</p>
<p className="text-[11px] text-[var(--color-text-muted)]">{label}</p>
<div className="flex items-center gap-1">
<p className="text-[11px] text-[var(--color-text-muted)]">{label}</p>
{info && (
<span className="group relative">
<Info
size={10}
className="cursor-help text-[var(--color-text-muted)] opacity-50 hover:opacity-80"
/>
<span className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 w-52 -translate-x-1/2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] px-2.5 py-2 text-[10px] leading-relaxed text-[var(--color-text-secondary)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
{info}
</span>
</span>
)}
</div>
{sub && <p className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">{sub}</p>}
</div>
);
@ -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."
/>
<StatCard label="Files" value={stats.filesTouched.length} />
<StatCard label="Tool Calls" value={totalToolCalls} />

View file

@ -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<HTMLTextAreaElement, MentionableTextareaProps>(
@ -125,7 +128,9 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
hintText = 'Use @ to mention team members',
showHint = true,
footerRight,
cornerAction,
style,
className,
...textareaProps
},
forwardedRef
@ -201,7 +206,10 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
{hasMentionOverlay ? (
<div
ref={backdropRef}
className="pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-md border border-transparent px-3 py-2 text-sm text-[var(--color-text)]"
className={cn(
'pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-md border border-transparent px-3 py-2 text-sm text-[var(--color-text)]',
cornerAction && 'pb-12 pr-[4.25rem]'
)}
style={{
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
@ -231,8 +239,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
{seg.value}
</span>
);
})}
{/* Trailing space ensures trailing newlines render correctly */}{' '}
})}{' '}
</div>
) : null}
@ -243,9 +250,15 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
onKeyDown={handleKeyDown}
onSelect={handleSelect}
{...textareaProps}
className={cn(className, cornerAction && 'pb-12 pr-[4.25rem]')}
onScroll={handleScroll}
style={textareaStyle}
/>
{cornerAction ? (
<div className="pointer-events-none absolute bottom-2 right-2 z-20 flex items-end justify-end">
<div className="pointer-events-auto">{cornerAction}</div>
</div>
) : null}
</div>
{showFooter ? (

View file

@ -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<string, unknown>;
}
interface ComboboxProps {

View file

@ -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

View file

@ -1,5 +1,53 @@
import { MESSAGE_REPLY_TAG } from '@shared/constants/agentBlocks';
type StructuredAgentMessage = Record<string, unknown>;
// ---------------------------------------------------------------------------
// 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',

View file

@ -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 "<original>", here is answer: "<reply>"
* ```
*/
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');
}

View file

@ -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<KanbanColumnId, 'review' | 'approved'>;
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 {

View file

@ -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 <<EOF > /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: [] });
});
});

View file

@ -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<string, { column: string; movedAt: string; reviewStatus?: string }>;
tasks?: Record<string, { column: string; movedAt: string }>;
};
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);
});