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:
parent
da25703935
commit
42171e239d
32 changed files with 1057 additions and 354 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)' } : {}),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
194
src/renderer/components/sidebar/TaskFiltersPopover.tsx
Normal file
194
src/renderer/components/sidebar/TaskFiltersPopover.tsx
Normal 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 ?? []);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
22
src/renderer/components/team/activity/ReplyQuoteBlock.tsx
Normal file
22
src/renderer/components/team/activity/ReplyQuoteBlock.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
74
test/main/services/team/MemberStatsComputer.test.ts
Normal file
74
test/main/services/team/MemberStatsComputer.test.ts
Normal 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: [] });
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue