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:
parent
2eac440fe2
commit
7b13a4c398
21 changed files with 331 additions and 125 deletions
12
README.md
12
README.md
|
|
@ -5,7 +5,7 @@
|
|||
<a href="docs/screenshots/8.png"><img src="docs/screenshots/8.png" width="75" alt="Task Detail" /></a>
|
||||
<img src="resources/icons/png/1024x1024.png" alt="Claude Agent Teams UI" width="80" />
|
||||
<a href="docs/screenshots/9.png"><img src="docs/screenshots/9.png" width="75" alt="Execution Logs" /></a>
|
||||
<a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.jpg" width="75" alt="Agent Comments" /></a>
|
||||
<a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.png" width="75" alt="Agent Comments" /></a>
|
||||
<a href="docs/screenshots/4.png"><img src="docs/screenshots/4.png" width="75" alt="Create Team" /></a>
|
||||
<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) | ❌ | ❌ |
|
||||
|
|
|
|||
BIN
docs/screenshots/more/image copy.png
Normal file
BIN
docs/screenshots/more/image copy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 504 KiB |
BIN
docs/screenshots/more/image.png
Normal file
BIN
docs/screenshots/more/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 448 KiB |
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue