refactor: update README and enhance team UI components

- Changed image format for agent comments screenshot in README.
- Improved table of contents in README for better navigation.
- Refactored imports in IPC config and discovery services for consistency.
- Added shimmer effect for waiting members in CSS.
- Enhanced dashboard view to display active teams with online indicators.
- Updated team provisioning components to support message severity.
- Improved task detail dialog layout for related tasks and dependencies.
- Adjusted team model selector default value and refined member status handling.
- Fixed minor styling issues in messages panel and tab bar.

These changes aim to improve user experience and maintainability across the application.
This commit is contained in:
iliya 2026-03-23 12:58:38 +02:00
parent 2eac440fe2
commit 7b13a4c398
21 changed files with 331 additions and 125 deletions

View file

@ -5,7 +5,7 @@
<a href="docs/screenshots/8.png"><img src="docs/screenshots/8.png" width="75" alt="Task Detail" /></a>&nbsp;
<img src="resources/icons/png/1024x1024.png" alt="Claude Agent Teams UI" width="80" />&nbsp;
<a href="docs/screenshots/9.png"><img src="docs/screenshots/9.png" width="75" alt="Execution Logs" /></a>&nbsp;
<a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.jpg" width="75" alt="Agent Comments" /></a>&nbsp;
<a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.png" width="75" alt="Agent Comments" /></a>&nbsp;
<a href="docs/screenshots/4.png"><img src="docs/screenshots/4.png" width="75" alt="Create Team" /></a>&nbsp;
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
</p>
@ -76,14 +76,20 @@ No prerequisites — Claude Code can be installed and configured directly from t
## Table of contents
- [Installation](#installation)
- [Table of contents](#table-of-contents)
- [What is this](#what-is-this)
- [Comparison](#comparison)
- [Quick start](#quick-start)
- [FAQ](#faq)
- [Roadmap](#roadmap)
- [Development](#development)
- [Tech stack](#tech-stack)
- [Build for distribution](#build-for-distribution)
- [Scripts](#scripts)
- [Roadmap](#roadmap)
- [Contributing](#contributing)
- [Security](#security)
- [License](#license)
## What is this
@ -149,7 +155,7 @@ A new approach to task management with AI agent teams.
| **Hunk-level review** | ✅ Accept / reject individual hunks | ❌ | ❌ | ✅ | ❌ |
| **Built-in code editor** | ✅ With Git support | ❌ | ❌ | ✅ Full IDE | ❌ |
| **Full autonomy** | ✅ Agents create, assign, review tasks end-to-end | ❌ Human manages tasks | ❌ Fixed pipeline | ⚠️ Isolated tasks only | ✅⚠️ (no UI) |
| **Task dependencies (blocked by)** | ✅ Guaranteed ordering | ❌ | ⚠️ Within plan only | ❌ | ✅⚠️ (no UI) |
| **Task dependencies (blocked by)** | ✅ Guaranteed ordering | ❌ | ⚠️ Within plan only | ❌ | ✅⚠️ (no UI, no notifications) |
| **Review workflow** | ✅ Agents review each other | ❌ | ⚠️ Auto QA pipeline | ❌ | ✅⚠️ (no UI) |
| **Zero setup** | ✅ | ❌ Config required | ❌ Config required | ✅ | ⚠️ CLI install required |
| **Kanban board** | ✅ 5 columns, real-time | ✅ | ✅ 6 columns (pipeline) | ❌ | ❌ |

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

View file

@ -17,8 +17,8 @@
* - config:testTrigger: Test a trigger against historical session data
*/
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
import { syncTelemetryFlag } from '@main/sentry';
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { execFile } from 'child_process';

View file

@ -40,6 +40,7 @@ import { calculateMetrics } from '@main/utils/jsonl';
import { createLogger } from '@shared/utils/logger';
import { startMainSpan } from '../../sentry';
import type { WaterfallData, WaterfallItem } from '@shared/types';
const logger = createLogger('Service:ChunkBuilder');

View file

@ -15,14 +15,14 @@ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFile
import { parseJsonlFile } from '@main/utils/jsonl';
import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { startMainSpan } from '../../sentry';
import {
extractMarkdownPlainText,
findMarkdownSearchMatches,
} from '@shared/utils/markdownTextSearch';
import * as path from 'path';
import { startMainSpan } from '../../sentry';
import { SearchTextCache } from './SearchTextCache';
import { extractSearchableEntries } from './SearchTextExtractor';
import { subprojectRegistry } from './SubprojectRegistry';

View file

@ -27,12 +27,22 @@ import { useShallow } from 'zustand/react/shallow';
const logger = createLogger('Component:DashboardView');
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { formatDistanceToNow } from 'date-fns';
import { Command, FolderGit2, FolderOpen, GitBranch, GitFork, Search, Users } from 'lucide-react';
import {
Command,
FolderGit2,
FolderOpen,
GitBranch,
GitFork,
Search,
Terminal,
Users,
} from 'lucide-react';
import { CliStatusBanner } from './CliStatusBanner';
import { DashboardUpdateBanner } from './DashboardUpdateBanner';
import type { RepositoryGroup } from '@renderer/types/data';
import type { TeamSummary } from '@shared/types';
// =============================================================================
// Command Search Input
@ -134,6 +144,7 @@ interface RepositoryCardProps {
isHighlighted?: boolean;
taskCounts?: TaskStatusCounts;
tasksLoading?: boolean;
activeTeams?: TeamSummary[];
}
const RepositoryCard = ({
@ -142,6 +153,7 @@ const RepositoryCard = ({
isHighlighted,
taskCounts,
tasksLoading,
activeTeams,
}: Readonly<RepositoryCardProps>): React.JSX.Element => {
const lastActivity = repo.mostRecentSession
? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true })
@ -223,6 +235,14 @@ const RepositoryCard = ({
boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined,
}}
>
{/* Online indicator — top-right corner */}
{activeTeams && activeTeams.length > 0 && (
<span className="absolute right-3 top-3 inline-flex size-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-2.5 rounded-full bg-emerald-500" />
</span>
)}
{/* Icon + Project name */}
<div className="mb-1 flex items-center gap-2.5">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
@ -368,6 +388,21 @@ const RepositoryCard = ({
);
})()
)}
{/* Active teams running in this project */}
{activeTeams && activeTeams.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-border pt-2">
<Terminal className="size-3 shrink-0 text-emerald-400" />
{activeTeams.map((t) => (
<span
key={t.teamName}
className="inline-flex items-center rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-medium text-emerald-400"
>
{t.displayName}
</span>
))}
</div>
)}
</button>
);
};
@ -518,6 +553,7 @@ const ProjectsGrid = ({
globalTasksLoading,
fetchAllTasks,
openTeamsTab,
teams,
} = useStore(
useShallow((s) => ({
repositoryGroups: s.repositoryGroups,
@ -529,11 +565,13 @@ const ProjectsGrid = ({
globalTasksLoading: s.globalTasksLoading,
fetchAllTasks: s.fetchAllTasks,
openTeamsTab: s.openTeamsTab,
teams: s.teams,
}))
);
const hasFetchedTasksRef = React.useRef(false);
const [visibleProjects, setVisibleProjects] = useState(maxProjects);
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
useEffect(() => {
if (repositoryGroups.length === 0 && !repositoryGroupsLoading && !repositoryGroupsError) {
@ -553,6 +591,37 @@ const ProjectsGrid = ({
}
}, [repositoryGroups.length, repositoryGroupsLoading, fetchAllTasks]);
// Fetch alive teams for online indicators
useEffect(() => {
let cancelled = false;
void api.teams
.aliveList()
.then((list) => {
if (!cancelled) setAliveTeams(list);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [teams]);
// Map: normalizedProjectPath → alive TeamSummary[]
const activeTeamsByProject = useMemo(() => {
const aliveSet = new Set(aliveTeams);
const map = new Map<string, TeamSummary[]>();
for (const team of teams) {
if (!aliveSet.has(team.teamName) || !team.projectPath) continue;
const key = normalizePath(team.projectPath);
const arr = map.get(key);
if (arr) {
arr.push(team);
} else {
map.set(key, [team]);
}
}
return map;
}, [teams, aliveTeams]);
useEffect(() => {
if (!searchQuery.trim()) {
setVisibleProjects(maxProjects);
@ -699,6 +768,20 @@ const ProjectsGrid = ({
},
{ pending: 0, inProgress: 0, completed: 0 }
);
// Collect active teams for this project (deduplicated by teamName)
const seen = new Set<string>();
const repoActiveTeams: TeamSummary[] = [];
for (const wt of repo.worktrees) {
const matched = activeTeamsByProject.get(normalizePath(wt.path));
if (matched) {
for (const t of matched) {
if (!seen.has(t.teamName)) {
seen.add(t.teamName);
repoActiveTeams.push(t);
}
}
}
}
return (
<RepositoryCard
key={repo.id}
@ -710,6 +793,7 @@ const ProjectsGrid = ({
isHighlighted={!!searchQuery.trim()}
taskCounts={globalTasksLoading ? undefined : counts}
tasksLoading={globalTasksLoading}
activeTeams={repoActiveTeams.length > 0 ? repoActiveTeams : undefined}
/>
);
})}

View file

@ -35,7 +35,7 @@ export const TabBarRow = (): React.JSX.Element => {
style={
{
height: `${HEADER_ROW1_HEIGHT}px`,
backgroundColor: 'var(--color-surface)',
backgroundColor: 'var(--color-surface-sidebar)',
borderBottom: '1px solid var(--color-border)',
WebkitAppRegion: isMacElectron ? 'drag' : undefined,
} as React.CSSProperties

View file

@ -23,6 +23,8 @@ export interface ProvisioningProgressBlockProps {
title: string;
/** Optional status message */
message?: string | null;
/** Visual severity for the message subtitle */
messageSeverity?: 'error' | 'warning';
/** Visual tone (e.g. highlight errors) */
tone?: 'default' | 'error';
/** Whether Live output is expanded by default */
@ -118,6 +120,7 @@ function sanitizeAssistantOutput(raw?: string, isError = false): string | null {
export const ProvisioningProgressBlock = ({
title,
message,
messageSeverity,
tone = 'default',
defaultLiveOutputOpen = true,
currentStepIndex,
@ -218,7 +221,11 @@ export const ProvisioningProgressBlock = ({
<p
className={cn(
'mt-1.5 text-xs',
isError ? 'text-[var(--step-error-text)]' : 'text-[var(--color-text-muted)]'
isError || messageSeverity === 'error'
? 'text-red-400'
: messageSeverity === 'warning'
? 'text-amber-400'
: 'text-[var(--color-text-muted)]'
)}
>
{message}

View file

@ -111,24 +111,47 @@ function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): Reac
);
}
function renderTeamRecentPaths(team: TeamSummary, status: TeamStatus): React.JSX.Element | null {
function renderTeamRecentPaths(
team: TeamSummary,
status: TeamStatus,
matchesCurrentProject: boolean,
isLight: boolean
): React.JSX.Element | null {
const recentPaths = getRecentProjects(team);
if (recentPaths.length === 0) return null;
return (
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<FolderOpen size={10} className="shrink-0" />
<span className="truncate">
{recentPaths.map((p, i) => (
<span key={p} title={p}>
{i === 0 && (status === 'active' || status === 'idle') ? (
<span className="text-emerald-400">{folderName(p)}</span>
) : (
folderName(p)
)}
{i < recentPaths.length - 1 ? ', ' : ''}
{matchesCurrentProject ? (
<span
className={`inline-flex items-center gap-1 truncate rounded-full px-2 py-0.5 text-[12px] font-medium ${
isLight ? 'bg-emerald-100 text-emerald-700' : 'bg-emerald-500/15 text-emerald-400'
}`}
>
<FolderOpen size={12} className="shrink-0" />
{recentPaths.map((p, i) => (
<span key={p} title={p}>
{folderName(p)}
{i < recentPaths.length - 1 ? ', ' : ''}
</span>
))}
</span>
) : (
<>
<FolderOpen size={10} className="shrink-0" />
<span className="truncate">
{recentPaths.map((p, i) => (
<span key={p} title={p}>
{i === 0 && (status === 'active' || status === 'idle') ? (
<span className="text-emerald-400">{folderName(p)}</span>
) : (
folderName(p)
)}
{i < recentPaths.length - 1 ? ', ' : ''}
</span>
))}
</span>
))}
</span>
</>
)}
</div>
);
}
@ -771,11 +794,7 @@ export const TeamListView = (): React.JSX.Element => {
key={team.teamName}
role="button"
tabIndex={0}
className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
matchesCurrentProject
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
: 'border-[var(--color-border)]'
}`}
className="group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
style={
teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
@ -959,7 +978,7 @@ export const TeamListView = (): React.JSX.Element => {
</div>
);
})()}
{renderTeamRecentPaths(team, status)}
{renderTeamRecentPaths(team, status, matchesCurrentProject, isLight)}
</div>
</div>
</div>

View file

@ -130,6 +130,7 @@ export const TeamProvisioningBanner = ({
key={progress.runId}
title="Launch details"
message={progress.message}
messageSeverity={progress.messageSeverity}
currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
startedAt={progress.startedAt}
pid={progress.pid}
@ -149,6 +150,7 @@ export const TeamProvisioningBanner = ({
key={progress.runId}
title="Launching team"
message={progress.message}
messageSeverity={progress.messageSeverity}
currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
loading
startedAt={progress.startedAt}

View file

@ -123,7 +123,7 @@ export const ToolApprovalDiffPreview: React.FC<ToolApprovalDiffPreviewProps> = (
<div className="mt-2">
{diff.loading && (
<div
className="flex items-center gap-2 rounded-md border px-3 py-3 text-xs"
className="flex items-center gap-2 rounded-md border p-3 text-xs"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',

View file

@ -750,77 +750,77 @@ export const TaskDetailDialog = ({
</div>
) : null}
{/* Related tasks (explicit) */}
{relatedIds.length > 0 || relatedByIds.length > 0 ? (
{/* Related tasks & Dependencies — 2-column grid */}
{(relatedIds.length > 0 ||
relatedByIds.length > 0 ||
blockedByIds.length > 0 ||
blocksIds.length > 0) && (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
<Link2 size={12} />
Related tasks
</div>
{relatedIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[var(--color-text-muted)]">Links</span>
{relatedIds.map((id) => {
const depTask = taskMap.get(id);
const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`;
return (
<Tooltip key={`related:${currentTask.id}:${id}`}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
})}
{/* "Related tasks" header — only if links exist */}
{(relatedIds.length > 0 || relatedByIds.length > 0) && (
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
<Link2 size={12} />
Related tasks
</div>
) : null}
)}
{relatedByIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[var(--color-text-muted)]">Linked from</span>
{relatedByIds.map((id) => {
const depTask = taskMap.get(id);
const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`;
return (
<Tooltip key={`related-by:${currentTask.id}:${id}`}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
})}
</div>
) : null}
</div>
) : null}
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
{relatedIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[var(--color-text-muted)]">Links</span>
{relatedIds.map((id) => {
const depTask = taskMap.get(id);
const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`;
return (
<Tooltip key={`related:${currentTask.id}:${id}`}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
})}
</div>
) : null}
{relatedByIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[var(--color-text-muted)]">Linked from</span>
{relatedByIds.map((id) => {
const depTask = taskMap.get(id);
const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`;
return (
<Tooltip key={`related-by:${currentTask.id}:${id}`}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
})}
</div>
) : null}
{/* Sections container with uniform spacing */}
<div className="min-w-0 space-y-1">
{/* Dependencies */}
{blockedByIds.length > 0 || blocksIds.length > 0 ? (
<div className="space-y-1">
{blockedByIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-0.5 text-xs text-yellow-700 dark:text-yellow-300">
@ -893,8 +893,11 @@ export const TaskDetailDialog = ({
</div>
) : null}
</div>
) : null}
</div>
)}
{/* Sections container with uniform spacing */}
<div className="min-w-0 space-y-1">
{/* Description */}
<CollapsibleTeamSection
title="Description"

View file

@ -101,7 +101,7 @@ export function computeEffectiveTeamModel(
const base = selectedModel || undefined;
if (limitContext) return base;
if (base === 'haiku') return base;
return base ? `${base}[1m]` : 'sonnet[1m]';
return base ? `${base}[1m]` : 'opus[1m]';
}
export interface TeamModelSelectorProps {

View file

@ -577,24 +577,22 @@ export const MessagesPanel = memo(function MessagesPanel({
) : undefined
}
headerExtra={
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
onTogglePosition();
}}
>
<PanelLeft size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Move to sidebar</TooltipContent>
</Tooltip>
</>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
onTogglePosition();
}}
>
<PanelLeft size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Move to sidebar</TooltipContent>
</Tooltip>
}
defaultOpen
action={<div className="flex items-center gap-2 px-2">{searchAndFilterBar}</div>}

View file

@ -964,6 +964,71 @@ body {
}
}
/* Skeleton-style shimmer for waiting members: a translucent light sweep */
.member-waiting-shimmer {
position: relative;
overflow: hidden;
opacity: 0.65;
}
.member-waiting-shimmer::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.06) 45%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.06) 55%,
transparent 100%
);
background-size: 200% 100%;
animation: member-shimmer-sweep 2s ease-in-out infinite;
pointer-events: none;
border-radius: inherit;
}
:root.light .member-waiting-shimmer::after {
background: linear-gradient(
90deg,
transparent 0%,
rgba(0, 0, 0, 0.04) 45%,
rgba(0, 0, 0, 0.07) 50%,
rgba(0, 0, 0, 0.04) 55%,
transparent 100%
);
background-size: 200% 100%;
animation: member-shimmer-sweep 2s ease-in-out infinite;
}
@keyframes member-shimmer-sweep {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@keyframes dot-online-jelly {
0% {
transform: scale(1);
}
25% {
transform: scale(1.6);
}
45% {
transform: scale(0.85);
}
65% {
transform: scale(1.15);
}
80% {
transform: scale(0.95);
}
100% {
transform: scale(1);
}
}
@keyframes member-fade-in {
from {
opacity: 0;

View file

@ -6,6 +6,7 @@
* for backward compatibility.
*/
import { addNavigationBreadcrumb } from '@renderer/sentry';
import {
createSearchNavigationRequest,
findTabBySession,
@ -13,7 +14,6 @@ import {
truncateLabel,
} from '@renderer/types/tabs';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { addNavigationBreadcrumb } from '@renderer/sentry';
import {
findPane,

View file

@ -41,6 +41,7 @@ export function getMemberDotClass(
leadActivity?: LeadActivityState
): string {
if (member.status === 'terminated') return STATUS_DOT_COLORS.terminated;
if (member.removedAt) return STATUS_DOT_COLORS.terminated;
if (isTeamProvisioning) return STATUS_DOT_COLORS.unknown;
if (isTeamAlive === false) return STATUS_DOT_COLORS.terminated;
if (leadActivity && isLeadMember(member)) {
@ -48,6 +49,11 @@ export function getMemberDotClass(
? `${STATUS_DOT_COLORS.active} animate-pulse`
: STATUS_DOT_COLORS.active;
}
// When team is alive, all non-terminated members are online
if (isTeamAlive) {
if (member.currentTaskId) return `${STATUS_DOT_COLORS.active} animate-pulse`;
return STATUS_DOT_COLORS.active;
}
if (member.status === 'unknown') return STATUS_DOT_COLORS.unknown;
if (member.currentTaskId) return STATUS_DOT_COLORS.active;
return member.status === 'active' ? STATUS_DOT_COLORS.active : STATUS_DOT_COLORS.idle;
@ -81,13 +87,15 @@ export function getPresenceLabel(
export const SPAWN_DOT_COLORS: Record<MemberSpawnStatus, string> = {
offline: 'bg-zinc-600',
spawning: 'bg-amber-400 animate-pulse',
online: 'bg-emerald-400',
waiting: 'bg-zinc-400 animate-pulse',
spawning: 'bg-amber-400',
online: 'bg-emerald-400 animate-[dot-online-jelly_0.45s_ease-out]',
error: 'bg-red-400',
};
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
offline: 'offline',
waiting: 'waiting',
spawning: 'spawning',
online: 'online',
error: 'spawn failed',
@ -134,8 +142,10 @@ export function getSpawnCardClass(spawnStatus: MemberSpawnStatus | undefined): s
switch (spawnStatus) {
case 'offline':
return 'opacity-40';
case 'waiting':
return 'member-waiting-shimmer';
case 'spawning':
return 'opacity-70 animate-[member-spawn-pulse_2s_ease-in-out_infinite]';
return '';
case 'online':
return 'animate-[member-fade-in_0.4s_ease-out]';
case 'error':

View file

@ -26,7 +26,7 @@ export function linkifyMentionsInMarkdown(
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(
// eslint-disable-next-line no-useless-escape -- backslash-quote and backslash-hyphen needed in template literal for RegExp
`(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`,
`(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}'\u2019-]|$)`,
'gi'
);
@ -58,7 +58,7 @@ export function linkifyTeamMentionsInMarkdown(
const escaped = sorted.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(
// eslint-disable-next-line no-useless-escape -- backslash-quote and backslash-hyphen needed in template literal for RegExp
`(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`,
`(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}'\u2019-]|$)`,
'gi'
);

View file

@ -251,8 +251,17 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
try {
parsed = JSON.parse(trimmed);
} catch {
// Non-JSON line (truncated, marker, etc.) — flush and skip
flushGroup();
// Non-JSON line (stderr debug output, truncated data, etc.)
// Show as raw output so the user can see CLI stderr activity.
if (trimmed.length > 0) {
if (!currentTimestamp) currentTimestamp = new Date();
if (!currentGroupId) currentGroupId = `stderr-${groups.length}-${lineIndex}`;
currentItems.push({
type: 'output',
content: trimmed,
timestamp: currentTimestamp,
});
}
continue;
}

View file

@ -368,7 +368,7 @@ export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown';
* - online: tool_result received, agent is active
* - error: spawn failed (tool_result with error)
*/
export type MemberSpawnStatus = 'offline' | 'spawning' | 'online' | 'error';
export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
@ -604,6 +604,8 @@ export interface TeamProvisioningProgress {
teamName: string;
state: Exclude<TeamProvisioningState, 'idle'>;
message: string;
/** Visual severity for the message subtitle: 'error' (red), 'warning' (amber), or default (muted). */
messageSeverity?: 'error' | 'warning';
startedAt: string;
updatedAt: string;
pid?: number;