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; <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; <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/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/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> <a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
</p> </p>
@ -76,14 +76,20 @@ No prerequisites — Claude Code can be installed and configured directly from t
## Table of contents ## Table of contents
- [Installation](#installation)
- [Table of contents](#table-of-contents)
- [What is this](#what-is-this) - [What is this](#what-is-this)
- [Comparison](#comparison) - [Comparison](#comparison)
- [Quick start](#quick-start) - [Quick start](#quick-start)
- [FAQ](#faq) - [FAQ](#faq)
- [Roadmap](#roadmap)
- [Development](#development) - [Development](#development)
- [Tech stack](#tech-stack) - [Tech stack](#tech-stack)
- [Build for distribution](#build-for-distribution)
- [Scripts](#scripts)
- [Roadmap](#roadmap)
- [Contributing](#contributing) - [Contributing](#contributing)
- [Security](#security)
- [License](#license)
## What is this ## What is this
@ -149,7 +155,7 @@ A new approach to task management with AI agent teams.
| **Hunk-level review** | ✅ Accept / reject individual hunks | ❌ | ❌ | ✅ | ❌ | | **Hunk-level review** | ✅ Accept / reject individual hunks | ❌ | ❌ | ✅ | ❌ |
| **Built-in code editor** | ✅ With Git support | ❌ | ❌ | ✅ Full IDE | ❌ | | **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) | | **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) | | **Review workflow** | ✅ Agents review each other | ❌ | ⚠️ Auto QA pipeline | ❌ | ✅⚠️ (no UI) |
| **Zero setup** | ✅ | ❌ Config required | ❌ Config required | ✅ | ⚠️ CLI install required | | **Zero setup** | ✅ | ❌ Config required | ❌ Config required | ✅ | ⚠️ CLI install required |
| **Kanban board** | ✅ 5 columns, real-time | ✅ | ✅ 6 columns (pipeline) | ❌ | ❌ | | **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 * - config:testTrigger: Test a trigger against historical session data
*/ */
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
import { syncTelemetryFlag } from '@main/sentry'; import { syncTelemetryFlag } from '@main/sentry';
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
import { getErrorMessage } from '@shared/utils/errorHandling'; import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { execFile } from 'child_process'; import { execFile } from 'child_process';

View file

@ -40,6 +40,7 @@ import { calculateMetrics } from '@main/utils/jsonl';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { startMainSpan } from '../../sentry'; import { startMainSpan } from '../../sentry';
import type { WaterfallData, WaterfallItem } from '@shared/types'; import type { WaterfallData, WaterfallItem } from '@shared/types';
const logger = createLogger('Service:ChunkBuilder'); 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 { parseJsonlFile } from '@main/utils/jsonl';
import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder'; import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { startMainSpan } from '../../sentry';
import { import {
extractMarkdownPlainText, extractMarkdownPlainText,
findMarkdownSearchMatches, findMarkdownSearchMatches,
} from '@shared/utils/markdownTextSearch'; } from '@shared/utils/markdownTextSearch';
import * as path from 'path'; import * as path from 'path';
import { startMainSpan } from '../../sentry';
import { SearchTextCache } from './SearchTextCache'; import { SearchTextCache } from './SearchTextCache';
import { extractSearchableEntries } from './SearchTextExtractor'; import { extractSearchableEntries } from './SearchTextExtractor';
import { subprojectRegistry } from './SubprojectRegistry'; import { subprojectRegistry } from './SubprojectRegistry';

View file

@ -27,12 +27,22 @@ import { useShallow } from 'zustand/react/shallow';
const logger = createLogger('Component:DashboardView'); const logger = createLogger('Component:DashboardView');
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { formatDistanceToNow } from 'date-fns'; 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 { CliStatusBanner } from './CliStatusBanner';
import { DashboardUpdateBanner } from './DashboardUpdateBanner'; import { DashboardUpdateBanner } from './DashboardUpdateBanner';
import type { RepositoryGroup } from '@renderer/types/data'; import type { RepositoryGroup } from '@renderer/types/data';
import type { TeamSummary } from '@shared/types';
// ============================================================================= // =============================================================================
// Command Search Input // Command Search Input
@ -134,6 +144,7 @@ interface RepositoryCardProps {
isHighlighted?: boolean; isHighlighted?: boolean;
taskCounts?: TaskStatusCounts; taskCounts?: TaskStatusCounts;
tasksLoading?: boolean; tasksLoading?: boolean;
activeTeams?: TeamSummary[];
} }
const RepositoryCard = ({ const RepositoryCard = ({
@ -142,6 +153,7 @@ const RepositoryCard = ({
isHighlighted, isHighlighted,
taskCounts, taskCounts,
tasksLoading, tasksLoading,
activeTeams,
}: Readonly<RepositoryCardProps>): React.JSX.Element => { }: Readonly<RepositoryCardProps>): React.JSX.Element => {
const lastActivity = repo.mostRecentSession const lastActivity = repo.mostRecentSession
? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true }) ? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true })
@ -223,6 +235,14 @@ const RepositoryCard = ({
boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined, 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 */} {/* Icon + Project name */}
<div className="mb-1 flex items-center gap-2.5"> <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"> <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> </button>
); );
}; };
@ -518,6 +553,7 @@ const ProjectsGrid = ({
globalTasksLoading, globalTasksLoading,
fetchAllTasks, fetchAllTasks,
openTeamsTab, openTeamsTab,
teams,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
repositoryGroups: s.repositoryGroups, repositoryGroups: s.repositoryGroups,
@ -529,11 +565,13 @@ const ProjectsGrid = ({
globalTasksLoading: s.globalTasksLoading, globalTasksLoading: s.globalTasksLoading,
fetchAllTasks: s.fetchAllTasks, fetchAllTasks: s.fetchAllTasks,
openTeamsTab: s.openTeamsTab, openTeamsTab: s.openTeamsTab,
teams: s.teams,
})) }))
); );
const hasFetchedTasksRef = React.useRef(false); const hasFetchedTasksRef = React.useRef(false);
const [visibleProjects, setVisibleProjects] = useState(maxProjects); const [visibleProjects, setVisibleProjects] = useState(maxProjects);
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
if (repositoryGroups.length === 0 && !repositoryGroupsLoading && !repositoryGroupsError) { if (repositoryGroups.length === 0 && !repositoryGroupsLoading && !repositoryGroupsError) {
@ -553,6 +591,37 @@ const ProjectsGrid = ({
} }
}, [repositoryGroups.length, repositoryGroupsLoading, fetchAllTasks]); }, [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(() => { useEffect(() => {
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
setVisibleProjects(maxProjects); setVisibleProjects(maxProjects);
@ -699,6 +768,20 @@ const ProjectsGrid = ({
}, },
{ pending: 0, inProgress: 0, completed: 0 } { 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 ( return (
<RepositoryCard <RepositoryCard
key={repo.id} key={repo.id}
@ -710,6 +793,7 @@ const ProjectsGrid = ({
isHighlighted={!!searchQuery.trim()} isHighlighted={!!searchQuery.trim()}
taskCounts={globalTasksLoading ? undefined : counts} taskCounts={globalTasksLoading ? undefined : counts}
tasksLoading={globalTasksLoading} tasksLoading={globalTasksLoading}
activeTeams={repoActiveTeams.length > 0 ? repoActiveTeams : undefined}
/> />
); );
})} })}

View file

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

View file

@ -23,6 +23,8 @@ export interface ProvisioningProgressBlockProps {
title: string; title: string;
/** Optional status message */ /** Optional status message */
message?: string | null; message?: string | null;
/** Visual severity for the message subtitle */
messageSeverity?: 'error' | 'warning';
/** Visual tone (e.g. highlight errors) */ /** Visual tone (e.g. highlight errors) */
tone?: 'default' | 'error'; tone?: 'default' | 'error';
/** Whether Live output is expanded by default */ /** Whether Live output is expanded by default */
@ -118,6 +120,7 @@ function sanitizeAssistantOutput(raw?: string, isError = false): string | null {
export const ProvisioningProgressBlock = ({ export const ProvisioningProgressBlock = ({
title, title,
message, message,
messageSeverity,
tone = 'default', tone = 'default',
defaultLiveOutputOpen = true, defaultLiveOutputOpen = true,
currentStepIndex, currentStepIndex,
@ -218,7 +221,11 @@ export const ProvisioningProgressBlock = ({
<p <p
className={cn( className={cn(
'mt-1.5 text-xs', '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} {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); const recentPaths = getRecentProjects(team);
if (recentPaths.length === 0) return null; if (recentPaths.length === 0) return null;
return ( return (
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]"> <div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<FolderOpen size={10} className="shrink-0" /> {matchesCurrentProject ? (
<span className="truncate"> <span
{recentPaths.map((p, i) => ( className={`inline-flex items-center gap-1 truncate rounded-full px-2 py-0.5 text-[12px] font-medium ${
<span key={p} title={p}> isLight ? 'bg-emerald-100 text-emerald-700' : 'bg-emerald-500/15 text-emerald-400'
{i === 0 && (status === 'active' || status === 'idle') ? ( }`}
<span className="text-emerald-400">{folderName(p)}</span> >
) : ( <FolderOpen size={12} className="shrink-0" />
folderName(p) {recentPaths.map((p, i) => (
)} <span key={p} title={p}>
{i < recentPaths.length - 1 ? ', ' : ''} {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>
))} </>
</span> )}
</div> </div>
); );
} }
@ -771,11 +794,7 @@ export const TeamListView = (): React.JSX.Element => {
key={team.teamName} key={team.teamName}
role="button" role="button"
tabIndex={0} 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)] ${ 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)]"
matchesCurrentProject
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
: 'border-[var(--color-border)]'
}`}
style={ style={
teamColorSet teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border } ? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
@ -959,7 +978,7 @@ export const TeamListView = (): React.JSX.Element => {
</div> </div>
); );
})()} })()}
{renderTeamRecentPaths(team, status)} {renderTeamRecentPaths(team, status, matchesCurrentProject, isLight)}
</div> </div>
</div> </div>
</div> </div>

View file

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

View file

@ -123,7 +123,7 @@ export const ToolApprovalDiffPreview: React.FC<ToolApprovalDiffPreviewProps> = (
<div className="mt-2"> <div className="mt-2">
{diff.loading && ( {diff.loading && (
<div <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={{ style={{
backgroundColor: 'var(--color-surface)', backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)', borderColor: 'var(--color-border)',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -251,8 +251,17 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
try { try {
parsed = JSON.parse(trimmed); parsed = JSON.parse(trimmed);
} catch { } catch {
// Non-JSON line (truncated, marker, etc.) — flush and skip // Non-JSON line (stderr debug output, truncated data, etc.)
flushGroup(); // 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; continue;
} }

View file

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